Merge branch 'ais/ship_position' into develop

This commit is contained in:
hyojin kim 2025-12-05 09:33:59 +09:00
커밋 1b7fa47dbd
19개의 변경된 파일2708개의 추가작업 그리고 257개의 파일을 삭제

파일 보기

@ -30,23 +30,29 @@ public class SwaggerConfig {
@Value("${server.port:8081}") @Value("${server.port:8081}")
private int serverPort; private int serverPort;
@Value("${server.servlet.context-path:}")
private String contextPath;
@Bean @Bean
public OpenAPI openAPI() { public OpenAPI openAPI() {
return new OpenAPI() return new OpenAPI()
.info(apiInfo()) .info(apiInfo())
.servers(List.of( .servers(List.of(
new Server() new Server()
.url("http://localhost:" + serverPort) .url("http://localhost:" + serverPort + contextPath)
.description("로컬 개발 서버"), .description("로컬 개발 서버"),
new Server() new Server()
.url("http://10.26.252.39:" + serverPort) .url("http://10.26.252.39:" + serverPort + contextPath)
.description("로컬 개발 서버"), .description("로컬 개발 서버"),
new Server() new Server()
.url("http://211.208.115.83:" + serverPort) .url("http://211.208.115.83:" + serverPort + contextPath)
.description("중계 서버"), .description("중계 서버"),
new Server() new Server()
.url("http://10.187.58.58:" + serverPort) .url("http://10.187.58.58:" + serverPort + contextPath)
.description("운영 서버") .description("운영 서버"),
new Server()
.url("https://mda.kcg.go.kr" + contextPath)
.description("운영 서버 프록시")
)); ));
} }

파일 보기

@ -1,50 +1,133 @@
package com.snp.batch.global.partition; package com.snp.batch.global.partition;
import lombok.Getter; import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional;
/** /**
* 파티션 관리 대상 테이블 설정 * 파티션 관리 설정 (application.yml 기반)
* *
* Daily 파티션: 매일 실행 * 설정 예시:
* Monthly 파티션: 매월 말일에만 실행 * app.batch.partition:
* daily-tables:
* - schema: snp_data
* table-name: ais_target
* partition-column: message_timestamp
* periods-ahead: 3
* monthly-tables:
* - schema: snp_data
* table-name: some_table
* partition-column: created_at
* periods-ahead: 2
* retention:
* daily-default-days: 14
* monthly-default-months: 1
* custom:
* - table-name: ais_target
* retention-days: 30
*/ */
@Getter @Getter
@Setter
@Component @Component
@ConfigurationProperties(prefix = "app.batch.partition")
public class PartitionConfig { public class PartitionConfig {
/** /**
* Daily 파티션 대상 테이블 (파티션 네이밍: {table}_YYYY_MM_DD) * 일별 파티션 테이블 목록 (파티션 네이밍: {table}_YYMMDD)
*/ */
private final List<PartitionTableInfo> dailyPartitionTables = List.of( private List<PartitionTableConfig> dailyTables = new ArrayList<>();
// 추후 daily 파티션 테이블 추가
);
/** /**
* Monthly 파티션 대상 테이블 (파티션 네이밍: {table}_YYYY_MM) * 월별 파티션 테이블 목록 (파티션 네이밍: {table}_YYYY_MM)
*/ */
private final List<PartitionTableInfo> monthlyPartitionTables = List.of( private List<PartitionTableConfig> monthlyTables = new ArrayList<>();
new PartitionTableInfo(
"snp_data",
"ais_target",
"message_timestamp",
2 // 미리 생성할 개월
)
);
/** /**
* 파티션 테이블 정보 * 보관기간 설정
*/ */
public record PartitionTableInfo( private RetentionConfig retention = new RetentionConfig();
String schema,
String tableName, /**
String partitionColumn, * 파티션 테이블 설정
int periodsAhead // 미리 생성할 기간 (daily: , monthly: ) */
) { @Getter
@Setter
public static class PartitionTableConfig {
private String schema = "snp_data";
private String tableName;
private String partitionColumn;
private int periodsAhead = 3; // 미리 생성할 기간 (daily: , monthly: )
public String getFullTableName() { public String getFullTableName() {
return schema + "." + tableName; return schema + "." + tableName;
} }
} }
/**
* 보관기간 설정
*/
@Getter
@Setter
public static class RetentionConfig {
/**
* 일별 파티션 기본 보관기간 ()
*/
private int dailyDefaultDays = 14;
/**
* 월별 파티션 기본 보관기간 (개월)
*/
private int monthlyDefaultMonths = 1;
/**
* 개별 테이블 보관기간 설정
*/
private List<CustomRetention> custom = new ArrayList<>();
}
/**
* 개별 테이블 보관기간 설정
*/
@Getter
@Setter
public static class CustomRetention {
private String tableName;
private Integer retentionDays; // 단위 보관기간 (일별 파티션용)
private Integer retentionMonths; // 단위 보관기간 (월별 파티션용)
}
/**
* 일별 파티션 테이블의 보관기간 조회 ( 단위)
*/
public int getDailyRetentionDays(String tableName) {
return getCustomRetention(tableName)
.map(c -> c.getRetentionDays() != null ? c.getRetentionDays() : retention.getDailyDefaultDays())
.orElse(retention.getDailyDefaultDays());
}
/**
* 월별 파티션 테이블의 보관기간 조회 ( 단위)
*/
public int getMonthlyRetentionMonths(String tableName) {
return getCustomRetention(tableName)
.map(c -> c.getRetentionMonths() != null ? c.getRetentionMonths() : retention.getMonthlyDefaultMonths())
.orElse(retention.getMonthlyDefaultMonths());
}
/**
* 개별 테이블 보관기간 설정 조회
*/
private Optional<CustomRetention> getCustomRetention(String tableName) {
if (retention.getCustom() == null) {
return Optional.empty();
}
return retention.getCustom().stream()
.filter(c -> tableName.equals(c.getTableName()))
.findFirst();
}
} }

파일 보기

@ -1,6 +1,6 @@
package com.snp.batch.global.partition; package com.snp.batch.global.partition;
import com.snp.batch.global.partition.PartitionConfig.PartitionTableInfo; import com.snp.batch.global.partition.PartitionConfig.PartitionTableConfig;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.StepContribution;
@ -20,8 +20,12 @@ import java.util.List;
* 파티션 관리 Tasklet * 파티션 관리 Tasklet
* *
* 스케줄: 매일 실행 * 스케줄: 매일 실행
* - Daily 파티션: 매일 생성 * - Daily 파티션: 매일 생성/삭제 (네이밍: {table}_YYMMDD)
* - Monthly 파티션: 매월 말일에만 생성 * - Monthly 파티션: 매월 말일에만 생성/삭제 (네이밍: {table}_YYYY_MM)
*
* 보관기간:
* - 기본값: 일별 14일, 월별 1개월
* - 개별 테이블별 보관기간 설정 가능 (application.yml)
*/ */
@Slf4j @Slf4j
@Component @Component
@ -31,6 +35,9 @@ public class PartitionManagerTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final PartitionConfig partitionConfig; private final PartitionConfig partitionConfig;
private static final DateTimeFormatter DAILY_PARTITION_FORMAT = DateTimeFormatter.ofPattern("yyMMdd");
private static final DateTimeFormatter MONTHLY_PARTITION_FORMAT = DateTimeFormatter.ofPattern("yyyy_MM");
private static final String PARTITION_EXISTS_SQL = """ private static final String PARTITION_EXISTS_SQL = """
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 FROM pg_class c SELECT 1 FROM pg_class c
@ -41,6 +48,17 @@ public class PartitionManagerTasklet implements Tasklet {
) )
"""; """;
private static final String FIND_PARTITIONS_SQL = """
SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_inherits i ON i.inhrelid = c.oid
WHERE n.nspname = ?
AND c.relname LIKE ?
AND c.relkind = 'r'
ORDER BY c.relname
""";
@Override @Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
@ -52,14 +70,24 @@ public class PartitionManagerTasklet implements Tasklet {
log.info("월 말일 여부: {}", isLastDayOfMonth); log.info("월 말일 여부: {}", isLastDayOfMonth);
log.info("========================================"); log.info("========================================");
// Daily 파티션 처리 (매일) // 1. Daily 파티션 생성 (매일)
processDailyPartitions(today); createDailyPartitions(today);
// Monthly 파티션 처리 (매월 말일만) // 2. Daily 파티션 삭제 (보관기간 초과분)
deleteDailyPartitions(today);
// 3. Monthly 파티션 생성 (매월 말일만)
if (isLastDayOfMonth) { if (isLastDayOfMonth) {
processMonthlyPartitions(today); createMonthlyPartitions(today);
} else { } else {
log.info("Monthly 파티션: 말일이 아니므로 스킵"); log.info("Monthly 파티션 생성: 말일이 아니므로 스킵");
}
// 4. Monthly 파티션 삭제 (매월 1일에만, 보관기간 초과분)
if (today.getDayOfMonth() == 1) {
deleteMonthlyPartitions(today);
} else {
log.info("Monthly 파티션 삭제: 1일이 아니므로 스킵");
} }
log.info("========================================"); log.info("========================================");
@ -76,36 +104,38 @@ public class PartitionManagerTasklet implements Tasklet {
return date.getDayOfMonth() == YearMonth.from(date).lengthOfMonth(); return date.getDayOfMonth() == YearMonth.from(date).lengthOfMonth();
} }
/** // ==================== Daily 파티션 생성 ====================
* Daily 파티션 처리
*/
private void processDailyPartitions(LocalDate today) {
List<PartitionTableInfo> tables = partitionConfig.getDailyPartitionTables();
if (tables.isEmpty()) { /**
log.info("Daily 파티션: 대상 테이블 없음"); * Daily 파티션 생성
*/
private void createDailyPartitions(LocalDate today) {
List<PartitionTableConfig> tables = partitionConfig.getDailyTables();
if (tables == null || tables.isEmpty()) {
log.info("Daily 파티션 생성: 대상 테이블 없음");
return; return;
} }
log.info("Daily 파티션 처리 시작: {} 개 테이블", tables.size()); log.info("Daily 파티션 생성 시작: {} 개 테이블", tables.size());
for (PartitionTableInfo table : tables) { for (PartitionTableConfig table : tables) {
processDailyPartition(table, today); createDailyPartitionsForTable(table, today);
} }
} }
/** /**
* 개별 Daily 파티션 생성 * 개별 테이블 Daily 파티션 생성
*/ */
private void processDailyPartition(PartitionTableInfo table, LocalDate today) { private void createDailyPartitionsForTable(PartitionTableConfig table, LocalDate today) {
List<String> created = new ArrayList<>(); List<String> created = new ArrayList<>();
List<String> skipped = new ArrayList<>(); List<String> skipped = new ArrayList<>();
for (int i = 0; i <= table.periodsAhead(); i++) { for (int i = 0; i <= table.getPeriodsAhead(); i++) {
LocalDate targetDate = today.plusDays(i); LocalDate targetDate = today.plusDays(i);
String partitionName = getDailyPartitionName(table.tableName(), targetDate); String partitionName = getDailyPartitionName(table.getTableName(), targetDate);
if (partitionExists(table.schema(), partitionName)) { if (partitionExists(table.getSchema(), partitionName)) {
skipped.add(partitionName); skipped.add(partitionName);
} else { } else {
createDailyPartition(table, targetDate, partitionName); createDailyPartition(table, targetDate, partitionName);
@ -113,40 +143,97 @@ public class PartitionManagerTasklet implements Tasklet {
} }
} }
log.info("[{}] Daily 파티션 - 생성: {}, 스킵: {}", log.info("[{}] Daily 파티션 생성 - 생성: {}, 스킵: {}",
table.tableName(), created.size(), skipped.size()); table.getTableName(), created.size(), skipped.size());
if (!created.isEmpty()) {
log.info("[{}] 생성된 파티션: {}", table.getTableName(), created);
}
} }
/** // ==================== Daily 파티션 삭제 ====================
* Monthly 파티션 처리
*/
private void processMonthlyPartitions(LocalDate today) {
List<PartitionTableInfo> tables = partitionConfig.getMonthlyPartitionTables();
if (tables.isEmpty()) { /**
log.info("Monthly 파티션: 대상 테이블 없음"); * Daily 파티션 삭제 (보관기간 초과분)
*/
private void deleteDailyPartitions(LocalDate today) {
List<PartitionTableConfig> tables = partitionConfig.getDailyTables();
if (tables == null || tables.isEmpty()) {
log.info("Daily 파티션 삭제: 대상 테이블 없음");
return; return;
} }
log.info("Monthly 파티션 처리 시작: {} 개 테이블", tables.size()); log.info("Daily 파티션 삭제 시작: {} 개 테이블", tables.size());
for (PartitionTableInfo table : tables) { for (PartitionTableConfig table : tables) {
processMonthlyPartition(table, today); int retentionDays = partitionConfig.getDailyRetentionDays(table.getTableName());
deleteDailyPartitionsForTable(table, today, retentionDays);
} }
} }
/** /**
* 개별 Monthly 파티션 생성 * 개별 테이블 Daily 파티션 삭제
*/ */
private void processMonthlyPartition(PartitionTableInfo table, LocalDate today) { private void deleteDailyPartitionsForTable(PartitionTableConfig table, LocalDate today, int retentionDays) {
LocalDate cutoffDate = today.minusDays(retentionDays);
String likePattern = table.getTableName() + "_%";
List<String> partitions = jdbcTemplate.queryForList(
FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern);
List<String> deleted = new ArrayList<>();
for (String partitionName : partitions) {
// 파티션 이름에서 날짜 추출 (table_YYMMDD)
LocalDate partitionDate = parseDailyPartitionDate(table.getTableName(), partitionName);
if (partitionDate != null && partitionDate.isBefore(cutoffDate)) {
dropPartition(table.getSchema(), partitionName);
deleted.add(partitionName);
}
}
if (!deleted.isEmpty()) {
log.info("[{}] Daily 파티션 삭제 - 보관기간: {}일, 삭제: {} 개",
table.getTableName(), retentionDays, deleted.size());
log.info("[{}] 삭제된 파티션: {}", table.getTableName(), deleted);
} else {
log.info("[{}] Daily 파티션 삭제 - 보관기간: {}일, 삭제할 파티션 없음",
table.getTableName(), retentionDays);
}
}
// ==================== Monthly 파티션 생성 ====================
/**
* Monthly 파티션 생성
*/
private void createMonthlyPartitions(LocalDate today) {
List<PartitionTableConfig> tables = partitionConfig.getMonthlyTables();
if (tables == null || tables.isEmpty()) {
log.info("Monthly 파티션 생성: 대상 테이블 없음");
return;
}
log.info("Monthly 파티션 생성 시작: {} 개 테이블", tables.size());
for (PartitionTableConfig table : tables) {
createMonthlyPartitionsForTable(table, today);
}
}
/**
* 개별 테이블 Monthly 파티션 생성
*/
private void createMonthlyPartitionsForTable(PartitionTableConfig table, LocalDate today) {
List<String> created = new ArrayList<>(); List<String> created = new ArrayList<>();
List<String> skipped = new ArrayList<>(); List<String> skipped = new ArrayList<>();
for (int i = 0; i <= table.periodsAhead(); i++) { for (int i = 0; i <= table.getPeriodsAhead(); i++) {
LocalDate targetDate = today.plusMonths(i).withDayOfMonth(1); LocalDate targetDate = today.plusMonths(i).withDayOfMonth(1);
String partitionName = getMonthlyPartitionName(table.tableName(), targetDate); String partitionName = getMonthlyPartitionName(table.getTableName(), targetDate);
if (partitionExists(table.schema(), partitionName)) { if (partitionExists(table.getSchema(), partitionName)) {
skipped.add(partitionName); skipped.add(partitionName);
} else { } else {
createMonthlyPartition(table, targetDate, partitionName); createMonthlyPartition(table, targetDate, partitionName);
@ -154,27 +241,127 @@ public class PartitionManagerTasklet implements Tasklet {
} }
} }
log.info("[{}] Monthly 파티션 - 생성: {}, 스킵: {}", log.info("[{}] Monthly 파티션 생성 - 생성: {}, 스킵: {}",
table.tableName(), created.size(), skipped.size()); table.getTableName(), created.size(), skipped.size());
if (!created.isEmpty()) { if (!created.isEmpty()) {
log.info("[{}] 생성된 파티션: {}", table.tableName(), created); log.info("[{}] 생성된 파티션: {}", table.getTableName(), created);
}
}
// ==================== Monthly 파티션 삭제 ====================
/**
* Monthly 파티션 삭제 (보관기간 초과분)
*/
private void deleteMonthlyPartitions(LocalDate today) {
List<PartitionTableConfig> tables = partitionConfig.getMonthlyTables();
if (tables == null || tables.isEmpty()) {
log.info("Monthly 파티션 삭제: 대상 테이블 없음");
return;
}
log.info("Monthly 파티션 삭제 시작: {} 개 테이블", tables.size());
for (PartitionTableConfig table : tables) {
int retentionMonths = partitionConfig.getMonthlyRetentionMonths(table.getTableName());
deleteMonthlyPartitionsForTable(table, today, retentionMonths);
} }
} }
/** /**
* Daily 파티션 이름 생성 (table_YYYY_MM_DD) * 개별 테이블 Monthly 파티션 삭제
*/
private void deleteMonthlyPartitionsForTable(PartitionTableConfig table, LocalDate today, int retentionMonths) {
LocalDate cutoffDate = today.minusMonths(retentionMonths).withDayOfMonth(1);
String likePattern = table.getTableName() + "_%";
List<String> partitions = jdbcTemplate.queryForList(
FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern);
List<String> deleted = new ArrayList<>();
for (String partitionName : partitions) {
// 파티션 이름에서 날짜 추출 (table_YYYY_MM)
LocalDate partitionDate = parseMonthlyPartitionDate(table.getTableName(), partitionName);
if (partitionDate != null && partitionDate.isBefore(cutoffDate)) {
dropPartition(table.getSchema(), partitionName);
deleted.add(partitionName);
}
}
if (!deleted.isEmpty()) {
log.info("[{}] Monthly 파티션 삭제 - 보관기간: {}개월, 삭제: {} 개",
table.getTableName(), retentionMonths, deleted.size());
log.info("[{}] 삭제된 파티션: {}", table.getTableName(), deleted);
} else {
log.info("[{}] Monthly 파티션 삭제 - 보관기간: {}개월, 삭제할 파티션 없음",
table.getTableName(), retentionMonths);
}
}
// ==================== 파티션 이름 생성 ====================
/**
* Daily 파티션 이름 생성 (table_YYMMDD)
*/ */
private String getDailyPartitionName(String tableName, LocalDate date) { private String getDailyPartitionName(String tableName, LocalDate date) {
return tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyy_MM_dd")); return tableName + "_" + date.format(DAILY_PARTITION_FORMAT);
} }
/** /**
* Monthly 파티션 이름 생성 (table_YYYY_MM) * Monthly 파티션 이름 생성 (table_YYYY_MM)
*/ */
private String getMonthlyPartitionName(String tableName, LocalDate date) { private String getMonthlyPartitionName(String tableName, LocalDate date) {
return tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyy_MM")); return tableName + "_" + date.format(MONTHLY_PARTITION_FORMAT);
} }
// ==================== 파티션 이름에서 날짜 추출 ====================
/**
* Daily 파티션 이름에서 날짜 추출 (table_YYMMDD -> LocalDate)
*/
private LocalDate parseDailyPartitionDate(String tableName, String partitionName) {
try {
String prefix = tableName + "_";
if (!partitionName.startsWith(prefix)) {
return null;
}
String dateStr = partitionName.substring(prefix.length());
// YYMMDD 형식 (6자리)
if (dateStr.length() == 6 && dateStr.matches("\\d{6}")) {
return LocalDate.parse(dateStr, DAILY_PARTITION_FORMAT);
}
return null;
} catch (Exception e) {
log.trace("파티션 날짜 파싱 실패: {}", partitionName);
return null;
}
}
/**
* Monthly 파티션 이름에서 날짜 추출 (table_YYYY_MM -> LocalDate)
*/
private LocalDate parseMonthlyPartitionDate(String tableName, String partitionName) {
try {
String prefix = tableName + "_";
if (!partitionName.startsWith(prefix)) {
return null;
}
String dateStr = partitionName.substring(prefix.length());
// YYYY_MM 형식 (7자리)
if (dateStr.length() == 7 && dateStr.matches("\\d{4}_\\d{2}")) {
return LocalDate.parse(dateStr + "_01", DateTimeFormatter.ofPattern("yyyy_MM_dd"));
}
return null;
} catch (Exception e) {
log.trace("파티션 날짜 파싱 실패: {}", partitionName);
return null;
}
}
// ==================== DB 작업 ====================
/** /**
* 파티션 존재 여부 확인 * 파티션 존재 여부 확인
*/ */
@ -186,14 +373,14 @@ public class PartitionManagerTasklet implements Tasklet {
/** /**
* Daily 파티션 생성 * Daily 파티션 생성
*/ */
private void createDailyPartition(PartitionTableInfo table, LocalDate targetDate, String partitionName) { private void createDailyPartition(PartitionTableConfig table, LocalDate targetDate, String partitionName) {
LocalDate endDate = targetDate.plusDays(1); LocalDate endDate = targetDate.plusDays(1);
String sql = String.format(""" String sql = String.format("""
CREATE TABLE %s.%s PARTITION OF %s CREATE TABLE %s.%s PARTITION OF %s
FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00') FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00')
""", """,
table.schema(), partitionName, table.getFullTableName(), table.getSchema(), partitionName, table.getFullTableName(),
targetDate, endDate); targetDate, endDate);
jdbcTemplate.execute(sql); jdbcTemplate.execute(sql);
@ -203,7 +390,7 @@ public class PartitionManagerTasklet implements Tasklet {
/** /**
* Monthly 파티션 생성 * Monthly 파티션 생성
*/ */
private void createMonthlyPartition(PartitionTableInfo table, LocalDate targetDate, String partitionName) { private void createMonthlyPartition(PartitionTableConfig table, LocalDate targetDate, String partitionName) {
LocalDate startDate = targetDate.withDayOfMonth(1); LocalDate startDate = targetDate.withDayOfMonth(1);
LocalDate endDate = startDate.plusMonths(1); LocalDate endDate = startDate.plusMonths(1);
@ -211,10 +398,19 @@ public class PartitionManagerTasklet implements Tasklet {
CREATE TABLE %s.%s PARTITION OF %s CREATE TABLE %s.%s PARTITION OF %s
FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00') FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00')
""", """,
table.schema(), partitionName, table.getFullTableName(), table.getSchema(), partitionName, table.getFullTableName(),
startDate, endDate); startDate, endDate);
jdbcTemplate.execute(sql); jdbcTemplate.execute(sql);
log.debug("Monthly 파티션 생성: {}", partitionName); log.debug("Monthly 파티션 생성: {}", partitionName);
} }
/**
* 파티션 삭제
*/
private void dropPartition(String schema, String partitionName) {
String sql = String.format("DROP TABLE IF EXISTS %s.%s", schema, partitionName);
jdbcTemplate.execute(sql);
log.debug("파티션 삭제: {}", partitionName);
}
} }

파일 보기

@ -46,7 +46,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
received_date, collected_at, created_at, updated_at received_date, collected_at, created_at, updated_at
) VALUES ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?, ?, public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326),
?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
@ -203,9 +203,9 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
SELECT DISTINCT ON (mmsi) * SELECT DISTINCT ON (mmsi) *
FROM %s FROM %s
WHERE message_timestamp BETWEEN ? AND ? WHERE message_timestamp BETWEEN ? AND ?
AND ST_DWithin( AND public.ST_DWithin(
geom::geography, geom::geography,
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326)::geography,
? ?
) )
ORDER BY mmsi, message_timestamp DESC ORDER BY mmsi, message_timestamp DESC

파일 보기

@ -4,7 +4,7 @@ spring:
# PostgreSQL Database Configuration # PostgreSQL Database Configuration
datasource: datasource:
url: jdbc:postgresql://10.187.58.58:5432/mdadb?currentSchema=snp_data url: jdbc:postgresql://10.187.58.58:5432/mdadb?currentSchema=snp_data,public
username: mda username: mda
password: mda#8932 password: mda#8932
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
@ -28,7 +28,7 @@ spring:
batch: batch:
jdbc: jdbc:
table-prefix: "snp_data.batch_" table-prefix: "snp_data.batch_"
initialize-schema: always # Changed to 'never' as tables already exist initialize-schema: never # Changed to 'never' as tables already exist
job: job:
enabled: false # Prevent auto-run on startup enabled: false # Prevent auto-run on startup
@ -55,9 +55,9 @@ spring:
# Server Configuration # Server Configuration
server: server:
port: 8041 port: 9000
servlet: servlet:
context-path: / context-path: /snp-api
# Actuator Configuration # Actuator Configuration
management: management:
@ -69,18 +69,10 @@ management:
health: health:
show-details: always show-details: always
# Logging Configuration
# Logging Configuration (logback-spring.xml에서 상세 설정)
logging: logging:
level: config: classpath:logback-spring.xml
root: INFO
com.snp.batch: DEBUG
org.springframework.batch: DEBUG
org.springframework.jdbc: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: logs/snp-batch.log
# Custom Application Properties # Custom Application Properties
app: app:

파일 보기

@ -91,15 +91,33 @@ app:
schedule: schedule:
enabled: true enabled: true
cron: "0 0 * * * ?" # Every hour cron: "0 0 * * * ?" # Every hour
# AIS Target 배치 설정 # AIS Target 배치 설정
ais-target: ais-target:
since-seconds: 60 # API 조회 범위 (초) since-seconds: 60 # API 조회 범위 (초)
chunk-size: 5000 # 배치 청크 크기 chunk-size: 5000 # 배치 청크 크기
schedule: schedule:
cron: "15 * * * * ?" # 매 분 15초 실행 cron: "15 * * * * ?" # 매 분 15초 실행
partition:
months-ahead: 2 # 미리 생성할 파티션 개월 수
# AIS Target 캐시 설정 # AIS Target 캐시 설정
ais-target-cache: ais-target-cache:
ttl-minutes: 120 # 캐시 TTL (분) - 2시간 ttl-minutes: 120 # 캐시 TTL (분) - 2시간
max-size: 300000 # 최대 캐시 크기 - 30만 건 max-size: 300000 # 최대 캐시 크기 - 30만 건
# 파티션 관리 설정
partition:
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
daily-tables:
- schema: snp_data
table-name: ais_target
partition-column: message_timestamp
periods-ahead: 3 # 미리 생성할 일수
# 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
monthly-tables: [] # 현재 없음
# 기본 보관기간
retention:
daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
# 개별 테이블 보관기간 설정 (옵션)
custom:
# - table-name: ais_target
# retention-days: 30 # ais_target만 30일 보관

파일 보기

@ -3,8 +3,8 @@
-- ============================================ -- ============================================
-- 용도: 선박 AIS 위치 정보 저장 (항적 분석용) -- 용도: 선박 AIS 위치 정보 저장 (항적 분석용)
-- 수집 주기: 매 분 15초 -- 수집 주기: 매 분 15초
-- 예상 데이터량: 약 33,000건/분 -- 예상 데이터량: 약 33,000건/분, 일 20GB (인덱스 포함)
-- 파티셔닝: 월별 파티션 (ais_target_YYYY_MM) -- 파티셔닝: 일별 파티션 (ais_target_YYMMDD)
-- ============================================ -- ============================================
-- PostGIS 확장 활성화 (이미 설치되어 있다면 생략) -- PostGIS 확장 활성화 (이미 설치되어 있다면 생략)
@ -77,18 +77,26 @@ CREATE TABLE IF NOT EXISTS snp_data.ais_target (
) PARTITION BY RANGE (message_timestamp); ) PARTITION BY RANGE (message_timestamp);
-- ============================================ -- ============================================
-- 2. 초기 파티션 생성 (현재 월 + 다음 월) -- 2. 초기 파티션 생성 (현재 일 + 다음 3일)
-- ============================================ -- ============================================
-- 예: 2025년 12월과 2026년 1월 파티션 -- 파티션 네이밍: ais_target_YYMMDD
-- 실제 운영 시 create_ais_target_partition 함수로 자동 생성 -- 실제 운영 시 partitionManagerJob에서 자동 생성
-- 2025년 12월 파티션 -- 2024년 12월 4일 파티션 (예시)
CREATE TABLE IF NOT EXISTS snp_data.ais_target_2025_12 PARTITION OF snp_data.ais_target CREATE TABLE IF NOT EXISTS snp_data.ais_target_241204 PARTITION OF snp_data.ais_target
FOR VALUES FROM ('2025-12-01 00:00:00+00') TO ('2026-01-01 00:00:00+00'); FOR VALUES FROM ('2024-12-04 00:00:00+00') TO ('2024-12-05 00:00:00+00');
-- 2026년 1월 파티션 -- 2024년 12월 5일 파티션
CREATE TABLE IF NOT EXISTS snp_data.ais_target_2026_01 PARTITION OF snp_data.ais_target CREATE TABLE IF NOT EXISTS snp_data.ais_target_241205 PARTITION OF snp_data.ais_target
FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00'); FOR VALUES FROM ('2024-12-05 00:00:00+00') TO ('2024-12-06 00:00:00+00');
-- 2024년 12월 6일 파티션
CREATE TABLE IF NOT EXISTS snp_data.ais_target_241206 PARTITION OF snp_data.ais_target
FOR VALUES FROM ('2024-12-06 00:00:00+00') TO ('2024-12-07 00:00:00+00');
-- 2024년 12월 7일 파티션
CREATE TABLE IF NOT EXISTS snp_data.ais_target_241207 PARTITION OF snp_data.ais_target
FOR VALUES FROM ('2024-12-07 00:00:00+00') TO ('2024-12-08 00:00:00+00');
-- ============================================ -- ============================================
-- 3. 인덱스 생성 (각 파티션에 자동 상속) -- 3. 인덱스 생성 (각 파티션에 자동 상속)
@ -120,7 +128,7 @@ CREATE INDEX IF NOT EXISTS idx_ais_target_collected_at
ON snp_data.ais_target (collected_at DESC); ON snp_data.ais_target (collected_at DESC);
-- ============================================ -- ============================================
-- 4. 파티션 자동 생성 함수 -- 4. 파티션 자동 생성 함수 (일별)
-- ============================================ -- ============================================
-- 파티션 존재 여부 확인 함수 -- 파티션 존재 여부 확인 함수
@ -137,8 +145,8 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- 특정 의 파티션 생성 함수 -- 특정 의 파티션 생성 함수
CREATE OR REPLACE FUNCTION snp_data.create_ais_target_partition(target_date DATE) CREATE OR REPLACE FUNCTION snp_data.create_ais_target_daily_partition(target_date DATE)
RETURNS TEXT AS $$ RETURNS TEXT AS $$
DECLARE DECLARE
partition_name TEXT; partition_name TEXT;
@ -146,12 +154,12 @@ DECLARE
end_date DATE; end_date DATE;
create_sql TEXT; create_sql TEXT;
BEGIN BEGIN
-- 파티션 이름 생성: ais_target_YYYY_MM -- 파티션 이름 생성: ais_target_YYMMDD
partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYYY_MM'); partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD');
-- 시작/종료 날짜 계산 -- 시작/종료 날짜 계산
start_date := DATE_TRUNC('month', target_date)::DATE; start_date := target_date;
end_date := (DATE_TRUNC('month', target_date) + INTERVAL '1 month')::DATE; end_date := target_date + INTERVAL '1 day';
-- 이미 존재하면 스킵 -- 이미 존재하면 스킵
IF snp_data.partition_exists(partition_name) THEN IF snp_data.partition_exists(partition_name) THEN
@ -175,18 +183,18 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- 다음 N개월 파티션 사전 생성 함수 -- 다음 N 파티션 사전 생성 함수
CREATE OR REPLACE FUNCTION snp_data.create_future_ais_target_partitions(months_ahead INTEGER DEFAULT 2) CREATE OR REPLACE FUNCTION snp_data.create_future_ais_target_daily_partitions(days_ahead INTEGER DEFAULT 3)
RETURNS TABLE (partition_name TEXT, status TEXT) AS $$ RETURNS TABLE (partition_name TEXT, status TEXT) AS $$
DECLARE DECLARE
i INTEGER; i INTEGER;
target_date DATE; target_date DATE;
result TEXT; result TEXT;
BEGIN BEGIN
FOR i IN 0..months_ahead LOOP FOR i IN 0..days_ahead LOOP
target_date := DATE_TRUNC('month', CURRENT_DATE + (i || ' months')::INTERVAL)::DATE; target_date := CURRENT_DATE + i;
result := snp_data.create_ais_target_partition(target_date); result := snp_data.create_ais_target_daily_partition(target_date);
partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYYY_MM'); partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD');
status := result; status := result;
RETURN NEXT; RETURN NEXT;
END LOOP; END LOOP;
@ -194,17 +202,17 @@ END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- ============================================ -- ============================================
-- 5. 오래된 파티션 삭제 함수 -- 5. 오래된 파티션 삭제 함수 (일별)
-- ============================================ -- ============================================
-- 특정 의 파티션 삭제 함수 -- 특정 의 파티션 삭제 함수
CREATE OR REPLACE FUNCTION snp_data.drop_ais_target_partition(target_date DATE) CREATE OR REPLACE FUNCTION snp_data.drop_ais_target_daily_partition(target_date DATE)
RETURNS TEXT AS $$ RETURNS TEXT AS $$
DECLARE DECLARE
partition_name TEXT; partition_name TEXT;
drop_sql TEXT; drop_sql TEXT;
BEGIN BEGIN
partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYYY_MM'); partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD');
-- 존재하지 않으면 스킵 -- 존재하지 않으면 스킵
IF NOT snp_data.partition_exists(partition_name) THEN IF NOT snp_data.partition_exists(partition_name) THEN
@ -221,17 +229,17 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- N개월 이전 파티션 정리 함수 -- N 이전 파티션 정리 함수
CREATE OR REPLACE FUNCTION snp_data.cleanup_old_ais_target_partitions(retention_months INTEGER DEFAULT 3) CREATE OR REPLACE FUNCTION snp_data.cleanup_old_ais_target_daily_partitions(retention_days INTEGER DEFAULT 14)
RETURNS TABLE (partition_name TEXT, status TEXT) AS $$ RETURNS TABLE (partition_name TEXT, status TEXT) AS $$
DECLARE DECLARE
rec RECORD; rec RECORD;
partition_date DATE; partition_date DATE;
cutoff_date DATE; cutoff_date DATE;
BEGIN BEGIN
cutoff_date := DATE_TRUNC('month', CURRENT_DATE - (retention_months || ' months')::INTERVAL)::DATE; cutoff_date := CURRENT_DATE - retention_days;
-- ais_target_YYYY_MM 패턴의 파티션 조회 -- ais_target_YYMMDD 패턴의 파티션 조회
FOR rec IN FOR rec IN
SELECT c.relname SELECT c.relname
FROM pg_class c FROM pg_class c
@ -239,12 +247,13 @@ BEGIN
JOIN pg_inherits i ON i.inhrelid = c.oid JOIN pg_inherits i ON i.inhrelid = c.oid
WHERE n.nspname = 'snp_data' WHERE n.nspname = 'snp_data'
AND c.relname LIKE 'ais_target_%' AND c.relname LIKE 'ais_target_%'
AND LENGTH(c.relname) = 17 -- ais_target_YYMMDD = 17자
AND c.relkind = 'r' AND c.relkind = 'r'
ORDER BY c.relname ORDER BY c.relname
LOOP LOOP
-- 파티션 이름에서 날짜 추출 (ais_target_YYYY_MM) -- 파티션 이름에서 날짜 추출 (ais_target_YYMMDD)
BEGIN BEGIN
partition_date := TO_DATE(SUBSTRING(rec.relname FROM 'ais_target_(\d{4}_\d{2})'), 'YYYY_MM'); partition_date := TO_DATE(SUBSTRING(rec.relname FROM 'ais_target_(\d{6})'), 'YYMMDD');
IF partition_date < cutoff_date THEN IF partition_date < cutoff_date THEN
EXECUTE format('DROP TABLE snp_data.%I', rec.relname); EXECUTE format('DROP TABLE snp_data.%I', rec.relname);
@ -261,8 +270,8 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- 파티션별 통계 조회 함수 -- 파티션별 통계 조회 함수 (일별)
CREATE OR REPLACE FUNCTION snp_data.ais_target_partition_stats() CREATE OR REPLACE FUNCTION snp_data.ais_target_daily_partition_stats()
RETURNS TABLE ( RETURNS TABLE (
partition_name TEXT, partition_name TEXT,
row_count BIGINT, row_count BIGINT,
@ -273,11 +282,7 @@ BEGIN
RETURN QUERY RETURN QUERY
SELECT SELECT
c.relname::TEXT as partition_name, c.relname::TEXT as partition_name,
(SELECT COUNT(*)::BIGINT FROM snp_data.ais_target WHERE message_timestamp >= (pg_stat_get_live_tuples(c.oid))::BIGINT as row_count,
TO_DATE(SUBSTRING(c.relname FROM 'ais_target_(\d{4}_\d{2})'), 'YYYY_MM')
AND message_timestamp <
TO_DATE(SUBSTRING(c.relname FROM 'ais_target_(\d{4}_\d{2})'), 'YYYY_MM') + INTERVAL '1 month'
) as row_count,
pg_relation_size(c.oid) as size_bytes, pg_relation_size(c.oid) as size_bytes,
pg_size_pretty(pg_relation_size(c.oid)) as size_pretty pg_size_pretty(pg_relation_size(c.oid)) as size_pretty
FROM pg_class c FROM pg_class c
@ -294,7 +299,7 @@ $$ LANGUAGE plpgsql;
-- 6. 코멘트 -- 6. 코멘트
-- ============================================ -- ============================================
COMMENT ON TABLE snp_data.ais_target IS 'AIS 선박 위치 정보 (매 분 15초 수집, 월별 파티션)'; COMMENT ON TABLE snp_data.ais_target IS 'AIS 선박 위치 정보 (매 분 15초 수집, 일별 파티션 - ais_target_YYMMDD)';
COMMENT ON COLUMN snp_data.ais_target.mmsi IS 'Maritime Mobile Service Identity (복합 PK)'; COMMENT ON COLUMN snp_data.ais_target.mmsi IS 'Maritime Mobile Service Identity (복합 PK)';
COMMENT ON COLUMN snp_data.ais_target.message_timestamp IS 'AIS 메시지 발생 시간 (복합 PK, 파티션 키)'; COMMENT ON COLUMN snp_data.ais_target.message_timestamp IS 'AIS 메시지 발생 시간 (복합 PK, 파티션 키)';
@ -308,33 +313,6 @@ COMMENT ON COLUMN snp_data.ais_target.draught IS '흘수 (meters)';
COMMENT ON COLUMN snp_data.ais_target.collected_at IS '배치 수집 시점'; COMMENT ON COLUMN snp_data.ais_target.collected_at IS '배치 수집 시점';
COMMENT ON COLUMN snp_data.ais_target.received_date IS 'API 수신 시간'; COMMENT ON COLUMN snp_data.ais_target.received_date IS 'API 수신 시간';
-- ============================================
-- 유지보수용 함수: 오래된 데이터 정리
-- ============================================
-- 오래된 데이터 삭제 함수 (기본: 7일 이전)
CREATE OR REPLACE FUNCTION snp_data.cleanup_ais_target(retention_days INTEGER DEFAULT 7)
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM snp_data.ais_target
WHERE message_timestamp < NOW() - (retention_days || ' days')::INTERVAL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RAISE NOTICE 'Deleted % rows older than % days', deleted_count, retention_days;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION snp_data.create_ais_target_partition IS '특정 월의 AIS Target 파티션 생성';
COMMENT ON FUNCTION snp_data.create_future_ais_target_partitions IS '향후 N개월 파티션 사전 생성';
COMMENT ON FUNCTION snp_data.drop_ais_target_partition IS '특정 월의 파티션 삭제';
COMMENT ON FUNCTION snp_data.cleanup_old_ais_target_partitions IS 'N개월 이전 파티션 정리';
COMMENT ON FUNCTION snp_data.ais_target_partition_stats IS '파티션별 통계 조회';
-- ============================================ -- ============================================
-- 7. 유지보수용 함수: 통계 조회 -- 7. 유지보수용 함수: 통계 조회
-- ============================================ -- ============================================
@ -362,6 +340,11 @@ END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
COMMENT ON FUNCTION snp_data.ais_target_stats IS 'AIS Target 테이블 통계 조회'; COMMENT ON FUNCTION snp_data.ais_target_stats IS 'AIS Target 테이블 통계 조회';
COMMENT ON FUNCTION snp_data.create_ais_target_daily_partition IS '특정 일의 AIS Target 파티션 생성';
COMMENT ON FUNCTION snp_data.create_future_ais_target_daily_partitions IS '향후 N일 파티션 사전 생성';
COMMENT ON FUNCTION snp_data.drop_ais_target_daily_partition IS '특정 일의 파티션 삭제';
COMMENT ON FUNCTION snp_data.cleanup_old_ais_target_daily_partitions IS 'N일 이전 파티션 정리';
COMMENT ON FUNCTION snp_data.ais_target_daily_partition_stats IS '파티션별 통계 조회';
-- ============================================ -- ============================================
-- 예시 쿼리 -- 예시 쿼리
@ -373,7 +356,7 @@ COMMENT ON FUNCTION snp_data.ais_target_stats IS 'AIS Target 테이블 통계
-- 2. 특정 시간 범위의 항적 조회 -- 2. 특정 시간 범위의 항적 조회
-- SELECT * FROM snp_data.ais_target -- SELECT * FROM snp_data.ais_target
-- WHERE mmsi = 123456789 -- WHERE mmsi = 123456789
-- AND message_timestamp BETWEEN '2025-12-01 00:00:00+00' AND '2025-12-01 01:00:00+00' -- AND message_timestamp BETWEEN '2024-12-04 00:00:00+00' AND '2024-12-04 01:00:00+00'
-- ORDER BY message_timestamp; -- ORDER BY message_timestamp;
-- 3. 특정 구역(원형) 내 선박 조회 -- 3. 특정 구역(원형) 내 선박 조회
@ -387,26 +370,19 @@ COMMENT ON FUNCTION snp_data.ais_target_stats IS 'AIS Target 테이블 통계
-- ) -- )
-- ORDER BY mmsi, message_timestamp DESC; -- ORDER BY mmsi, message_timestamp DESC;
-- 4. LineString 항적 생성 -- 4. 다음 7일 파티션 미리 생성
-- SELECT mmsi, ST_MakeLine(geom ORDER BY message_timestamp) as track -- SELECT * FROM snp_data.create_future_ais_target_daily_partitions(7);
-- FROM snp_data.ais_target
-- WHERE mmsi = 123456789
-- AND message_timestamp BETWEEN '2025-12-01 00:00:00+00' AND '2025-12-01 01:00:00+00'
-- GROUP BY mmsi;
-- 5. 다음 3개월 파티션 미리 생성 -- 5. 특정 일 파티션 생성
-- SELECT * FROM snp_data.create_future_ais_target_partitions(3); -- SELECT snp_data.create_ais_target_daily_partition('2024-12-10');
-- 6. 특정 월 파티션 생성 -- 6. 14일 이전 파티션 정리
-- SELECT snp_data.create_ais_target_partition('2026-03-01'); -- SELECT * FROM snp_data.cleanup_old_ais_target_daily_partitions(14);
-- 7. 3개월 이전 파티션 정리 -- 7. 파티션별 통계 조회
-- SELECT * FROM snp_data.cleanup_old_ais_target_partitions(3); -- SELECT * FROM snp_data.ais_target_daily_partition_stats();
-- 8. 파티션별 통계 조회 -- 8. 전체 통계 조회
-- SELECT * FROM snp_data.ais_target_partition_stats();
-- 9. 전체 통계 조회
-- SELECT * FROM snp_data.ais_target_stats(); -- SELECT * FROM snp_data.ais_target_stats();
-- ============================================ -- ============================================
@ -431,12 +407,13 @@ VALUES (
updated_at = NOW(); updated_at = NOW();
-- 2. partitionManagerJob: 매일 00:10에 실행 -- 2. partitionManagerJob: 매일 00:10에 실행
-- Daily 파티션: 매일 생성, Monthly 파티션: 말일에만 생성 (Job 내부에서 분기) -- Daily 파티션: 매일 생성/삭제 (ais_target_YYMMDD)
-- Monthly 파티션: 말일 생성, 1일 삭제 (table_YYYY_MM)
INSERT INTO public.job_schedule (job_name, cron_expression, description, active, created_at, updated_at, created_by, updated_by) INSERT INTO public.job_schedule (job_name, cron_expression, description, active, created_at, updated_at, created_by, updated_by)
VALUES ( VALUES (
'partitionManagerJob', 'partitionManagerJob',
'0 10 0 * * ?', '0 10 0 * * ?',
'파티션 관리 - 매일 00:10 실행 (Daily: 매일, Monthly: 말일만)', '파티션 관리 - 매일 00:10 실행 (Daily: 생성/삭제, Monthly: 말일 생성/1일 삭제)',
true, true,
NOW(), NOW(),
NOW(), NOW(),

파일 보기

@ -0,0 +1,67 @@
-- 오래된 STARTED 상태 Job을 정리하는 SQL 쿼리입니다.
-- snp_data 스키마에 batch_ 접두사를 사용하는 예시입니다. 실제 스키마에 맞추어 수정해서 사용하세요.
-- 참고: 시간 간격 변경이 필요하면 INTERVAL '2 hours' 부분을 수정하세요:
-- 1시간: INTERVAL '1 hour'
-- 30분: INTERVAL '30 minutes'
-- 1일: INTERVAL '1 day'
-- 2시간 이상 경과한 STARTED 상태 Job Execution 조회
SELECT
je.job_execution_id,
ji.job_name,
je.status,
je.start_time,
je.end_time,
NOW() - je.start_time AS elapsed_time
FROM snp_data.batch_job_execution je
JOIN snp_data.batch_job_instance ji ON je.job_instance_id = ji.job_instance_id
WHERE je.status = 'STARTED'
AND je.start_time < NOW() - INTERVAL '2 hours'
ORDER BY je.start_time;
-- Step Execution을 FAILED로 변경
UPDATE snp_data.batch_step_execution
SET
status = 'FAILED',
exit_code = 'FAILED',
exit_message = 'Manually cleaned up - stale execution (process restart)',
end_time = NOW(),
last_updated = NOW()
WHERE job_execution_id IN (
SELECT job_execution_id
FROM snp_data.batch_job_execution
WHERE status = 'STARTED'
AND start_time < NOW() - INTERVAL '2 hours'
);
-- Job Execution을 FAILED로 변경
UPDATE snp_data.batch_job_execution
SET
status = 'FAILED',
exit_code = 'FAILED',
exit_message = 'Manually cleaned up - stale execution (process restart)',
end_time = NOW(),
last_updated = NOW()
WHERE status = 'STARTED'
AND start_time < NOW() - INTERVAL '2 hours';
-- 정리 후 STARTED 상태 확인
SELECT
je.job_execution_id,
ji.job_name,
je.status,
je.exit_code,
je.start_time,
je.end_time
FROM snp_data.batch_job_execution je
JOIN snp_data.batch_job_instance ji ON je.job_instance_id = ji.job_instance_id
WHERE je.status IN ('STARTED', 'FAILED')
ORDER BY je.start_time DESC
LIMIT 20;

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

파일 보기

@ -265,8 +265,8 @@
<div class="header"> <div class="header">
<h1>실행 상세 정보</h1> <h1>실행 상세 정보</h1>
<div class="button-group"> <div class="button-group">
<a href="/" class="back-btn secondary">← 대시보드로</a> <a th:href="@{/}" href="/" class="back-btn secondary">← 대시보드로</a>
<a href="/executions" class="back-btn">← 실행 이력으로</a> <a th:href="@{/executions}" href="/executions" class="back-btn">← 실행 이력으로</a>
</div> </div>
</div> </div>
@ -275,7 +275,10 @@
</div> </div>
</div> </div>
<script> <script th:inline="javascript">
// Context path for API calls
const contextPath = /*[[@{/}]]*/ '/';
// URL에서 실행 ID 추출 (두 가지 형식 지원) // URL에서 실행 ID 추출 (두 가지 형식 지원)
// 1. Path parameter: /executions/123 // 1. Path parameter: /executions/123
// 2. Query parameter: /execution-detail?id=123 // 2. Query parameter: /execution-detail?id=123
@ -297,7 +300,7 @@
async function loadExecutionDetail() { async function loadExecutionDetail() {
try { try {
const response = await fetch(`/api/batch/executions/${executionId}/detail`); const response = await fetch(contextPath + `api/batch/executions/${executionId}/detail`);
if (!response.ok) { if (!response.ok) {
throw new Error('실행 정보를 찾을 수 없습니다.'); throw new Error('실행 정보를 찾을 수 없습니다.');

파일 보기

@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업 실행 이력 - SNP 배치</title> <title>작업 실행 이력 - SNP 배치</title>
<!-- Bootstrap 5 CSS --> <!-- Bootstrap 5 CSS (로컬) -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons --> <!-- Bootstrap Icons (로컬) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css"> <link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
<style> <style>
:root { :root {
@ -119,7 +119,7 @@
<!-- Header --> <!-- Header -->
<div class="page-header d-flex justify-content-between align-items-center"> <div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-clock-history"></i> 작업 실행 이력</h1> <h1><i class="bi bi-clock-history"></i> 작업 실행 이력</h1>
<a href="/" class="btn btn-primary"> <a th:href="@{/}" href="/" class="btn btn-primary">
<i class="bi bi-house-door"></i> 대시보드로 돌아가기 <i class="bi bi-house-door"></i> 대시보드로 돌아가기
</a> </a>
</div> </div>
@ -165,16 +165,18 @@
</div> </div>
</div> </div>
<!-- Bootstrap 5 JS Bundle --> <!-- Bootstrap 5 JS Bundle (로컬) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script th:src="@{/js/bootstrap.bundle.min.js}" src="/js/bootstrap.bundle.min.js"></script>
<script> <script th:inline="javascript">
// Context path for API calls
const contextPath = /*[[@{/}]]*/ '/';
let currentJobName = null; let currentJobName = null;
// Load jobs for filter dropdown // Load jobs for filter dropdown
async function loadJobs() { async function loadJobs() {
try { try {
const response = await fetch('/api/batch/jobs'); const response = await fetch(contextPath + 'api/batch/jobs');
const jobs = await response.json(); const jobs = await response.json();
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@ -231,7 +233,7 @@
`; `;
try { try {
const response = await fetch(`/api/batch/jobs/${currentJobName}/executions`); const response = await fetch(contextPath + `api/batch/jobs/${currentJobName}/executions`);
const executions = await response.json(); const executions = await response.json();
if (executions.length === 0) { if (executions.length === 0) {
@ -352,7 +354,7 @@
} }
try { try {
const response = await fetch(`/api/batch/executions/${executionId}/stop`, { const response = await fetch(contextPath + `api/batch/executions/${executionId}/stop`, {
method: 'POST' method: 'POST'
}); });
@ -371,7 +373,7 @@
// View execution details // View execution details
function viewDetails(executionId) { function viewDetails(executionId) {
window.location.href = `/executions/${executionId}`; window.location.href = contextPath + `executions/${executionId}`;
} }
// Initialize on page load // Initialize on page load

파일 보기

@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>S&P 배치 관리 시스템</title> <title>S&P 배치 관리 시스템</title>
<!-- Bootstrap 5 CSS --> <!-- Bootstrap 5 CSS (로컬) -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons --> <!-- Bootstrap Icons (로컬) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css"> <link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
<style> <style>
:root { :root {
@ -262,7 +262,7 @@
<div class="container"> <div class="container">
<!-- Header --> <!-- Header -->
<div class="dashboard-header"> <div class="dashboard-header">
<a href="/swagger-ui/index.html" target="_blank" class="swagger-btn" title="Swagger API 문서 열기"> <a th:href="@{/swagger-ui/index.html}" href="/swagger-ui/index.html" target="_blank" class="swagger-btn" title="Swagger API 문서 열기">
<i class="bi bi-file-earmark-code"></i> <i class="bi bi-file-earmark-code"></i>
<span>API 문서</span> <span>API 문서</span>
</a> </a>
@ -275,34 +275,34 @@
<div class="section-title"> <div class="section-title">
<i class="bi bi-clock-history"></i> <i class="bi bi-clock-history"></i>
스케줄 현황 스케줄 현황
<a href="/schedule-timeline" class="btn btn-warning btn-sm ms-auto"> <a th:href="@{/schedule-timeline}" href="/schedule-timeline" class="btn btn-warning btn-sm ms-auto">
<i class="bi bi-calendar3"></i> 스케줄 타임라인 <i class="bi bi-calendar3"></i> 스케줄 타임라인
</a> </a>
</div> </div>
<div class="row g-3" id="scheduleStats"> <div class="row g-3" id="scheduleStats">
<div class="col-md-3 col-sm-6"> <div class="col-md-3 col-sm-6">
<div class="stat-card" onclick="location.href='/schedules'"> <div class="stat-card" onclick="navigateTo('schedules')">
<div class="icon"><i class="bi bi-calendar-check text-primary"></i></div> <div class="icon"><i class="bi bi-calendar-check text-primary"></i></div>
<div class="value" id="totalSchedules">-</div> <div class="value" id="totalSchedules">-</div>
<div class="label">전체 스케줄</div> <div class="label">전체 스케줄</div>
</div> </div>
</div> </div>
<div class="col-md-3 col-sm-6"> <div class="col-md-3 col-sm-6">
<div class="stat-card" onclick="location.href='/schedules'"> <div class="stat-card" onclick="navigateTo('schedules')">
<div class="icon"><i class="bi bi-play-circle text-success"></i></div> <div class="icon"><i class="bi bi-play-circle text-success"></i></div>
<div class="value" id="activeSchedules">-</div> <div class="value" id="activeSchedules">-</div>
<div class="label">활성 스케줄</div> <div class="label">활성 스케줄</div>
</div> </div>
</div> </div>
<div class="col-md-3 col-sm-6"> <div class="col-md-3 col-sm-6">
<div class="stat-card" onclick="location.href='/schedules'"> <div class="stat-card" onclick="navigateTo('schedules')">
<div class="icon"><i class="bi bi-pause-circle text-warning"></i></div> <div class="icon"><i class="bi bi-pause-circle text-warning"></i></div>
<div class="value" id="inactiveSchedules">-</div> <div class="value" id="inactiveSchedules">-</div>
<div class="label">비활성 스케줄</div> <div class="label">비활성 스케줄</div>
</div> </div>
</div> </div>
<div class="col-md-3 col-sm-6"> <div class="col-md-3 col-sm-6">
<div class="stat-card" onclick="location.href='/jobs'"> <div class="stat-card" onclick="navigateTo('jobs')">
<div class="icon"><i class="bi bi-file-earmark-code text-info"></i></div> <div class="icon"><i class="bi bi-file-earmark-code text-info"></i></div>
<div class="value" id="totalJobs">-</div> <div class="value" id="totalJobs">-</div>
<div class="label">등록된 Job</div> <div class="label">등록된 Job</div>
@ -341,7 +341,7 @@
</div> </div>
</div> </div>
<div class="view-all-link"> <div class="view-all-link">
<a href="/executions">전체 실행 이력 보기 <i class="bi bi-arrow-right"></i></a> <a th:href="@{/executions}" href="/executions">전체 실행 이력 보기 <i class="bi bi-arrow-right"></i></a>
</div> </div>
</div> </div>
@ -355,13 +355,13 @@
<button class="btn btn-primary" onclick="showExecuteJobModal()"> <button class="btn btn-primary" onclick="showExecuteJobModal()">
<i class="bi bi-play-fill"></i> 작업 즉시 실행 <i class="bi bi-play-fill"></i> 작업 즉시 실행
</button> </button>
<a href="/jobs" class="btn btn-info"> <a th:href="@{/jobs}" href="/jobs" class="btn btn-info">
<i class="bi bi-list-ul"></i> 모든 작업 보기 <i class="bi bi-list-ul"></i> 모든 작업 보기
</a> </a>
<a href="/schedules" class="btn btn-success"> <a th:href="@{/schedules}" href="/schedules" class="btn btn-success">
<i class="bi bi-calendar-plus"></i> 스케줄 관리 <i class="bi bi-calendar-plus"></i> 스케줄 관리
</a> </a>
<a href="/executions" class="btn btn-secondary"> <a th:href="@{/executions}" href="/executions" class="btn btn-secondary">
<i class="bi bi-clock-history"></i> 실행 이력 <i class="bi bi-clock-history"></i> 실행 이력
</a> </a>
</div> </div>
@ -392,11 +392,18 @@
</div> </div>
</div> </div>
<!-- Bootstrap 5 JS Bundle --> <!-- Bootstrap 5 JS Bundle (로컬) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script th:src="@{/js/bootstrap.bundle.min.js}" src="/js/bootstrap.bundle.min.js"></script>
<script> <script th:inline="javascript">
let executeModal; let executeModal;
// Context path for API calls
const contextPath = /*[[@{/}]]*/ '/';
// Navigate to a page with context path
function navigateTo(path) {
location.href = contextPath + path;
}
// Initialize on page load // Initialize on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -410,7 +417,7 @@
// Load all dashboard data (single API call) // Load all dashboard data (single API call)
async function loadDashboardData() { async function loadDashboardData() {
try { try {
const response = await fetch('/api/batch/dashboard'); const response = await fetch(contextPath + 'api/batch/dashboard');
const data = await response.json(); const data = await response.json();
// Update stats // Update stats
@ -460,7 +467,7 @@
`; `;
} else { } else {
recentContainer.innerHTML = data.recentExecutions.map(exec => ` recentContainer.innerHTML = data.recentExecutions.map(exec => `
<div class="execution-item" onclick="location.href='/executions/${exec.executionId}'"> <div class="execution-item" onclick="location.href='${contextPath}executions/${exec.executionId}'">
<div class="execution-info"> <div class="execution-info">
<div class="job-name">${exec.jobName}</div> <div class="job-name">${exec.jobName}</div>
<div class="execution-meta"> <div class="execution-meta">
@ -499,7 +506,7 @@
// Show execute job modal // Show execute job modal
async function showExecuteJobModal() { async function showExecuteJobModal() {
try { try {
const response = await fetch('/api/batch/jobs'); const response = await fetch(contextPath + 'api/batch/jobs');
const jobs = await response.json(); const jobs = await response.json();
const select = document.getElementById('jobSelect'); const select = document.getElementById('jobSelect');
@ -528,7 +535,7 @@
} }
try { try {
const response = await fetch(`/api/batch/jobs/${jobName}/execute`, { const response = await fetch(`${contextPath}api/batch/jobs/${jobName}/execute`, {
method: 'POST' method: 'POST'
}); });

파일 보기

@ -187,7 +187,7 @@
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>배치 작업</h1> <h1>배치 작업</h1>
<a href="/" class="back-btn">← 대시보드로 돌아가기</a> <a th:href="@{/}" href="/" class="back-btn">← 대시보드로 돌아가기</a>
</div> </div>
<div class="content"> <div class="content">
@ -205,10 +205,13 @@
</div> </div>
</div> </div>
<script> <script th:inline="javascript">
// Context path for API calls
const contextPath = /*[[@{/}]]*/ '/';
async function loadJobs() { async function loadJobs() {
try { try {
const response = await fetch('/api/batch/jobs'); const response = await fetch(contextPath + 'api/batch/jobs');
const jobs = await response.json(); const jobs = await response.json();
const jobListDiv = document.getElementById('jobList'); const jobListDiv = document.getElementById('jobList');
@ -250,7 +253,7 @@
} }
try { try {
const response = await fetch(`/api/batch/jobs/${jobName}/execute`, { const response = await fetch(contextPath + `api/batch/jobs/${jobName}/execute`, {
method: 'POST' method: 'POST'
}); });
@ -267,7 +270,7 @@
} }
function viewExecutions(jobName) { function viewExecutions(jobName) {
window.location.href = `/executions?job=${jobName}`; window.location.href = contextPath + `executions?job=${jobName}`;
} }
function showModal(title, message) { function showModal(title, message) {

파일 보기

@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>스케줄 타임라인 - SNP 배치</title> <title>스케줄 타임라인 - SNP 배치</title>
<!-- Bootstrap 5 CSS --> <!-- Bootstrap 5 CSS (로컬) -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons --> <!-- Bootstrap Icons (로컬) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css"> <link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
<style> <style>
:root { :root {
@ -371,10 +371,10 @@
<div class="page-header d-flex justify-content-between align-items-center"> <div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-calendar3"></i> 스케줄 타임라인</h1> <h1><i class="bi bi-calendar3"></i> 스케줄 타임라인</h1>
<div> <div>
<a href="/schedules" class="btn btn-outline-primary me-2"> <a th:href="@{/schedules}" href="/schedules" class="btn btn-outline-primary me-2">
<i class="bi bi-calendar-check"></i> 스케줄 관리 <i class="bi bi-calendar-check"></i> 스케줄 관리
</a> </a>
<a href="/" class="btn btn-primary"> <a th:href="@{/}" href="/" class="btn btn-primary">
<i class="bi bi-house-door"></i> 대시보드 <i class="bi bi-house-door"></i> 대시보드
</a> </a>
</div> </div>
@ -471,10 +471,13 @@
<!-- Custom Tooltip --> <!-- Custom Tooltip -->
<div id="customTooltip" class="custom-tooltip"></div> <div id="customTooltip" class="custom-tooltip"></div>
<!-- Bootstrap 5 JS Bundle --> <!-- Bootstrap 5 JS Bundle (로컬) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script th:src="@{/js/bootstrap.bundle.min.js}" src="/js/bootstrap.bundle.min.js"></script>
<script th:inline="javascript">
// Context path for API calls
const contextPath = /*[[@{/}]]*/ '/';
<script>
let currentView = 'day'; let currentView = 'day';
let currentDate = new Date(); let currentDate = new Date();
@ -505,7 +508,7 @@
// Load timeline data // Load timeline data
async function loadTimeline() { async function loadTimeline() {
try { try {
const response = await fetch(`/api/batch/timeline?view=${currentView}&date=${currentDate.toISOString()}`); const response = await fetch(contextPath + `api/batch/timeline?view=${currentView}&date=${currentDate.toISOString()}`);
const data = await response.json(); const data = await response.json();
renderTimeline(data); renderTimeline(data);
@ -729,7 +732,7 @@
}, 100); }, 100);
try { try {
const response = await fetch(`/api/batch/timeline/period-executions?jobName=${encodeURIComponent(jobName)}&view=${currentView}&periodKey=${encodeURIComponent(periodKey)}`); const response = await fetch(contextPath + `api/batch/timeline/period-executions?jobName=${encodeURIComponent(jobName)}&view=${currentView}&periodKey=${encodeURIComponent(periodKey)}`);
const executions = await response.json(); const executions = await response.json();
renderPeriodExecutions(executions); renderPeriodExecutions(executions);
@ -783,14 +786,14 @@
tableHTML += ` tableHTML += `
<tr> <tr>
<td><a href="/executions/${exec.executionId}" class="text-primary" style="text-decoration: none; font-weight: 600;">#${exec.executionId}</a></td> <td><a href="${contextPath}executions/${exec.executionId}" class="text-primary" style="text-decoration: none; font-weight: 600;">#${exec.executionId}</a></td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td>${startTime}</td> <td>${startTime}</td>
<td>${endTime}</td> <td>${endTime}</td>
<td><code style="font-size: 12px;">${exec.exitCode}</code></td> <td><code style="font-size: 12px;">${exec.exitCode}</code></td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${exitMessage}">${exitMessage}</td> <td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${exitMessage}">${exitMessage}</td>
<td> <td>
<a href="/executions/${exec.executionId}" class="btn btn-sm btn-outline-primary" style="font-size: 12px;"> <a href="${contextPath}executions/${exec.executionId}" class="btn btn-sm btn-outline-primary" style="font-size: 12px;">
<i class="bi bi-eye"></i> 상세 <i class="bi bi-eye"></i> 상세
</a> </a>
</td> </td>

파일 보기

@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업 스케줄 - SNP 배치</title> <title>작업 스케줄 - SNP 배치</title>
<!-- Bootstrap 5 CSS --> <!-- Bootstrap 5 CSS (로컬) -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons --> <!-- Bootstrap Icons (로컬) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css"> <link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
<style> <style>
:root { :root {
@ -139,7 +139,7 @@
<!-- Header --> <!-- Header -->
<div class="page-header d-flex justify-content-between align-items-center"> <div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-calendar-check"></i> 작업 스케줄</h1> <h1><i class="bi bi-calendar-check"></i> 작업 스케줄</h1>
<a href="/" class="btn btn-primary"> <a th:href="@{/}" href="/" class="btn btn-primary">
<i class="bi bi-house-door"></i> 대시보드로 돌아가기 <i class="bi bi-house-door"></i> 대시보드로 돌아가기
</a> </a>
</div> </div>
@ -205,14 +205,17 @@
</div> </div>
</div> </div>
<!-- Bootstrap 5 JS Bundle --> <!-- Bootstrap 5 JS Bundle (로컬) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script th:src="@{/js/bootstrap.bundle.min.js}" src="/js/bootstrap.bundle.min.js"></script>
<script th:inline="javascript">
// Context path for API calls
const contextPath = /*[[@{/}]]*/ '/';
<script>
// Load jobs for dropdown // Load jobs for dropdown
async function loadJobs() { async function loadJobs() {
try { try {
const response = await fetch('/api/batch/jobs'); const response = await fetch(contextPath + 'api/batch/jobs');
const jobs = await response.json(); const jobs = await response.json();
const select = document.getElementById('jobName'); const select = document.getElementById('jobName');
@ -241,7 +244,7 @@
} }
try { try {
const response = await fetch(`/api/batch/schedules/${jobName}`); const response = await fetch(contextPath + `api/batch/schedules/${jobName}`);
if (response.ok) { if (response.ok) {
const schedule = await response.json(); const schedule = await response.json();
@ -283,7 +286,7 @@
// Load schedules // Load schedules
async function loadSchedules() { async function loadSchedules() {
try { try {
const response = await fetch('/api/batch/schedules'); const response = await fetch(contextPath + 'api/batch/schedules');
const data = await response.json(); const data = await response.json();
const schedules = data.schedules || []; const schedules = data.schedules || [];
@ -389,11 +392,11 @@
try { try {
// Check if schedule already exists // Check if schedule already exists
let method = 'POST'; let method = 'POST';
let url = '/api/batch/schedules'; let url = contextPath + 'api/batch/schedules';
let scheduleExists = false; let scheduleExists = false;
try { try {
const checkResponse = await fetch(`/api/batch/schedules/${jobName}`); const checkResponse = await fetch(contextPath + `api/batch/schedules/${jobName}`);
if (checkResponse.ok) { if (checkResponse.ok) {
scheduleExists = true; scheduleExists = true;
} }
@ -408,7 +411,7 @@
return; return;
} }
method = 'PUT'; method = 'PUT';
url = `/api/batch/schedules/${jobName}`; url = contextPath + `api/batch/schedules/${jobName}`;
} }
const response = await fetch(url, { const response = await fetch(url, {
@ -451,7 +454,7 @@
} }
try { try {
const response = await fetch(`/api/batch/schedules/${jobName}/toggle`, { const response = await fetch(contextPath + `api/batch/schedules/${jobName}/toggle`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -479,7 +482,7 @@
} }
try { try {
const response = await fetch(`/api/batch/schedules/${jobName}`, { const response = await fetch(contextPath + `api/batch/schedules/${jobName}`, {
method: 'DELETE' method: 'DELETE'
}); });