ais/ship_position into dev_ship_movement
This commit is contained in:
커밋
7941396d62
@ -30,23 +30,30 @@ public class SwaggerConfig {
|
||||
@Value("${server.port:8081}")
|
||||
private int serverPort;
|
||||
|
||||
@Value("${server.servlet.context-path:}")
|
||||
private String contextPath;
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.info(apiInfo())
|
||||
.servers(List.of(
|
||||
new Server()
|
||||
.url("http://localhost:" + serverPort + "/snp-api")
|
||||
|
||||
.url("http://localhost:" + serverPort + contextPath)
|
||||
.description("로컬 개발 서버"),
|
||||
new Server()
|
||||
.url("http://10.26.252.39:" + serverPort)
|
||||
.url("http://10.26.252.39:" + serverPort + contextPath)
|
||||
.description("로컬 개발 서버"),
|
||||
new Server()
|
||||
.url("http://211.208.115.83:" + serverPort + "/snp-api")
|
||||
.url("http://211.208.115.83:" + serverPort + contextPath)
|
||||
.description("중계 서버"),
|
||||
new Server()
|
||||
.url("http://10.187.58.58:" + serverPort + "/snp-api")
|
||||
.description("운영 서버")
|
||||
.url("http://10.187.58.58:" + serverPort + contextPath)
|
||||
.description("운영 서버"),
|
||||
new Server()
|
||||
.url("https://mda.kcg.go.kr" + contextPath)
|
||||
.description("운영 서버 프록시")
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@ -1,50 +1,133 @@
|
||||
package com.snp.batch.global.partition;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
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
|
||||
@Setter
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.batch.partition")
|
||||
public class PartitionConfig {
|
||||
|
||||
/**
|
||||
* Daily 파티션 대상 테이블 (파티션 네이밍: {table}_YYYY_MM_DD)
|
||||
* 일별 파티션 테이블 목록 (파티션 네이밍: {table}_YYMMDD)
|
||||
*/
|
||||
private final List<PartitionTableInfo> dailyPartitionTables = List.of(
|
||||
// 추후 daily 파티션 테이블 추가
|
||||
);
|
||||
private List<PartitionTableConfig> dailyTables = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Monthly 파티션 대상 테이블 (파티션 네이밍: {table}_YYYY_MM)
|
||||
* 월별 파티션 테이블 목록 (파티션 네이밍: {table}_YYYY_MM)
|
||||
*/
|
||||
private final List<PartitionTableInfo> monthlyPartitionTables = List.of(
|
||||
new PartitionTableInfo(
|
||||
"snp_data",
|
||||
"ais_target",
|
||||
"message_timestamp",
|
||||
2 // 미리 생성할 개월 수
|
||||
)
|
||||
);
|
||||
private List<PartitionTableConfig> monthlyTables = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 파티션 테이블 정보
|
||||
* 보관기간 설정
|
||||
*/
|
||||
public record PartitionTableInfo(
|
||||
String schema,
|
||||
String tableName,
|
||||
String partitionColumn,
|
||||
int periodsAhead // 미리 생성할 기간 수 (daily: 일, monthly: 월)
|
||||
) {
|
||||
private RetentionConfig retention = new RetentionConfig();
|
||||
|
||||
/**
|
||||
* 파티션 테이블 설정
|
||||
*/
|
||||
@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() {
|
||||
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;
|
||||
|
||||
import com.snp.batch.global.partition.PartitionConfig.PartitionTableInfo;
|
||||
import com.snp.batch.global.partition.PartitionConfig.PartitionTableConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
@ -20,8 +20,12 @@ import java.util.List;
|
||||
* 파티션 관리 Tasklet
|
||||
*
|
||||
* 스케줄: 매일 실행
|
||||
* - Daily 파티션: 매일 생성
|
||||
* - Monthly 파티션: 매월 말일에만 생성
|
||||
* - Daily 파티션: 매일 생성/삭제 (네이밍: {table}_YYMMDD)
|
||||
* - Monthly 파티션: 매월 말일에만 생성/삭제 (네이밍: {table}_YYYY_MM)
|
||||
*
|
||||
* 보관기간:
|
||||
* - 기본값: 일별 14일, 월별 1개월
|
||||
* - 개별 테이블별 보관기간 설정 가능 (application.yml)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ -31,6 +35,9 @@ public class PartitionManagerTasklet implements Tasklet {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
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 = """
|
||||
SELECT EXISTS (
|
||||
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
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
LocalDate today = LocalDate.now();
|
||||
@ -52,14 +70,24 @@ public class PartitionManagerTasklet implements Tasklet {
|
||||
log.info("월 말일 여부: {}", isLastDayOfMonth);
|
||||
log.info("========================================");
|
||||
|
||||
// Daily 파티션 처리 (매일)
|
||||
processDailyPartitions(today);
|
||||
// 1. Daily 파티션 생성 (매일)
|
||||
createDailyPartitions(today);
|
||||
|
||||
// Monthly 파티션 처리 (매월 말일만)
|
||||
// 2. Daily 파티션 삭제 (보관기간 초과분)
|
||||
deleteDailyPartitions(today);
|
||||
|
||||
// 3. Monthly 파티션 생성 (매월 말일만)
|
||||
if (isLastDayOfMonth) {
|
||||
processMonthlyPartitions(today);
|
||||
createMonthlyPartitions(today);
|
||||
} else {
|
||||
log.info("Monthly 파티션: 말일이 아니므로 스킵");
|
||||
log.info("Monthly 파티션 생성: 말일이 아니므로 스킵");
|
||||
}
|
||||
|
||||
// 4. Monthly 파티션 삭제 (매월 1일에만, 보관기간 초과분)
|
||||
if (today.getDayOfMonth() == 1) {
|
||||
deleteMonthlyPartitions(today);
|
||||
} else {
|
||||
log.info("Monthly 파티션 삭제: 1일이 아니므로 스킵");
|
||||
}
|
||||
|
||||
log.info("========================================");
|
||||
@ -76,36 +104,38 @@ public class PartitionManagerTasklet implements Tasklet {
|
||||
return date.getDayOfMonth() == YearMonth.from(date).lengthOfMonth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily 파티션 처리
|
||||
*/
|
||||
private void processDailyPartitions(LocalDate today) {
|
||||
List<PartitionTableInfo> tables = partitionConfig.getDailyPartitionTables();
|
||||
// ==================== Daily 파티션 생성 ====================
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
log.info("Daily 파티션 처리 시작: {} 개 테이블", tables.size());
|
||||
log.info("Daily 파티션 생성 시작: {} 개 테이블", tables.size());
|
||||
|
||||
for (PartitionTableInfo table : tables) {
|
||||
processDailyPartition(table, today);
|
||||
for (PartitionTableConfig table : tables) {
|
||||
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> skipped = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i <= table.periodsAhead(); i++) {
|
||||
for (int i = 0; i <= table.getPeriodsAhead(); 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);
|
||||
} else {
|
||||
createDailyPartition(table, targetDate, partitionName);
|
||||
@ -113,40 +143,97 @@ public class PartitionManagerTasklet implements Tasklet {
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[{}] Daily 파티션 - 생성: {}, 스킵: {}",
|
||||
table.tableName(), created.size(), skipped.size());
|
||||
log.info("[{}] Daily 파티션 생성 - 생성: {}, 스킵: {}",
|
||||
table.getTableName(), created.size(), skipped.size());
|
||||
if (!created.isEmpty()) {
|
||||
log.info("[{}] 생성된 파티션: {}", table.getTableName(), created);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monthly 파티션 처리
|
||||
*/
|
||||
private void processMonthlyPartitions(LocalDate today) {
|
||||
List<PartitionTableInfo> tables = partitionConfig.getMonthlyPartitionTables();
|
||||
// ==================== Daily 파티션 삭제 ====================
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
log.info("Monthly 파티션 처리 시작: {} 개 테이블", tables.size());
|
||||
log.info("Daily 파티션 삭제 시작: {} 개 테이블", tables.size());
|
||||
|
||||
for (PartitionTableInfo table : tables) {
|
||||
processMonthlyPartition(table, today);
|
||||
for (PartitionTableConfig table : tables) {
|
||||
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> 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);
|
||||
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);
|
||||
} else {
|
||||
createMonthlyPartition(table, targetDate, partitionName);
|
||||
@ -154,27 +241,127 @@ public class PartitionManagerTasklet implements Tasklet {
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[{}] Monthly 파티션 - 생성: {}, 스킵: {}",
|
||||
table.tableName(), created.size(), skipped.size());
|
||||
log.info("[{}] Monthly 파티션 생성 - 생성: {}, 스킵: {}",
|
||||
table.getTableName(), created.size(), skipped.size());
|
||||
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) {
|
||||
return tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyy_MM_dd"));
|
||||
return tableName + "_" + date.format(DAILY_PARTITION_FORMAT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Monthly 파티션 이름 생성 (table_YYYY_MM)
|
||||
*/
|
||||
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 파티션 생성
|
||||
*/
|
||||
private void createDailyPartition(PartitionTableInfo table, LocalDate targetDate, String partitionName) {
|
||||
private void createDailyPartition(PartitionTableConfig table, LocalDate targetDate, String partitionName) {
|
||||
LocalDate endDate = targetDate.plusDays(1);
|
||||
|
||||
String sql = String.format("""
|
||||
CREATE TABLE %s.%s PARTITION OF %s
|
||||
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);
|
||||
|
||||
jdbcTemplate.execute(sql);
|
||||
@ -203,7 +390,7 @@ public class PartitionManagerTasklet implements Tasklet {
|
||||
/**
|
||||
* 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 endDate = startDate.plusMonths(1);
|
||||
|
||||
@ -211,10 +398,19 @@ public class PartitionManagerTasklet implements Tasklet {
|
||||
CREATE TABLE %s.%s PARTITION OF %s
|
||||
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);
|
||||
|
||||
jdbcTemplate.execute(sql);
|
||||
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
|
||||
) 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) *
|
||||
FROM %s
|
||||
WHERE message_timestamp BETWEEN ? AND ?
|
||||
AND ST_DWithin(
|
||||
AND public.ST_DWithin(
|
||||
geom::geography,
|
||||
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography,
|
||||
public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326)::geography,
|
||||
?
|
||||
)
|
||||
ORDER BY mmsi, message_timestamp DESC
|
||||
|
||||
@ -4,7 +4,7 @@ spring:
|
||||
|
||||
# PostgreSQL Database Configuration
|
||||
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
|
||||
password: mda#8932
|
||||
driver-class-name: org.postgresql.Driver
|
||||
@ -28,7 +28,7 @@ spring:
|
||||
batch:
|
||||
jdbc:
|
||||
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:
|
||||
enabled: false # Prevent auto-run on startup
|
||||
|
||||
@ -55,9 +55,9 @@ spring:
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
port: 8041
|
||||
port: 9000
|
||||
servlet:
|
||||
context-path: /
|
||||
context-path: /snp-api
|
||||
|
||||
# Actuator Configuration
|
||||
management:
|
||||
@ -69,18 +69,10 @@ management:
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
# Logging Configuration
|
||||
|
||||
# Logging Configuration (logback-spring.xml에서 상세 설정)
|
||||
logging:
|
||||
level:
|
||||
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
|
||||
config: classpath:logback-spring.xml
|
||||
|
||||
# Custom Application Properties
|
||||
app:
|
||||
|
||||
@ -91,15 +91,33 @@ app:
|
||||
schedule:
|
||||
enabled: true
|
||||
cron: "0 0 * * * ?" # Every hour
|
||||
|
||||
# AIS Target 배치 설정
|
||||
ais-target:
|
||||
since-seconds: 60 # API 조회 범위 (초)
|
||||
chunk-size: 5000 # 배치 청크 크기
|
||||
schedule:
|
||||
cron: "15 * * * * ?" # 매 분 15초 실행
|
||||
partition:
|
||||
months-ahead: 2 # 미리 생성할 파티션 개월 수
|
||||
# AIS Target 캐시 설정
|
||||
ais-target-cache:
|
||||
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
|
||||
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 위치 정보 저장 (항적 분석용)
|
||||
-- 수집 주기: 매 분 15초
|
||||
-- 예상 데이터량: 약 33,000건/분
|
||||
-- 파티셔닝: 월별 파티션 (ais_target_YYYY_MM)
|
||||
-- 예상 데이터량: 약 33,000건/분, 일 20GB (인덱스 포함)
|
||||
-- 파티셔닝: 일별 파티션 (ais_target_YYMMDD)
|
||||
-- ============================================
|
||||
|
||||
-- PostGIS 확장 활성화 (이미 설치되어 있다면 생략)
|
||||
@ -77,18 +77,26 @@ CREATE TABLE IF NOT EXISTS snp_data.ais_target (
|
||||
) PARTITION BY RANGE (message_timestamp);
|
||||
|
||||
-- ============================================
|
||||
-- 2. 초기 파티션 생성 (현재 월 + 다음 월)
|
||||
-- 2. 초기 파티션 생성 (현재 일 + 다음 3일)
|
||||
-- ============================================
|
||||
-- 예: 2025년 12월과 2026년 1월 파티션
|
||||
-- 실제 운영 시 create_ais_target_partition 함수로 자동 생성
|
||||
-- 파티션 네이밍: ais_target_YYMMDD
|
||||
-- 실제 운영 시 partitionManagerJob에서 자동 생성
|
||||
|
||||
-- 2025년 12월 파티션
|
||||
CREATE TABLE IF NOT EXISTS snp_data.ais_target_2025_12 PARTITION OF snp_data.ais_target
|
||||
FOR VALUES FROM ('2025-12-01 00:00:00+00') TO ('2026-01-01 00:00:00+00');
|
||||
-- 2024년 12월 4일 파티션 (예시)
|
||||
CREATE TABLE IF NOT EXISTS snp_data.ais_target_241204 PARTITION OF snp_data.ais_target
|
||||
FOR VALUES FROM ('2024-12-04 00:00:00+00') TO ('2024-12-05 00:00:00+00');
|
||||
|
||||
-- 2026년 1월 파티션
|
||||
CREATE TABLE IF NOT EXISTS snp_data.ais_target_2026_01 PARTITION OF snp_data.ais_target
|
||||
FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00');
|
||||
-- 2024년 12월 5일 파티션
|
||||
CREATE TABLE IF NOT EXISTS snp_data.ais_target_241205 PARTITION OF snp_data.ais_target
|
||||
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. 인덱스 생성 (각 파티션에 자동 상속)
|
||||
@ -120,7 +128,7 @@ CREATE INDEX IF NOT EXISTS idx_ais_target_collected_at
|
||||
ON snp_data.ais_target (collected_at DESC);
|
||||
|
||||
-- ============================================
|
||||
-- 4. 파티션 자동 생성 함수
|
||||
-- 4. 파티션 자동 생성 함수 (일별)
|
||||
-- ============================================
|
||||
|
||||
-- 파티션 존재 여부 확인 함수
|
||||
@ -137,8 +145,8 @@ BEGIN
|
||||
END;
|
||||
$$ 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 $$
|
||||
DECLARE
|
||||
partition_name TEXT;
|
||||
@ -146,12 +154,12 @@ DECLARE
|
||||
end_date DATE;
|
||||
create_sql TEXT;
|
||||
BEGIN
|
||||
-- 파티션 이름 생성: ais_target_YYYY_MM
|
||||
partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYYY_MM');
|
||||
-- 파티션 이름 생성: ais_target_YYMMDD
|
||||
partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD');
|
||||
|
||||
-- 시작/종료 날짜 계산
|
||||
start_date := DATE_TRUNC('month', target_date)::DATE;
|
||||
end_date := (DATE_TRUNC('month', target_date) + INTERVAL '1 month')::DATE;
|
||||
start_date := target_date;
|
||||
end_date := target_date + INTERVAL '1 day';
|
||||
|
||||
-- 이미 존재하면 스킵
|
||||
IF snp_data.partition_exists(partition_name) THEN
|
||||
@ -175,18 +183,18 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 다음 N개월 파티션 사전 생성 함수
|
||||
CREATE OR REPLACE FUNCTION snp_data.create_future_ais_target_partitions(months_ahead INTEGER DEFAULT 2)
|
||||
-- 다음 N일 파티션 사전 생성 함수
|
||||
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 $$
|
||||
DECLARE
|
||||
i INTEGER;
|
||||
target_date DATE;
|
||||
result TEXT;
|
||||
BEGIN
|
||||
FOR i IN 0..months_ahead LOOP
|
||||
target_date := DATE_TRUNC('month', CURRENT_DATE + (i || ' months')::INTERVAL)::DATE;
|
||||
result := snp_data.create_ais_target_partition(target_date);
|
||||
partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYYY_MM');
|
||||
FOR i IN 0..days_ahead LOOP
|
||||
target_date := CURRENT_DATE + i;
|
||||
result := snp_data.create_ais_target_daily_partition(target_date);
|
||||
partition_name := 'ais_target_' || TO_CHAR(target_date, 'YYMMDD');
|
||||
status := result;
|
||||
RETURN NEXT;
|
||||
END LOOP;
|
||||
@ -194,17 +202,17 @@ END;
|
||||
$$ 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 $$
|
||||
DECLARE
|
||||
partition_name TEXT;
|
||||
drop_sql TEXT;
|
||||
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
|
||||
@ -221,17 +229,17 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- N개월 이전 파티션 정리 함수
|
||||
CREATE OR REPLACE FUNCTION snp_data.cleanup_old_ais_target_partitions(retention_months INTEGER DEFAULT 3)
|
||||
-- N일 이전 파티션 정리 함수
|
||||
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 $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
partition_date DATE;
|
||||
cutoff_date DATE;
|
||||
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
|
||||
SELECT c.relname
|
||||
FROM pg_class c
|
||||
@ -239,12 +247,13 @@ BEGIN
|
||||
JOIN pg_inherits i ON i.inhrelid = c.oid
|
||||
WHERE n.nspname = 'snp_data'
|
||||
AND c.relname LIKE 'ais_target_%'
|
||||
AND LENGTH(c.relname) = 17 -- ais_target_YYMMDD = 17자
|
||||
AND c.relkind = 'r'
|
||||
ORDER BY c.relname
|
||||
LOOP
|
||||
-- 파티션 이름에서 날짜 추출 (ais_target_YYYY_MM)
|
||||
-- 파티션 이름에서 날짜 추출 (ais_target_YYMMDD)
|
||||
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
|
||||
EXECUTE format('DROP TABLE snp_data.%I', rec.relname);
|
||||
@ -261,8 +270,8 @@ BEGIN
|
||||
END;
|
||||
$$ 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 (
|
||||
partition_name TEXT,
|
||||
row_count BIGINT,
|
||||
@ -273,11 +282,7 @@ BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
c.relname::TEXT as partition_name,
|
||||
(SELECT COUNT(*)::BIGINT FROM snp_data.ais_target WHERE message_timestamp >=
|
||||
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_stat_get_live_tuples(c.oid))::BIGINT as row_count,
|
||||
pg_relation_size(c.oid) as size_bytes,
|
||||
pg_size_pretty(pg_relation_size(c.oid)) as size_pretty
|
||||
FROM pg_class c
|
||||
@ -294,7 +299,7 @@ $$ LANGUAGE plpgsql;
|
||||
-- 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.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.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. 유지보수용 함수: 통계 조회
|
||||
-- ============================================
|
||||
@ -362,6 +340,11 @@ END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
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. 특정 시간 범위의 항적 조회
|
||||
-- SELECT * 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'
|
||||
-- AND message_timestamp BETWEEN '2024-12-04 00:00:00+00' AND '2024-12-04 01:00:00+00'
|
||||
-- ORDER BY message_timestamp;
|
||||
|
||||
-- 3. 특정 구역(원형) 내 선박 조회
|
||||
@ -387,26 +370,19 @@ COMMENT ON FUNCTION snp_data.ais_target_stats IS 'AIS Target 테이블 통계
|
||||
-- )
|
||||
-- ORDER BY mmsi, message_timestamp DESC;
|
||||
|
||||
-- 4. LineString 항적 생성
|
||||
-- SELECT mmsi, ST_MakeLine(geom ORDER BY message_timestamp) as track
|
||||
-- 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;
|
||||
-- 4. 다음 7일 파티션 미리 생성
|
||||
-- SELECT * FROM snp_data.create_future_ais_target_daily_partitions(7);
|
||||
|
||||
-- 5. 다음 3개월 파티션 미리 생성
|
||||
-- SELECT * FROM snp_data.create_future_ais_target_partitions(3);
|
||||
-- 5. 특정 일 파티션 생성
|
||||
-- SELECT snp_data.create_ais_target_daily_partition('2024-12-10');
|
||||
|
||||
-- 6. 특정 월 파티션 생성
|
||||
-- SELECT snp_data.create_ais_target_partition('2026-03-01');
|
||||
-- 6. 14일 이전 파티션 정리
|
||||
-- SELECT * FROM snp_data.cleanup_old_ais_target_daily_partitions(14);
|
||||
|
||||
-- 7. 3개월 이전 파티션 정리
|
||||
-- SELECT * FROM snp_data.cleanup_old_ais_target_partitions(3);
|
||||
-- 7. 파티션별 통계 조회
|
||||
-- SELECT * FROM snp_data.ais_target_daily_partition_stats();
|
||||
|
||||
-- 8. 파티션별 통계 조회
|
||||
-- SELECT * FROM snp_data.ais_target_partition_stats();
|
||||
|
||||
-- 9. 전체 통계 조회
|
||||
-- 8. 전체 통계 조회
|
||||
-- SELECT * FROM snp_data.ais_target_stats();
|
||||
|
||||
-- ============================================
|
||||
@ -431,12 +407,13 @@ VALUES (
|
||||
updated_at = NOW();
|
||||
|
||||
-- 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)
|
||||
VALUES (
|
||||
'partitionManagerJob',
|
||||
'0 10 0 * * ?',
|
||||
'파티션 관리 - 매일 00:10 실행 (Daily: 매일, Monthly: 말일만)',
|
||||
'파티션 관리 - 매일 00:10 실행 (Daily: 생성/삭제, Monthly: 말일 생성/1일 삭제)',
|
||||
true,
|
||||
NOW(),
|
||||
NOW(),
|
||||
|
||||
67
src/main/resources/sql/old_job_cleanup.sql
Normal file
67
src/main/resources/sql/old_job_cleanup.sql
Normal file
@ -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;
|
||||
|
||||
2078
src/main/resources/static/css/bootstrap-icons.css
vendored
Normal file
2078
src/main/resources/static/css/bootstrap-icons.css
vendored
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
6
src/main/resources/static/css/bootstrap.min.css
vendored
Normal file
6
src/main/resources/static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
src/main/resources/static/fonts/bootstrap-icons.woff
Normal file
BIN
src/main/resources/static/fonts/bootstrap-icons.woff
Normal file
Binary file not shown.
BIN
src/main/resources/static/fonts/bootstrap-icons.woff2
Normal file
BIN
src/main/resources/static/fonts/bootstrap-icons.woff2
Normal file
Binary file not shown.
7
src/main/resources/static/js/bootstrap.bundle.min.js
vendored
Normal file
7
src/main/resources/static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -265,8 +265,8 @@
|
||||
<div class="header">
|
||||
<h1>실행 상세 정보</h1>
|
||||
<div class="button-group">
|
||||
<a href="/" class="back-btn secondary">← 대시보드로</a>
|
||||
<a href="/executions" class="back-btn">← 실행 이력으로</a>
|
||||
<a th:href="@{/}" href="/" class="back-btn secondary">← 대시보드로</a>
|
||||
<a th:href="@{/executions}" href="/executions" class="back-btn">← 실행 이력으로</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -275,7 +275,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script th:inline="javascript">
|
||||
// Context path for API calls
|
||||
const contextPath = /*[[@{/}]]*/ '/';
|
||||
|
||||
// URL에서 실행 ID 추출 (두 가지 형식 지원)
|
||||
// 1. Path parameter: /executions/123
|
||||
// 2. Query parameter: /execution-detail?id=123
|
||||
@ -297,7 +300,7 @@
|
||||
|
||||
async function loadExecutionDetail() {
|
||||
try {
|
||||
const response = await fetch(`/api/batch/executions/${executionId}/detail`);
|
||||
const response = await fetch(contextPath + `api/batch/executions/${executionId}/detail`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('실행 정보를 찾을 수 없습니다.');
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 실행 이력 - SNP 배치</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<!-- Bootstrap 5 CSS (로컬) -->
|
||||
<link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (로컬) -->
|
||||
<link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
@ -119,7 +119,7 @@
|
||||
<!-- Header -->
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<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> 대시보드로 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
@ -165,16 +165,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Bootstrap 5 JS Bundle (로컬) -->
|
||||
<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;
|
||||
|
||||
// Load jobs for filter dropdown
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const response = await fetch('/api/batch/jobs');
|
||||
const response = await fetch(contextPath + 'api/batch/jobs');
|
||||
const jobs = await response.json();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@ -231,7 +233,7 @@
|
||||
`;
|
||||
|
||||
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();
|
||||
|
||||
if (executions.length === 0) {
|
||||
@ -352,7 +354,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/batch/executions/${executionId}/stop`, {
|
||||
const response = await fetch(contextPath + `api/batch/executions/${executionId}/stop`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
@ -371,7 +373,7 @@
|
||||
|
||||
// View execution details
|
||||
function viewDetails(executionId) {
|
||||
window.location.href = `/executions/${executionId}`;
|
||||
window.location.href = contextPath + `executions/${executionId}`;
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>S&P 배치 관리 시스템</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<!-- Bootstrap 5 CSS (로컬) -->
|
||||
<link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (로컬) -->
|
||||
<link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
@ -262,7 +262,7 @@
|
||||
<div class="container">
|
||||
<!-- 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>
|
||||
<span>API 문서</span>
|
||||
</a>
|
||||
@ -275,34 +275,34 @@
|
||||
<div class="section-title">
|
||||
<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> 스케줄 타임라인
|
||||
</a>
|
||||
</div>
|
||||
<div class="row g-3" id="scheduleStats">
|
||||
<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="value" id="totalSchedules">-</div>
|
||||
<div class="label">전체 스케줄</div>
|
||||
</div>
|
||||
</div>
|
||||
<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="value" id="activeSchedules">-</div>
|
||||
<div class="label">활성 스케줄</div>
|
||||
</div>
|
||||
</div>
|
||||
<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="value" id="inactiveSchedules">-</div>
|
||||
<div class="label">비활성 스케줄</div>
|
||||
</div>
|
||||
</div>
|
||||
<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="value" id="totalJobs">-</div>
|
||||
<div class="label">등록된 Job</div>
|
||||
@ -341,7 +341,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@ -355,13 +355,13 @@
|
||||
<button class="btn btn-primary" onclick="showExecuteJobModal()">
|
||||
<i class="bi bi-play-fill"></i> 작업 즉시 실행
|
||||
</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> 모든 작업 보기
|
||||
</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> 스케줄 관리
|
||||
</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> 실행 이력
|
||||
</a>
|
||||
</div>
|
||||
@ -392,11 +392,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Bootstrap 5 JS Bundle (로컬) -->
|
||||
<script th:src="@{/js/bootstrap.bundle.min.js}" src="/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
<script th:inline="javascript">
|
||||
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
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@ -410,7 +417,7 @@
|
||||
// Load all dashboard data (single API call)
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
const response = await fetch('/api/batch/dashboard');
|
||||
const response = await fetch(contextPath + 'api/batch/dashboard');
|
||||
const data = await response.json();
|
||||
|
||||
// Update stats
|
||||
@ -460,7 +467,7 @@
|
||||
`;
|
||||
} else {
|
||||
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="job-name">${exec.jobName}</div>
|
||||
<div class="execution-meta">
|
||||
@ -499,7 +506,7 @@
|
||||
// Show execute job modal
|
||||
async function showExecuteJobModal() {
|
||||
try {
|
||||
const response = await fetch('/api/batch/jobs');
|
||||
const response = await fetch(contextPath + 'api/batch/jobs');
|
||||
const jobs = await response.json();
|
||||
|
||||
const select = document.getElementById('jobSelect');
|
||||
@ -528,7 +535,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/batch/jobs/${jobName}/execute`, {
|
||||
const response = await fetch(`${contextPath}api/batch/jobs/${jobName}/execute`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
|
||||
@ -187,7 +187,7 @@
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>배치 작업</h1>
|
||||
<a href="/" class="back-btn">← 대시보드로 돌아가기</a>
|
||||
<a th:href="@{/}" href="/" class="back-btn">← 대시보드로 돌아가기</a>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@ -205,10 +205,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script th:inline="javascript">
|
||||
// Context path for API calls
|
||||
const contextPath = /*[[@{/}]]*/ '/';
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const response = await fetch('/api/batch/jobs');
|
||||
const response = await fetch(contextPath + 'api/batch/jobs');
|
||||
const jobs = await response.json();
|
||||
|
||||
const jobListDiv = document.getElementById('jobList');
|
||||
@ -250,7 +253,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/batch/jobs/${jobName}/execute`, {
|
||||
const response = await fetch(contextPath + `api/batch/jobs/${jobName}/execute`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
@ -267,7 +270,7 @@
|
||||
}
|
||||
|
||||
function viewExecutions(jobName) {
|
||||
window.location.href = `/executions?job=${jobName}`;
|
||||
window.location.href = contextPath + `executions?job=${jobName}`;
|
||||
}
|
||||
|
||||
function showModal(title, message) {
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>스케줄 타임라인 - SNP 배치</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<!-- Bootstrap 5 CSS (로컬) -->
|
||||
<link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (로컬) -->
|
||||
<link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
@ -371,10 +371,10 @@
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-calendar3"></i> 스케줄 타임라인</h1>
|
||||
<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> 스케줄 관리
|
||||
</a>
|
||||
<a href="/" class="btn btn-primary">
|
||||
<a th:href="@{/}" href="/" class="btn btn-primary">
|
||||
<i class="bi bi-house-door"></i> 대시보드
|
||||
</a>
|
||||
</div>
|
||||
@ -471,10 +471,13 @@
|
||||
<!-- Custom Tooltip -->
|
||||
<div id="customTooltip" class="custom-tooltip"></div>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Bootstrap 5 JS Bundle (로컬) -->
|
||||
<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 currentDate = new Date();
|
||||
|
||||
@ -505,7 +508,7 @@
|
||||
// Load timeline data
|
||||
async function loadTimeline() {
|
||||
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();
|
||||
|
||||
renderTimeline(data);
|
||||
@ -729,7 +732,7 @@
|
||||
}, 100);
|
||||
|
||||
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();
|
||||
|
||||
renderPeriodExecutions(executions);
|
||||
@ -783,14 +786,14 @@
|
||||
|
||||
tableHTML += `
|
||||
<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>${startTime}</td>
|
||||
<td>${endTime}</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>
|
||||
<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> 상세
|
||||
</a>
|
||||
</td>
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 스케줄 - SNP 배치</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<!-- Bootstrap 5 CSS (로컬) -->
|
||||
<link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (로컬) -->
|
||||
<link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
@ -139,7 +139,7 @@
|
||||
<!-- Header -->
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<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> 대시보드로 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
@ -205,14 +205,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Bootstrap 5 JS Bundle (로컬) -->
|
||||
<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
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const response = await fetch('/api/batch/jobs');
|
||||
const response = await fetch(contextPath + 'api/batch/jobs');
|
||||
const jobs = await response.json();
|
||||
|
||||
const select = document.getElementById('jobName');
|
||||
@ -241,7 +244,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/batch/schedules/${jobName}`);
|
||||
const response = await fetch(contextPath + `api/batch/schedules/${jobName}`);
|
||||
|
||||
if (response.ok) {
|
||||
const schedule = await response.json();
|
||||
@ -283,7 +286,7 @@
|
||||
// Load schedules
|
||||
async function loadSchedules() {
|
||||
try {
|
||||
const response = await fetch('/api/batch/schedules');
|
||||
const response = await fetch(contextPath + 'api/batch/schedules');
|
||||
const data = await response.json();
|
||||
const schedules = data.schedules || [];
|
||||
|
||||
@ -389,11 +392,11 @@
|
||||
try {
|
||||
// Check if schedule already exists
|
||||
let method = 'POST';
|
||||
let url = '/api/batch/schedules';
|
||||
let url = contextPath + 'api/batch/schedules';
|
||||
let scheduleExists = false;
|
||||
|
||||
try {
|
||||
const checkResponse = await fetch(`/api/batch/schedules/${jobName}`);
|
||||
const checkResponse = await fetch(contextPath + `api/batch/schedules/${jobName}`);
|
||||
if (checkResponse.ok) {
|
||||
scheduleExists = true;
|
||||
}
|
||||
@ -408,7 +411,7 @@
|
||||
return;
|
||||
}
|
||||
method = 'PUT';
|
||||
url = `/api/batch/schedules/${jobName}`;
|
||||
url = contextPath + `api/batch/schedules/${jobName}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
@ -451,7 +454,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/batch/schedules/${jobName}/toggle`, {
|
||||
const response = await fetch(contextPath + `api/batch/schedules/${jobName}/toggle`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@ -479,7 +482,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/batch/schedules/${jobName}`, {
|
||||
const response = await fetch(contextPath + `api/batch/schedules/${jobName}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user