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}")
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)
.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)
.url("http://211.208.115.83:" + serverPort + contextPath)
.description("중계 서버"),
new Server()
.url("http://10.187.58.58:" + serverPort)
.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(),

파일 보기

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