Compare commits
39 Commits
b952ec7b47
...
feat/train
| Author | SHA1 | Date | |
|---|---|---|---|
| 71a8b45097 | |||
| 219e17e8ba | |||
| a9260c17f4 | |||
| b546e053b6 | |||
| a66b25dda5 | |||
| bc67753b99 | |||
| 5dc817ecad | |||
| 8043e1a41a | |||
| 98e3c35d9a | |||
| d48e96ba82 | |||
| 3da4b73c59 | |||
| 01a1211e55 | |||
| c0532f230c | |||
| a0f4d0b8e2 | |||
| 38906a5795 | |||
| 051082665d | |||
| 13d9d9176b | |||
| 62398846d3 | |||
| 260569225c | |||
|
|
acdffd99ec | ||
| bd6fe924de | |||
| d7458e3c8b | |||
| 92492ca879 | |||
| a5d79b2504 | |||
| b8c53aae64 | |||
| 348d3d0052 | |||
| b85ead36b4 | |||
| e77eae8f8b | |||
| 570952df7e | |||
| 26d34d88eb | |||
| 46a5d4c2d3 | |||
| 91f022889b | |||
|
|
f00296cf2c | ||
| f98f6cb038 | |||
| d1593e57c3 | |||
| 732dccf2e4 | |||
| f6cd553af8 | |||
| 618dbe4047 | |||
| 5546e8ef89 |
@@ -104,7 +104,7 @@ public class HyperParam {
|
|||||||
@Schema(description = "Best 모델 선정 규칙", example = "less")
|
@Schema(description = "Best 모델 선정 규칙", example = "less")
|
||||||
private String saveBestRule; // save_best_rule
|
private String saveBestRule; // save_best_rule
|
||||||
|
|
||||||
@Schema(description = "검증 수행 주기(Epoch)", example = "10")
|
@Schema(description = "검증 수행 주기(Epoch)", example = "1")
|
||||||
private Integer valInterval; // val_interval
|
private Integer valInterval; // val_interval
|
||||||
|
|
||||||
@Schema(description = "로그 기록 주기(Iteration)", example = "400")
|
@Schema(description = "로그 기록 주기(Iteration)", example = "400")
|
||||||
|
|||||||
@@ -137,6 +137,6 @@ public class SecurityConfig {
|
|||||||
/** 완전 제외(필터 자체를 안 탐) */
|
/** 완전 제외(필터 자체를 안 탐) */
|
||||||
@Bean
|
@Bean
|
||||||
public WebSecurityCustomizer webSecurityCustomizer() {
|
public WebSecurityCustomizer webSecurityCustomizer() {
|
||||||
return (web) -> web.ignoring().requestMatchers("/api/mapsheet/**");
|
return (web) -> web.ignoring().requestMatchers("/api/mapsheet/**", "/api/file-manager/**");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,13 +248,13 @@ public class DatasetDto {
|
|||||||
private Integer targetYyyy;
|
private Integer targetYyyy;
|
||||||
private String memo;
|
private String memo;
|
||||||
@JsonIgnore private Long classCount;
|
@JsonIgnore private Long classCount;
|
||||||
private Integer buildingCnt;
|
private Long buildingCnt;
|
||||||
private Integer containerCnt;
|
private Long containerCnt;
|
||||||
private String dataTypeName;
|
private String dataTypeName;
|
||||||
|
|
||||||
private Long wasteCnt;
|
private Long wasteCnt;
|
||||||
private Long landCoverCnt;
|
private Long landCoverCnt;
|
||||||
private Integer solarPanelCnt;
|
private Long solarPanelCnt;
|
||||||
|
|
||||||
public SelectDataSet(
|
public SelectDataSet(
|
||||||
String modelNo,
|
String modelNo,
|
||||||
@@ -267,6 +267,7 @@ public class DatasetDto {
|
|||||||
Integer targetYyyy,
|
Integer targetYyyy,
|
||||||
String memo,
|
String memo,
|
||||||
Long classCount) {
|
Long classCount) {
|
||||||
|
this.modelNo = modelNo;
|
||||||
this.datasetId = datasetId;
|
this.datasetId = datasetId;
|
||||||
this.uuid = uuid;
|
this.uuid = uuid;
|
||||||
this.dataType = dataType;
|
this.dataType = dataType;
|
||||||
@@ -281,6 +282,8 @@ public class DatasetDto {
|
|||||||
this.wasteCnt = classCount;
|
this.wasteCnt = classCount;
|
||||||
} else if (modelNo.equals(ModelType.G3.getId())) {
|
} else if (modelNo.equals(ModelType.G3.getId())) {
|
||||||
this.landCoverCnt = classCount;
|
this.landCoverCnt = classCount;
|
||||||
|
} else if (modelNo.equals(ModelType.G4.getId())) {
|
||||||
|
this.solarPanelCnt = classCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,8 +297,9 @@ public class DatasetDto {
|
|||||||
Integer compareYyyy,
|
Integer compareYyyy,
|
||||||
Integer targetYyyy,
|
Integer targetYyyy,
|
||||||
String memo,
|
String memo,
|
||||||
Integer buildingCnt,
|
Long buildingCnt,
|
||||||
Integer containerCnt) {
|
Long containerCnt) {
|
||||||
|
this.modelNo = modelNo;
|
||||||
this.datasetId = datasetId;
|
this.datasetId = datasetId;
|
||||||
this.uuid = uuid;
|
this.uuid = uuid;
|
||||||
this.dataType = dataType;
|
this.dataType = dataType;
|
||||||
@@ -309,29 +313,6 @@ public class DatasetDto {
|
|||||||
this.containerCnt = containerCnt;
|
this.containerCnt = containerCnt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public SelectDataSet(
|
|
||||||
String modelNo,
|
|
||||||
Long datasetId,
|
|
||||||
UUID uuid,
|
|
||||||
String dataType,
|
|
||||||
String title,
|
|
||||||
Long roundNo,
|
|
||||||
Integer compareYyyy,
|
|
||||||
Integer targetYyyy,
|
|
||||||
String memo,
|
|
||||||
Integer solarPanelCnt) {
|
|
||||||
this.datasetId = datasetId;
|
|
||||||
this.uuid = uuid;
|
|
||||||
this.dataType = dataType;
|
|
||||||
this.dataTypeName = getDataTypeName(dataType);
|
|
||||||
this.title = title;
|
|
||||||
this.roundNo = roundNo;
|
|
||||||
this.compareYyyy = compareYyyy;
|
|
||||||
this.targetYyyy = targetYyyy;
|
|
||||||
this.memo = memo;
|
|
||||||
this.solarPanelCnt = solarPanelCnt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getDataTypeName(String groupTitleCd) {
|
public String getDataTypeName(String groupTitleCd) {
|
||||||
LearnDataType type = Enums.fromId(LearnDataType.class, groupTitleCd);
|
LearnDataType type = Enums.fromId(LearnDataType.class, groupTitleCd);
|
||||||
return type == null ? null : type.getText();
|
return type == null ? null : type.getText();
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package com.kamco.cd.training.filemanager;
|
||||||
|
|
||||||
|
import com.kamco.cd.training.config.api.ApiResponseDto;
|
||||||
|
import com.kamco.cd.training.filemanager.dto.FileManagerDto;
|
||||||
|
import com.kamco.cd.training.filemanager.service.FileManagerService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.ExampleObject;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Tag(name = "파일 관리", description = "/data 디렉토리 파일 관리 API")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/file-manager")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FileManagerApiController {
|
||||||
|
|
||||||
|
private final FileManagerService fileManagerService;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "파일 목록 조회",
|
||||||
|
description = "/data 디렉토리 내 파일 및 디렉토리 목록을 조회합니다. recursive=true로 설정하면 하위 디렉토리까지 조회합니다.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "조회 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = FileManagerDto.ListFilesRes.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 요청 (유효하지 않은 경로)", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "404", description = "디렉토리를 찾을 수 없음", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@GetMapping("/files")
|
||||||
|
public ApiResponseDto<FileManagerDto.ListFilesRes> listFiles(
|
||||||
|
@Parameter(description = "조회할 디렉토리 경로 (기본값: /data)", example = "/data/request")
|
||||||
|
@RequestParam(required = false)
|
||||||
|
String directoryPath,
|
||||||
|
@Parameter(description = "하위 디렉토리 포함 여부", example = "false")
|
||||||
|
@RequestParam(required = false, defaultValue = "false")
|
||||||
|
Boolean recursive) {
|
||||||
|
|
||||||
|
FileManagerDto.ListFilesReq request =
|
||||||
|
FileManagerDto.ListFilesReq.builder()
|
||||||
|
.directoryPath(directoryPath)
|
||||||
|
.recursive(recursive)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
FileManagerDto.ListFilesRes response = fileManagerService.listFiles(request);
|
||||||
|
return ApiResponseDto.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "파일/디렉토리 삭제",
|
||||||
|
description = "지정된 파일 또는 디렉토리를 삭제합니다. recursive=true로 설정하면 디렉토리 내 모든 파일을 삭제합니다.",
|
||||||
|
requestBody =
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = FileManagerDto.DeleteFileReq.class),
|
||||||
|
examples = {
|
||||||
|
@ExampleObject(
|
||||||
|
name = "단일 파일 삭제",
|
||||||
|
value =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"filePaths": ["/data/request/old_file.zip"],
|
||||||
|
"recursive": false
|
||||||
|
}
|
||||||
|
"""),
|
||||||
|
@ExampleObject(
|
||||||
|
name = "여러 파일 삭제",
|
||||||
|
value =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"filePaths": ["/data/file1.txt", "/data/file2.txt"],
|
||||||
|
"recursive": false
|
||||||
|
}
|
||||||
|
"""),
|
||||||
|
@ExampleObject(
|
||||||
|
name = "디렉토리 전체 삭제",
|
||||||
|
value =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"filePaths": ["/data/old_folder"],
|
||||||
|
"recursive": true
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
})))
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "삭제 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = FileManagerDto.DeleteFileRes.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 요청 (유효하지 않은 경로)", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "404", description = "파일을 찾을 수 없음", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@DeleteMapping("/files")
|
||||||
|
public ApiResponseDto<FileManagerDto.DeleteFileRes> deleteFiles(
|
||||||
|
@RequestBody FileManagerDto.DeleteFileReq request) {
|
||||||
|
|
||||||
|
FileManagerDto.DeleteFileRes response = fileManagerService.deleteFiles(request);
|
||||||
|
return ApiResponseDto.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "모델 파일 경로 조회",
|
||||||
|
description =
|
||||||
|
"특정 모델 UUID로 파일 위치 경로와 하위 파일 목록을 조회합니다. "
|
||||||
|
+ "request_path(심볼릭 링크 디렉토리)와 response_path(모델 결과)를 동시에 반환합니다.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "조회 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = FileManagerDto.ModelFilePathRes.class))),
|
||||||
|
@ApiResponse(responseCode = "404", description = "모델을 찾을 수 없음", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@GetMapping("/model-path/{modelUuid}")
|
||||||
|
public ApiResponseDto<FileManagerDto.ModelFilePathRes> getModelFilePath(
|
||||||
|
@Parameter(description = "모델 UUID", example = "df284755-c0b7-4070-bfee-ef554e8d0fe4")
|
||||||
|
@PathVariable
|
||||||
|
UUID modelUuid) {
|
||||||
|
|
||||||
|
FileManagerDto.ModelFilePathRes response = fileManagerService.getModelFilePath(modelUuid);
|
||||||
|
return ApiResponseDto.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "데이터셋 파일 경로 조회",
|
||||||
|
description =
|
||||||
|
"특정 데이터셋 UUID로 파일 위치 경로와 하위 파일 목록을 조회합니다. " + "dataset_path 컬럼의 request_dir 경로를 반환합니다.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "조회 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = FileManagerDto.DatasetFilePathRes.class))),
|
||||||
|
@ApiResponse(responseCode = "404", description = "데이터셋을 찾을 수 없음", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@GetMapping("/dataset-path/{datasetUuid}")
|
||||||
|
public ApiResponseDto<FileManagerDto.DatasetFilePathRes> getDatasetFilePath(
|
||||||
|
@Parameter(description = "데이터셋 UUID", example = "037b09a0-b315-4e2e-b88d-b9011f9eaa15")
|
||||||
|
@PathVariable
|
||||||
|
UUID datasetUuid) {
|
||||||
|
|
||||||
|
FileManagerDto.DatasetFilePathRes response = fileManagerService.getDatasetFilePath(datasetUuid);
|
||||||
|
return ApiResponseDto.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "디렉토리 용량 체크",
|
||||||
|
description = "특정 디렉토리의 총 용량, 파일 개수, 디렉토리 개수를 조회합니다. " + "basepath 하위 폴더의 용량을 재귀적으로 계산합니다.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "조회 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = FileManagerDto.DirectoryCapacityRes.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 경로", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "404", description = "디렉토리를 찾을 수 없음", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@GetMapping("/directory-capacity")
|
||||||
|
public ApiResponseDto<FileManagerDto.DirectoryCapacityRes> checkDirectoryCapacity(
|
||||||
|
@Parameter(
|
||||||
|
description = "디렉토리 경로",
|
||||||
|
example = "/home/kcomu/data/request/037b09a0-b315-4e2e-b88d-b9011f9eaa15")
|
||||||
|
@RequestParam
|
||||||
|
String directoryPath) {
|
||||||
|
|
||||||
|
FileManagerDto.DirectoryCapacityRes response =
|
||||||
|
fileManagerService.checkDirectoryCapacity(directoryPath);
|
||||||
|
return ApiResponseDto.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "모델별 학습 실행 상태 조회",
|
||||||
|
description =
|
||||||
|
"G1~G4 모델의 현재 학습 실행 상태를 조회합니다. "
|
||||||
|
+ "step1_state, step2_state를 체크하여 어떤 모델이 학습 중인지 확인합니다. "
|
||||||
|
+ "step1과 step2는 동시 진행되지 않습니다.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "조회 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema =
|
||||||
|
@Schema(
|
||||||
|
implementation = FileManagerDto.AllModelsExecutionStatusRes.class))),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@GetMapping("/models-execution-status")
|
||||||
|
public ApiResponseDto<FileManagerDto.AllModelsExecutionStatusRes> getModelsExecutionStatus() {
|
||||||
|
|
||||||
|
FileManagerDto.AllModelsExecutionStatusRes response =
|
||||||
|
fileManagerService.getModelsExecutionStatus();
|
||||||
|
return ApiResponseDto.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "저장공간 정보 조회",
|
||||||
|
description =
|
||||||
|
"/home/kcomu/data 경로의 사용 중인 용량, 전체 디스크 용량, 남은 저장공간을 조회합니다. "
|
||||||
|
+ "파라미터 없이 호출하면 자동으로 /home/kcomu/data 경로 정보를 반환합니다.")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "조회 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = FileManagerDto.StorageSpaceRes.class))),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@GetMapping("/storage-space")
|
||||||
|
public ApiResponseDto<FileManagerDto.StorageSpaceRes> getStorageSpaceInfo() {
|
||||||
|
|
||||||
|
FileManagerDto.StorageSpaceRes response = fileManagerService.getStorageSpaceInfo();
|
||||||
|
return ApiResponseDto.ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package com.kamco.cd.training.filemanager.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
public class FileManagerDto {
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "파일 정보")
|
||||||
|
public static class FileInfo {
|
||||||
|
@Schema(description = "파일명", example = "dataset.zip")
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
@Schema(description = "파일 전체 경로", example = "/data/request/dataset.zip")
|
||||||
|
private String filePath;
|
||||||
|
|
||||||
|
@Schema(description = "파일 크기 (bytes)", example = "1024000")
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
@Schema(description = "파일인지 디렉토리인지 여부", example = "true")
|
||||||
|
private Boolean isFile;
|
||||||
|
|
||||||
|
@Schema(description = "디렉토리인지 여부", example = "false")
|
||||||
|
private Boolean isDirectory;
|
||||||
|
|
||||||
|
@Schema(description = "마지막 수정 시간", example = "2026-04-06T15:30:00")
|
||||||
|
private LocalDateTime lastModified;
|
||||||
|
|
||||||
|
@Schema(description = "읽기 권한", example = "true")
|
||||||
|
private Boolean readable;
|
||||||
|
|
||||||
|
@Schema(description = "쓰기 권한", example = "true")
|
||||||
|
private Boolean writable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "디렉토리 목록 조회 요청")
|
||||||
|
public static class ListFilesReq {
|
||||||
|
@Schema(description = "조회할 디렉토리 경로 (기본값: /data)", example = "/data/request")
|
||||||
|
private String directoryPath;
|
||||||
|
|
||||||
|
@Schema(description = "하위 디렉토리 포함 여부", example = "false")
|
||||||
|
private Boolean recursive;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "디렉토리 목록 조회 응답")
|
||||||
|
public static class ListFilesRes {
|
||||||
|
@Schema(description = "조회된 디렉토리 경로", example = "/data/request")
|
||||||
|
private String directoryPath;
|
||||||
|
|
||||||
|
@Schema(description = "파일 목록")
|
||||||
|
private List<FileInfo> files;
|
||||||
|
|
||||||
|
@Schema(description = "총 파일 개수", example = "10")
|
||||||
|
private Integer totalCount;
|
||||||
|
|
||||||
|
@Schema(description = "총 파일 크기 (bytes)", example = "10240000")
|
||||||
|
private Long totalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "파일 삭제 요청")
|
||||||
|
public static class DeleteFileReq {
|
||||||
|
@Schema(
|
||||||
|
description = "삭제할 파일 또는 디렉토리 경로 목록",
|
||||||
|
example = "[\"/data/request/old_file.zip\", \"/data/tmp/test_folder\"]")
|
||||||
|
private List<String> filePaths;
|
||||||
|
|
||||||
|
@Schema(description = "디렉토리일 경우 하위 파일 포함 삭제 여부", example = "true")
|
||||||
|
private Boolean recursive;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "파일 삭제 응답")
|
||||||
|
public static class DeleteFileRes {
|
||||||
|
@Schema(description = "삭제 성공한 파일 경로 목록")
|
||||||
|
private List<String> deletedFiles;
|
||||||
|
|
||||||
|
@Schema(description = "삭제 실패한 파일 경로 목록")
|
||||||
|
private List<String> failedFiles;
|
||||||
|
|
||||||
|
@Schema(description = "삭제 성공 개수", example = "5")
|
||||||
|
private Integer successCount;
|
||||||
|
|
||||||
|
@Schema(description = "삭제 실패 개수", example = "0")
|
||||||
|
private Integer failureCount;
|
||||||
|
|
||||||
|
@Schema(description = "전체 메시지", example = "5개 파일 삭제 성공")
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "디스크 사용량 조회 응답")
|
||||||
|
public static class DiskUsageRes {
|
||||||
|
@Schema(description = "디렉토리 경로", example = "/data")
|
||||||
|
private String directoryPath;
|
||||||
|
|
||||||
|
@Schema(description = "총 용량 (bytes)", example = "1000000000000")
|
||||||
|
private Long totalSpace;
|
||||||
|
|
||||||
|
@Schema(description = "사용 가능 용량 (bytes)", example = "500000000000")
|
||||||
|
private Long usableSpace;
|
||||||
|
|
||||||
|
@Schema(description = "사용 중인 용량 (bytes)", example = "500000000000")
|
||||||
|
private Long usedSpace;
|
||||||
|
|
||||||
|
@Schema(description = "사용률 (%)", example = "50.0")
|
||||||
|
private Double usagePercentage;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "모델 파일 경로 정보 응답")
|
||||||
|
public static class ModelFilePathRes {
|
||||||
|
@Schema(description = "모델 UUID", example = "df284755-c0b7-4070-bfee-ef554e8d0fe4")
|
||||||
|
private String modelUuid;
|
||||||
|
|
||||||
|
@Schema(
|
||||||
|
description = "요청 경로 (심볼릭 링크 디렉토리)",
|
||||||
|
example = "/home/kcomu/data/tmp/AE366F3076504FACBF12106986202AB5")
|
||||||
|
private String requestPath;
|
||||||
|
|
||||||
|
@Schema(
|
||||||
|
description = "응답 경로 (모델 결과 저장)",
|
||||||
|
example = "/home/kcomu/data/response/df284755-c0b7-4070-bfee-ef554e8d0fe4")
|
||||||
|
private String responsePath;
|
||||||
|
|
||||||
|
@Schema(description = "요청 경로 파일 목록")
|
||||||
|
private List<FileInfo> requestFiles;
|
||||||
|
|
||||||
|
@Schema(description = "응답 경로 파일 목록")
|
||||||
|
private List<FileInfo> responseFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "데이터셋 파일 경로 정보 응답")
|
||||||
|
public static class DatasetFilePathRes {
|
||||||
|
@Schema(description = "데이터셋 UUID", example = "037b09a0-b315-4e2e-b88d-b9011f9eaa15")
|
||||||
|
private String datasetUuid;
|
||||||
|
|
||||||
|
@Schema(
|
||||||
|
description = "데이터셋 경로",
|
||||||
|
example = "/home/kcomu/data/request/037b09a0-b315-4e2e-b88d-b9011f9eaa15")
|
||||||
|
private String datasetPath;
|
||||||
|
|
||||||
|
@Schema(description = "데이터셋 파일 목록")
|
||||||
|
private List<FileInfo> files;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "디렉토리 용량 체크 응답")
|
||||||
|
public static class DirectoryCapacityRes {
|
||||||
|
@Schema(description = "디렉토리 경로", example = "/home/kcomu/data/request/dataset-uuid")
|
||||||
|
private String directoryPath;
|
||||||
|
|
||||||
|
@Schema(description = "파일 개수", example = "1234")
|
||||||
|
private Integer fileCount;
|
||||||
|
|
||||||
|
@Schema(description = "디렉토리 개수", example = "56")
|
||||||
|
private Integer directoryCount;
|
||||||
|
|
||||||
|
@Schema(description = "총 용량 (bytes)", example = "10485760000")
|
||||||
|
private Long totalSize;
|
||||||
|
|
||||||
|
@Schema(description = "총 용량 (읽기 쉬운 형식)", example = "9.77 GB")
|
||||||
|
private String totalSizeFormatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "모델별 학습 실행 상태 응답")
|
||||||
|
public static class ModelExecutionStatusRes {
|
||||||
|
@Schema(description = "모델 번호", example = "G1")
|
||||||
|
private String modelNo;
|
||||||
|
|
||||||
|
@Schema(description = "실행 상태 메시지", example = "G1 모델에 테스트를 진행중입니다.")
|
||||||
|
private String statusMessage;
|
||||||
|
|
||||||
|
@Schema(description = "1단계 상태", example = "COMPLETED")
|
||||||
|
private String step1State;
|
||||||
|
|
||||||
|
@Schema(description = "2단계 상태", example = "IN_PROGRESS")
|
||||||
|
private String step2State;
|
||||||
|
|
||||||
|
@Schema(description = "현재 실행 중인 단계", example = "2")
|
||||||
|
private Integer currentStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "전체 모델 학습 실행 상태 목록 응답")
|
||||||
|
public static class AllModelsExecutionStatusRes {
|
||||||
|
@Schema(description = "모델별 실행 상태 목록")
|
||||||
|
private List<ModelExecutionStatusRes> modelStatuses;
|
||||||
|
|
||||||
|
@Schema(description = "현재 실행 중인 모델 개수", example = "2")
|
||||||
|
private Integer runningCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "저장공간 정보 응답 (남은 공간 포함)")
|
||||||
|
public static class StorageSpaceRes {
|
||||||
|
@Schema(description = "디렉토리 경로", example = "/home/kcomu/data")
|
||||||
|
private String directoryPath;
|
||||||
|
|
||||||
|
@Schema(description = "파일 개수", example = "1234")
|
||||||
|
private Integer fileCount;
|
||||||
|
|
||||||
|
@Schema(description = "디렉토리 개수", example = "56")
|
||||||
|
private Integer directoryCount;
|
||||||
|
|
||||||
|
@Schema(description = "사용 중인 용량 (bytes)", example = "10485760000")
|
||||||
|
private Long usedSize;
|
||||||
|
|
||||||
|
@Schema(description = "사용 중인 용량 (읽기 쉬운 형식)", example = "9.77 GB")
|
||||||
|
private String usedSizeFormatted;
|
||||||
|
|
||||||
|
@Schema(description = "전체 디스크 용량 (bytes)", example = "1000000000000")
|
||||||
|
private Long totalDiskSpace;
|
||||||
|
|
||||||
|
@Schema(description = "전체 디스크 용량 (읽기 쉬운 형식)", example = "931.32 GB")
|
||||||
|
private String totalDiskSpaceFormatted;
|
||||||
|
|
||||||
|
@Schema(description = "남은 저장공간 (bytes)", example = "500000000000")
|
||||||
|
private Long freeSpace;
|
||||||
|
|
||||||
|
@Schema(description = "남은 저장공간 (읽기 쉬운 형식)", example = "465.66 GB")
|
||||||
|
private String freeSpaceFormatted;
|
||||||
|
|
||||||
|
@Schema(description = "사용 가능한 공간 (bytes)", example = "480000000000")
|
||||||
|
private Long usableSpace;
|
||||||
|
|
||||||
|
@Schema(description = "사용 가능한 공간 (읽기 쉬운 형식)", example = "447.03 GB")
|
||||||
|
private String usableSpaceFormatted;
|
||||||
|
|
||||||
|
@Schema(description = "디스크 사용률 (%)", example = "50.5")
|
||||||
|
private Double usagePercentage;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,694 @@
|
|||||||
|
package com.kamco.cd.training.filemanager.service;
|
||||||
|
|
||||||
|
import com.kamco.cd.training.filemanager.dto.FileManagerDto;
|
||||||
|
import com.kamco.cd.training.postgres.entity.DatasetEntity;
|
||||||
|
import com.kamco.cd.training.postgres.entity.ModelMasterEntity;
|
||||||
|
import com.kamco.cd.training.postgres.repository.dataset.DatasetRepository;
|
||||||
|
import com.kamco.cd.training.postgres.repository.model.ModelMngRepository;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.FileVisitResult;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.SimpleFileVisitor;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FileManagerService {
|
||||||
|
|
||||||
|
private static final String BASE_DATA_PATH = "/data";
|
||||||
|
private static final long MAX_PATH_LENGTH = 500;
|
||||||
|
|
||||||
|
@Value("${train.docker.request_dir}")
|
||||||
|
private String requestDir;
|
||||||
|
|
||||||
|
@Value("${train.docker.response_dir}")
|
||||||
|
private String responseDir;
|
||||||
|
|
||||||
|
@Value("${train.docker.symbolic_link_dir}")
|
||||||
|
private String symbolicLinkDir;
|
||||||
|
|
||||||
|
private final ModelMngRepository modelMngRepository;
|
||||||
|
private final DatasetRepository datasetRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디렉토리 내 파일 목록 조회
|
||||||
|
*
|
||||||
|
* @param request 조회 요청 정보
|
||||||
|
* @return 파일 목록 응답
|
||||||
|
*/
|
||||||
|
public FileManagerDto.ListFilesRes listFiles(FileManagerDto.ListFilesReq request) {
|
||||||
|
String targetPath =
|
||||||
|
request.getDirectoryPath() != null ? request.getDirectoryPath() : BASE_DATA_PATH;
|
||||||
|
|
||||||
|
boolean recursive = request.getRecursive() != null && request.getRecursive();
|
||||||
|
|
||||||
|
// 경로 검증은 수행하되, 예외를 throw하지 않고 빈 데이터 반환
|
||||||
|
try {
|
||||||
|
validatePath(targetPath);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.debug("유효하지 않은 경로: {}", targetPath);
|
||||||
|
return FileManagerDto.ListFilesRes.builder()
|
||||||
|
.directoryPath(targetPath)
|
||||||
|
.files(new ArrayList<>())
|
||||||
|
.totalCount(0)
|
||||||
|
.totalSize(0L)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Path directory = Paths.get(targetPath);
|
||||||
|
if (!Files.exists(directory)) {
|
||||||
|
log.debug("디렉토리가 존재하지 않습니다: {}", targetPath);
|
||||||
|
return FileManagerDto.ListFilesRes.builder()
|
||||||
|
.directoryPath(targetPath)
|
||||||
|
.files(new ArrayList<>())
|
||||||
|
.totalCount(0)
|
||||||
|
.totalSize(0L)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Files.isDirectory(directory)) {
|
||||||
|
log.debug("디렉토리 경로가 아닙니다: {}", targetPath);
|
||||||
|
return FileManagerDto.ListFilesRes.builder()
|
||||||
|
.directoryPath(targetPath)
|
||||||
|
.files(new ArrayList<>())
|
||||||
|
.totalCount(0)
|
||||||
|
.totalSize(0L)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FileManagerDto.FileInfo> files = new ArrayList<>();
|
||||||
|
long totalSize = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (recursive) {
|
||||||
|
// 재귀적으로 모든 하위 파일 조회
|
||||||
|
Files.walkFileTree(
|
||||||
|
directory,
|
||||||
|
new SimpleFileVisitor<>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||||
|
files.add(createFileInfo(file));
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
|
||||||
|
if (!dir.equals(directory)) {
|
||||||
|
files.add(createFileInfo(dir));
|
||||||
|
}
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 현재 디렉토리의 파일만 조회
|
||||||
|
try (Stream<Path> stream = Files.list(directory)) {
|
||||||
|
stream.forEach(path -> files.add(createFileInfo(path)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 총 파일 크기 계산
|
||||||
|
for (FileManagerDto.FileInfo file : files) {
|
||||||
|
if (file.getIsFile() && file.getFileSize() != null) {
|
||||||
|
totalSize += file.getFileSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("파일 목록 조회 중 오류 발생: {}", targetPath, e);
|
||||||
|
return FileManagerDto.ListFilesRes.builder()
|
||||||
|
.directoryPath(targetPath)
|
||||||
|
.files(new ArrayList<>())
|
||||||
|
.totalCount(0)
|
||||||
|
.totalSize(0L)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileManagerDto.ListFilesRes.builder()
|
||||||
|
.directoryPath(targetPath)
|
||||||
|
.files(files)
|
||||||
|
.totalCount(files.size())
|
||||||
|
.totalSize(totalSize)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 또는 디렉토리 삭제
|
||||||
|
*
|
||||||
|
* @param request 삭제 요청 정보
|
||||||
|
* @return 삭제 결과
|
||||||
|
*/
|
||||||
|
public FileManagerDto.DeleteFileRes deleteFiles(FileManagerDto.DeleteFileReq request) {
|
||||||
|
List<String> deletedFiles = new ArrayList<>();
|
||||||
|
List<String> failedFiles = new ArrayList<>();
|
||||||
|
|
||||||
|
boolean recursive = request.getRecursive() != null && request.getRecursive();
|
||||||
|
|
||||||
|
for (String filePath : request.getFilePaths()) {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
validatePath(filePath);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.debug("유효하지 않은 경로: {}", filePath);
|
||||||
|
failedFiles.add(filePath + " (유효하지 않은 경로)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Path path = Paths.get(filePath);
|
||||||
|
|
||||||
|
if (!Files.exists(path)) {
|
||||||
|
log.debug("삭제하려는 파일이 존재하지 않습니다: {}", filePath);
|
||||||
|
failedFiles.add(filePath + " (파일이 존재하지 않음)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Files.isDirectory(path)) {
|
||||||
|
if (recursive) {
|
||||||
|
// 디렉토리 및 하위 파일 모두 삭제
|
||||||
|
deleteDirectoryRecursively(path);
|
||||||
|
deletedFiles.add(filePath);
|
||||||
|
} else {
|
||||||
|
// 빈 디렉토리만 삭제
|
||||||
|
if (isDirectoryEmpty(path)) {
|
||||||
|
Files.delete(path);
|
||||||
|
deletedFiles.add(filePath);
|
||||||
|
} else {
|
||||||
|
failedFiles.add(filePath + " (디렉토리가 비어있지 않음)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 파일 삭제
|
||||||
|
Files.delete(path);
|
||||||
|
deletedFiles.add(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("파일 삭제 성공: {}", filePath);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("파일 삭제 실패: {}", filePath, e);
|
||||||
|
failedFiles.add(filePath + " (" + e.getMessage() + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String message =
|
||||||
|
String.format("%d개 파일 삭제 성공, %d개 파일 삭제 실패", deletedFiles.size(), failedFiles.size());
|
||||||
|
|
||||||
|
return FileManagerDto.DeleteFileRes.builder()
|
||||||
|
.deletedFiles(deletedFiles)
|
||||||
|
.failedFiles(failedFiles)
|
||||||
|
.successCount(deletedFiles.size())
|
||||||
|
.failureCount(failedFiles.size())
|
||||||
|
.message(message)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FileInfo 객체 생성 */
|
||||||
|
private FileManagerDto.FileInfo createFileInfo(Path path) {
|
||||||
|
try {
|
||||||
|
BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
|
||||||
|
|
||||||
|
return FileManagerDto.FileInfo.builder()
|
||||||
|
.fileName(path.getFileName().toString())
|
||||||
|
.filePath(path.toString())
|
||||||
|
.fileSize(attrs.isRegularFile() ? attrs.size() : null)
|
||||||
|
.isFile(attrs.isRegularFile())
|
||||||
|
.isDirectory(attrs.isDirectory())
|
||||||
|
.lastModified(
|
||||||
|
LocalDateTime.ofInstant(
|
||||||
|
Instant.ofEpochMilli(attrs.lastModifiedTime().toMillis()),
|
||||||
|
ZoneId.systemDefault()))
|
||||||
|
.readable(Files.isReadable(path))
|
||||||
|
.writable(Files.isWritable(path))
|
||||||
|
.build();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("파일 정보 조회 실패: {}", path, e);
|
||||||
|
return FileManagerDto.FileInfo.builder()
|
||||||
|
.fileName(path.getFileName().toString())
|
||||||
|
.filePath(path.toString())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 디렉토리 재귀 삭제 */
|
||||||
|
private void deleteDirectoryRecursively(Path directory) throws IOException {
|
||||||
|
Files.walkFileTree(
|
||||||
|
directory,
|
||||||
|
new SimpleFileVisitor<>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
|
||||||
|
throws IOException {
|
||||||
|
Files.delete(file);
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
|
||||||
|
Files.delete(dir);
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 디렉토리가 비어있는지 확인 */
|
||||||
|
private boolean isDirectoryEmpty(Path directory) throws IOException {
|
||||||
|
try (Stream<Path> stream = Files.list(directory)) {
|
||||||
|
return stream.findFirst().isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 경로 검증 (보안) */
|
||||||
|
private void validatePath(String path) {
|
||||||
|
if (path == null || path.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("경로가 비어있습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.length() > MAX_PATH_LENGTH) {
|
||||||
|
throw new IllegalArgumentException("경로가 너무 깁니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 경로 순회 공격 방지 - 상대경로 패턴만 제한
|
||||||
|
if (path.contains("..")) {
|
||||||
|
throw new IllegalArgumentException("상대 경로(..)는 사용할 수 없습니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모델 파일 경로 조회
|
||||||
|
*
|
||||||
|
* @param modelUuid 모델 UUID
|
||||||
|
* @return 모델 파일 경로 및 파일 목록
|
||||||
|
*/
|
||||||
|
public FileManagerDto.ModelFilePathRes getModelFilePath(UUID modelUuid) {
|
||||||
|
// tb_model_master 테이블에서 모델 조회
|
||||||
|
ModelMasterEntity model = modelMngRepository.findByUuid(modelUuid).orElse(null);
|
||||||
|
|
||||||
|
// 모델이 존재하지 않으면 빈 데이터 반환
|
||||||
|
if (model == null) {
|
||||||
|
return FileManagerDto.ModelFilePathRes.builder()
|
||||||
|
.modelUuid(modelUuid.toString())
|
||||||
|
.requestPath(null)
|
||||||
|
.responsePath(null)
|
||||||
|
.requestFiles(new ArrayList<>())
|
||||||
|
.responseFiles(new ArrayList<>())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// request_path: tb_model_master.request_path 컬럼 값 사용
|
||||||
|
// request_path 컬럼에는 'AE366F3076504FACBF12106986202AB5' 형태의 값이 저장되어 있음
|
||||||
|
String requestPathFromDb = model.getRequestPath();
|
||||||
|
String requestPath;
|
||||||
|
if (requestPathFromDb != null && !requestPathFromDb.isEmpty()) {
|
||||||
|
// DB에 저장된 값이 있으면 symbolic_link_dir + request_path 조합
|
||||||
|
requestPath = symbolicLinkDir + "/" + requestPathFromDb;
|
||||||
|
} else {
|
||||||
|
// 없으면 기본값: symbolic_link_dir + model_uuid
|
||||||
|
requestPath = symbolicLinkDir + "/" + modelUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// response_path: response_dir + model_uuid (UUID 사용)
|
||||||
|
String responsePath = responseDir + "/" + modelUuid;
|
||||||
|
|
||||||
|
// 파일 목록 조회
|
||||||
|
List<FileManagerDto.FileInfo> requestFiles = getFilesInDirectory(requestPath);
|
||||||
|
List<FileManagerDto.FileInfo> responseFiles = getFilesInDirectory(responsePath);
|
||||||
|
|
||||||
|
return FileManagerDto.ModelFilePathRes.builder()
|
||||||
|
.modelUuid(modelUuid.toString())
|
||||||
|
.requestPath(requestPath)
|
||||||
|
.responsePath(responsePath)
|
||||||
|
.requestFiles(requestFiles)
|
||||||
|
.responseFiles(responseFiles)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터셋 파일 경로 조회
|
||||||
|
*
|
||||||
|
* @param datasetUuid 데이터셋 UUID
|
||||||
|
* @return 데이터셋 파일 경로 및 파일 목록
|
||||||
|
*/
|
||||||
|
public FileManagerDto.DatasetFilePathRes getDatasetFilePath(UUID datasetUuid) {
|
||||||
|
// tb_dataset 테이블에서 데이터셋 조회
|
||||||
|
DatasetEntity dataset = datasetRepository.findByUuid(datasetUuid).orElse(null);
|
||||||
|
|
||||||
|
// 데이터셋이 존재하지 않으면 빈 데이터 반환
|
||||||
|
if (dataset == null) {
|
||||||
|
return FileManagerDto.DatasetFilePathRes.builder()
|
||||||
|
.datasetUuid(datasetUuid.toString())
|
||||||
|
.datasetPath(null)
|
||||||
|
.files(new ArrayList<>())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataset_path 컬럼이 있으면 사용, 없으면 request_dir + uuid 사용
|
||||||
|
String datasetPath = dataset.getDatasetPath();
|
||||||
|
if (datasetPath == null || datasetPath.isEmpty()) {
|
||||||
|
datasetPath = requestDir + "/" + datasetUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 목록 조회
|
||||||
|
List<FileManagerDto.FileInfo> files = getFilesInDirectory(datasetPath);
|
||||||
|
|
||||||
|
return FileManagerDto.DatasetFilePathRes.builder()
|
||||||
|
.datasetUuid(datasetUuid.toString())
|
||||||
|
.datasetPath(datasetPath)
|
||||||
|
.files(files)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디렉토리 용량 체크
|
||||||
|
*
|
||||||
|
* @param directoryPath 디렉토리 경로
|
||||||
|
* @return 디렉토리 용량 정보
|
||||||
|
*/
|
||||||
|
public FileManagerDto.DirectoryCapacityRes checkDirectoryCapacity(String directoryPath) {
|
||||||
|
// 경로 검증은 수행하되, 예외를 throw하지 않고 빈 데이터 반환
|
||||||
|
try {
|
||||||
|
validatePath(directoryPath);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.debug("유효하지 않은 경로: {}", directoryPath);
|
||||||
|
return FileManagerDto.DirectoryCapacityRes.builder()
|
||||||
|
.directoryPath(directoryPath)
|
||||||
|
.fileCount(0)
|
||||||
|
.directoryCount(0)
|
||||||
|
.totalSize(0L)
|
||||||
|
.totalSizeFormatted("0 B")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Path directory = Paths.get(directoryPath);
|
||||||
|
if (!Files.exists(directory)) {
|
||||||
|
log.debug("디렉토리가 존재하지 않습니다: {}", directoryPath);
|
||||||
|
return FileManagerDto.DirectoryCapacityRes.builder()
|
||||||
|
.directoryPath(directoryPath)
|
||||||
|
.fileCount(0)
|
||||||
|
.directoryCount(0)
|
||||||
|
.totalSize(0L)
|
||||||
|
.totalSizeFormatted("0 B")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Files.isDirectory(directory)) {
|
||||||
|
log.debug("디렉토리 경로가 아닙니다: {}", directoryPath);
|
||||||
|
return FileManagerDto.DirectoryCapacityRes.builder()
|
||||||
|
.directoryPath(directoryPath)
|
||||||
|
.fileCount(0)
|
||||||
|
.directoryCount(0)
|
||||||
|
.totalSize(0L)
|
||||||
|
.totalSizeFormatted("0 B")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
final long[] totalSize = {0};
|
||||||
|
final int[] fileCount = {0};
|
||||||
|
final int[] directoryCount = {0};
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.walkFileTree(
|
||||||
|
directory,
|
||||||
|
new SimpleFileVisitor<>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||||
|
totalSize[0] += attrs.size();
|
||||||
|
fileCount[0]++;
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
|
||||||
|
if (!dir.equals(directory)) {
|
||||||
|
directoryCount[0]++;
|
||||||
|
}
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("디렉토리 용량 체크 중 오류 발생: {}", directoryPath, e);
|
||||||
|
return FileManagerDto.DirectoryCapacityRes.builder()
|
||||||
|
.directoryPath(directoryPath)
|
||||||
|
.fileCount(0)
|
||||||
|
.directoryCount(0)
|
||||||
|
.totalSize(0L)
|
||||||
|
.totalSizeFormatted("0 B")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileManagerDto.DirectoryCapacityRes.builder()
|
||||||
|
.directoryPath(directoryPath)
|
||||||
|
.fileCount(fileCount[0])
|
||||||
|
.directoryCount(directoryCount[0])
|
||||||
|
.totalSize(totalSize[0])
|
||||||
|
.totalSizeFormatted(formatFileSize(totalSize[0]))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모델별 학습 실행 상태 조회
|
||||||
|
*
|
||||||
|
* @return 모델별 학습 실행 상태 목록
|
||||||
|
*/
|
||||||
|
public FileManagerDto.AllModelsExecutionStatusRes getModelsExecutionStatus() {
|
||||||
|
// G1 ~ G4 모델 조회
|
||||||
|
List<String> modelNumbers = Arrays.asList("G1", "G2", "G3", "G4");
|
||||||
|
List<FileManagerDto.ModelExecutionStatusRes> modelStatuses = new ArrayList<>();
|
||||||
|
int runningCount = 0;
|
||||||
|
|
||||||
|
for (String modelNo : modelNumbers) {
|
||||||
|
// model_no로 가장 최근 모델 조회 (del_yn = false)
|
||||||
|
List<ModelMasterEntity> models =
|
||||||
|
modelMngRepository.findByModelNoAndDelYnOrderByCreatedDttmDesc(modelNo, false);
|
||||||
|
|
||||||
|
if (models.isEmpty()) {
|
||||||
|
// 모델이 없으면 대기 상태
|
||||||
|
modelStatuses.add(
|
||||||
|
FileManagerDto.ModelExecutionStatusRes.builder()
|
||||||
|
.modelNo(modelNo)
|
||||||
|
.statusMessage(modelNo + " 모델은 대기 중입니다.")
|
||||||
|
.step1State(null)
|
||||||
|
.step2State(null)
|
||||||
|
.currentStep(null)
|
||||||
|
.build());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelMasterEntity model = models.getFirst();
|
||||||
|
String step1State = model.getStep1State();
|
||||||
|
String step2State = model.getStep2State();
|
||||||
|
|
||||||
|
String statusMessage;
|
||||||
|
Integer currentStep = null;
|
||||||
|
|
||||||
|
// step1, step2 상태 확인
|
||||||
|
boolean step1Running = "IN_PROGRESS".equals(step1State);
|
||||||
|
boolean step2Running = "IN_PROGRESS".equals(step2State);
|
||||||
|
|
||||||
|
if (step1Running) {
|
||||||
|
statusMessage = modelNo + " 모델에 학습(1단계)을 진행중입니다.";
|
||||||
|
currentStep = 1;
|
||||||
|
runningCount++;
|
||||||
|
} else if (step2Running) {
|
||||||
|
statusMessage = modelNo + " 모델에 테스트(2단계)를 진행중입니다.";
|
||||||
|
currentStep = 2;
|
||||||
|
runningCount++;
|
||||||
|
} else if ("COMPLETED".equals(step1State) && "COMPLETED".equals(step2State)) {
|
||||||
|
statusMessage = modelNo + " 모델은 학습이 완료되었습니다.";
|
||||||
|
} else if ("COMPLETED".equals(step1State)) {
|
||||||
|
statusMessage = modelNo + " 모델은 1단계 학습이 완료되었습니다.";
|
||||||
|
} else {
|
||||||
|
statusMessage = modelNo + " 모델은 대기 중입니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
modelStatuses.add(
|
||||||
|
FileManagerDto.ModelExecutionStatusRes.builder()
|
||||||
|
.modelNo(modelNo)
|
||||||
|
.statusMessage(statusMessage)
|
||||||
|
.step1State(step1State)
|
||||||
|
.step2State(step2State)
|
||||||
|
.currentStep(currentStep)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileManagerDto.AllModelsExecutionStatusRes.builder()
|
||||||
|
.modelStatuses(modelStatuses)
|
||||||
|
.runningCount(runningCount)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 디렉토리 내 파일 목록 조회 (내부 사용) */
|
||||||
|
private List<FileManagerDto.FileInfo> getFilesInDirectory(String directoryPath) {
|
||||||
|
List<FileManagerDto.FileInfo> files = new ArrayList<>();
|
||||||
|
|
||||||
|
Path directory = Paths.get(directoryPath);
|
||||||
|
if (!Files.exists(directory)) {
|
||||||
|
log.debug("디렉토리가 존재하지 않습니다: {}", directoryPath);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Files.isDirectory(directory)) {
|
||||||
|
log.debug("디렉토리 경로가 아닙니다: {}", directoryPath);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Stream<Path> stream = Files.list(directory)) {
|
||||||
|
stream.forEach(path -> files.add(createFileInfo(path)));
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("파일 목록 조회 중 오류 발생: {}", directoryPath, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 파일 크기 포맷팅 (읽기 쉬운 형식) */
|
||||||
|
private String formatFileSize(long size) {
|
||||||
|
if (size < 1024) {
|
||||||
|
return size + " B";
|
||||||
|
} else if (size < 1024 * 1024) {
|
||||||
|
return String.format("%.2f KB", size / 1024.0);
|
||||||
|
} else if (size < 1024 * 1024 * 1024) {
|
||||||
|
return String.format("%.2f MB", size / (1024.0 * 1024.0));
|
||||||
|
} else {
|
||||||
|
return String.format("%.2f GB", size / (1024.0 * 1024.0 * 1024.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장공간 정보 조회 (남은 공간 포함) 고정 경로: /home/kcomu/data
|
||||||
|
*
|
||||||
|
* @return 저장공간 정보 (사용량, 남은 공간, 디스크 용량)
|
||||||
|
*/
|
||||||
|
public FileManagerDto.StorageSpaceRes getStorageSpaceInfo() {
|
||||||
|
// 고정 경로: /home/kcomu/data
|
||||||
|
String directoryPath = "/home/kcomu/data";
|
||||||
|
|
||||||
|
Path directory = Paths.get(directoryPath);
|
||||||
|
if (!Files.exists(directory)) {
|
||||||
|
log.debug("디렉토리가 존재하지 않습니다: {}", directoryPath);
|
||||||
|
return FileManagerDto.StorageSpaceRes.builder()
|
||||||
|
.directoryPath(directoryPath)
|
||||||
|
.fileCount(0)
|
||||||
|
.directoryCount(0)
|
||||||
|
.usedSize(0L)
|
||||||
|
.usedSizeFormatted("0 B")
|
||||||
|
.totalDiskSpace(0L)
|
||||||
|
.totalDiskSpaceFormatted("0 B")
|
||||||
|
.freeSpace(0L)
|
||||||
|
.freeSpaceFormatted("0 B")
|
||||||
|
.usableSpace(0L)
|
||||||
|
.usableSpaceFormatted("0 B")
|
||||||
|
.usagePercentage(0.0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Files.isDirectory(directory)) {
|
||||||
|
log.debug("디렉토리 경로가 아닙니다: {}", directoryPath);
|
||||||
|
return FileManagerDto.StorageSpaceRes.builder()
|
||||||
|
.directoryPath(directoryPath)
|
||||||
|
.fileCount(0)
|
||||||
|
.directoryCount(0)
|
||||||
|
.usedSize(0L)
|
||||||
|
.usedSizeFormatted("0 B")
|
||||||
|
.totalDiskSpace(0L)
|
||||||
|
.totalDiskSpaceFormatted("0 B")
|
||||||
|
.freeSpace(0L)
|
||||||
|
.freeSpaceFormatted("0 B")
|
||||||
|
.usableSpace(0L)
|
||||||
|
.usableSpaceFormatted("0 B")
|
||||||
|
.usagePercentage(0.0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디렉토리 사용량 계산 (재귀적으로 모든 파일 크기 합산)
|
||||||
|
final long[] usedSize = {0};
|
||||||
|
final int[] fileCount = {0};
|
||||||
|
final int[] directoryCount = {0};
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.walkFileTree(
|
||||||
|
directory,
|
||||||
|
new SimpleFileVisitor<>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||||
|
usedSize[0] += attrs.size();
|
||||||
|
fileCount[0]++;
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
|
||||||
|
if (!dir.equals(directory)) {
|
||||||
|
directoryCount[0]++;
|
||||||
|
}
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("디렉토리 용량 계산 중 오류 발생: {}", directoryPath, e);
|
||||||
|
return FileManagerDto.StorageSpaceRes.builder()
|
||||||
|
.directoryPath(directoryPath)
|
||||||
|
.fileCount(0)
|
||||||
|
.directoryCount(0)
|
||||||
|
.usedSize(0L)
|
||||||
|
.usedSizeFormatted("0 B")
|
||||||
|
.totalDiskSpace(0L)
|
||||||
|
.totalDiskSpaceFormatted("0 B")
|
||||||
|
.freeSpace(0L)
|
||||||
|
.freeSpaceFormatted("0 B")
|
||||||
|
.usableSpace(0L)
|
||||||
|
.usableSpaceFormatted("0 B")
|
||||||
|
.usagePercentage(0.0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디스크 공간 정보 조회 (FileStore 사용)
|
||||||
|
long totalDiskSpace = 0;
|
||||||
|
long freeSpace = 0;
|
||||||
|
long usableSpace = 0;
|
||||||
|
double usagePercentage = 0.0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
java.nio.file.FileStore fileStore = Files.getFileStore(directory);
|
||||||
|
totalDiskSpace = fileStore.getTotalSpace(); // 전체 디스크 용량
|
||||||
|
freeSpace = fileStore.getUnallocatedSpace(); // 남은 저장공간 (할당되지 않은 공간)
|
||||||
|
usableSpace = fileStore.getUsableSpace(); // 사용 가능한 공간 (실제 사용 가능)
|
||||||
|
|
||||||
|
// 디스크 사용률 계산
|
||||||
|
if (totalDiskSpace > 0) {
|
||||||
|
long usedDiskSpace = totalDiskSpace - freeSpace;
|
||||||
|
usagePercentage = (usedDiskSpace * 100.0) / totalDiskSpace;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("디스크 공간 정보 조회 중 오류 발생: {}", directoryPath, e);
|
||||||
|
// 디스크 정보 조회 실패 시에도 디렉토리 용량은 반환
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileManagerDto.StorageSpaceRes.builder()
|
||||||
|
.directoryPath(directoryPath)
|
||||||
|
.fileCount(fileCount[0])
|
||||||
|
.directoryCount(directoryCount[0])
|
||||||
|
.usedSize(usedSize[0])
|
||||||
|
.usedSizeFormatted(formatFileSize(usedSize[0]))
|
||||||
|
.totalDiskSpace(totalDiskSpace)
|
||||||
|
.totalDiskSpaceFormatted(formatFileSize(totalDiskSpace))
|
||||||
|
.freeSpace(freeSpace)
|
||||||
|
.freeSpaceFormatted(formatFileSize(freeSpace))
|
||||||
|
.usableSpace(usableSpace)
|
||||||
|
.usableSpaceFormatted(formatFileSize(usableSpace))
|
||||||
|
.usagePercentage(Math.round(usagePercentage * 100.0) / 100.0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,6 +142,9 @@ public class ModelTrainDetailDto {
|
|||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
private Long landCoverCnt;
|
private Long landCoverCnt;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
private Long solarPanelCnt;
|
||||||
|
|
||||||
public MappingDataset(
|
public MappingDataset(
|
||||||
Long modelId,
|
Long modelId,
|
||||||
Long datasetId,
|
Long datasetId,
|
||||||
@@ -152,17 +155,20 @@ public class ModelTrainDetailDto {
|
|||||||
Long buildingCnt,
|
Long buildingCnt,
|
||||||
Long containerCnt,
|
Long containerCnt,
|
||||||
Long wasteCnt,
|
Long wasteCnt,
|
||||||
Long landCoverCnt) {
|
Long landCoverCnt,
|
||||||
|
Long solarPanelCnt) {
|
||||||
this.modelId = modelId;
|
this.modelId = modelId;
|
||||||
this.datasetId = datasetId;
|
this.datasetId = datasetId;
|
||||||
this.dataType = dataType;
|
this.dataType = dataType;
|
||||||
this.compareYyyy = compareYyyy;
|
this.compareYyyy = compareYyyy;
|
||||||
this.targetYyyy = targetYyyy;
|
this.targetYyyy = targetYyyy;
|
||||||
this.roundNo = roundNo;
|
this.roundNo = roundNo;
|
||||||
this.buildingCnt = buildingCnt;
|
this.buildingCnt = toNullIfZero(buildingCnt);
|
||||||
this.containerCnt = containerCnt;
|
this.containerCnt = toNullIfZero(containerCnt);
|
||||||
this.wasteCnt = wasteCnt;
|
this.wasteCnt = toNullIfZero(wasteCnt);
|
||||||
this.landCoverCnt = landCoverCnt;
|
this.landCoverCnt = toNullIfZero(landCoverCnt);
|
||||||
|
this.solarPanelCnt = toNullIfZero(solarPanelCnt);
|
||||||
|
|
||||||
this.dataTypeName = getDataTypeName(this.dataType);
|
this.dataTypeName = getDataTypeName(this.dataType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +178,10 @@ public class ModelTrainDetailDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Long toNullIfZero(Long value) {
|
||||||
|
return (value == null || value == 0L) ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ public class ModelTrainMngDto {
|
|||||||
public static class AddReq {
|
public static class AddReq {
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Schema(description = "모델 종류 G1, G2, G3", example = "G1")
|
@Schema(description = "모델 종류 G1, G2, G3, G4", example = "G1")
|
||||||
private String modelNo;
|
private String modelNo;
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@@ -197,10 +197,11 @@ public class ModelTrainMngDto {
|
|||||||
@Schema(description = "폐기물", example = "0")
|
@Schema(description = "폐기물", example = "0")
|
||||||
private Long wasteCnt;
|
private Long wasteCnt;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "도로, 비닐하우스, 밭, 과수원, 초지, 숲, 물, 모재/자갈, 토분(무덤), 일반토지, 기타", example = "0")
|
||||||
description = "도로, 비닐하우스, 밭, 과수원, 초지, 숲, 물, 모재/자갈, 토분(무덤), 일반토지, 태양광, 기타",
|
|
||||||
example = "0")
|
|
||||||
private Long LandCoverCnt;
|
private Long LandCoverCnt;
|
||||||
|
|
||||||
|
@Schema(description = "태양광", example = "0")
|
||||||
|
private Long solarPanelCnt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ public class ModelTrainMngService {
|
|||||||
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델 없음");
|
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델 없음");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (model.getRequestPath() == null) {
|
||||||
|
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "임시파일 경로 없음");
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 2. 경로 생성 =====
|
// ===== 2. 경로 생성 =====
|
||||||
Path tmpBase = Path.of(symbolicDir).toAbsolutePath().normalize();
|
Path tmpBase = Path.of(symbolicDir).toAbsolutePath().normalize();
|
||||||
Path tmp = tmpBase.resolve(model.getRequestPath()).normalize();
|
Path tmp = tmpBase.resolve(model.getRequestPath()).normalize();
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ public class ModelTestMetricsJobCoreService {
|
|||||||
return modelTestMetricsJobRepository.getTestMetricSaveNotYetModelIds();
|
return modelTestMetricsJobRepository.getTestMetricSaveNotYetModelIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ResponsePathDto getTestMetricSaveNotYetModelId(Long modelId) {
|
||||||
|
return modelTestMetricsJobRepository.getTestMetricSaveNotYetModelId(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
public void insertModelMetricsTest(List<Object[]> batchArgs) {
|
public void insertModelMetricsTest(List<Object[]> batchArgs) {
|
||||||
modelTestMetricsJobRepository.insertModelMetricsTest(batchArgs);
|
modelTestMetricsJobRepository.insertModelMetricsTest(batchArgs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ public class ModelTrainJobCoreService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 실행 시작 처리 수정 */
|
||||||
|
@Transactional
|
||||||
|
public void updateJobStatus(Long jobId, String jobStatus) {
|
||||||
|
ModelTrainJobEntity job =
|
||||||
|
modelTrainJobRepository
|
||||||
|
.findById(jobId)
|
||||||
|
.orElseThrow(() -> new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND));
|
||||||
|
|
||||||
|
job.setStatusCd(jobStatus);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 성공 처리
|
* 성공 처리
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ public class ModelTrainMetricsJobCoreService {
|
|||||||
return modelTrainMetricsJobRepository.getTrainMetricSaveNotYetModelIds();
|
return modelTrainMetricsJobRepository.getTrainMetricSaveNotYetModelIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ResponsePathDto getTrainMetricSaveNotYetModelId(Long modelId) {
|
||||||
|
return modelTrainMetricsJobRepository.getTrainMetricSaveNotYetModelId(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
public void insertModelMetricsTrain(List<Object[]> batchArgs) {
|
public void insertModelMetricsTrain(List<Object[]> batchArgs) {
|
||||||
modelTrainMetricsJobRepository.insertModelMetricsTrain(batchArgs);
|
modelTrainMetricsJobRepository.insertModelMetricsTrain(batchArgs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ public class ModelTrainMngCoreService {
|
|||||||
* @param addReq 요청 파라미터
|
* @param addReq 요청 파라미터
|
||||||
*/
|
*/
|
||||||
public void saveModelDataset(Long modelId, ModelTrainMngDto.AddReq addReq) {
|
public void saveModelDataset(Long modelId, ModelTrainMngDto.AddReq addReq) {
|
||||||
|
|
||||||
TrainingDataset dataset = addReq.getTrainingDataset();
|
TrainingDataset dataset = addReq.getTrainingDataset();
|
||||||
ModelMasterEntity modelMasterEntity = new ModelMasterEntity();
|
ModelMasterEntity modelMasterEntity = new ModelMasterEntity();
|
||||||
ModelDatasetEntity datasetEntity = new ModelDatasetEntity();
|
ModelDatasetEntity datasetEntity = new ModelDatasetEntity();
|
||||||
@@ -154,6 +155,8 @@ public class ModelTrainMngCoreService {
|
|||||||
datasetEntity.setWasteCnt(dataset.getSummary().getWasteCnt());
|
datasetEntity.setWasteCnt(dataset.getSummary().getWasteCnt());
|
||||||
} else if (addReq.getModelNo().equals(ModelType.G3.getId())) {
|
} else if (addReq.getModelNo().equals(ModelType.G3.getId())) {
|
||||||
datasetEntity.setLandCoverCnt(dataset.getSummary().getLandCoverCnt());
|
datasetEntity.setLandCoverCnt(dataset.getSummary().getLandCoverCnt());
|
||||||
|
} else if (addReq.getModelNo().equals(ModelType.G4.getId())) {
|
||||||
|
datasetEntity.setSolarCnt(dataset.getSummary().getSolarPanelCnt());
|
||||||
}
|
}
|
||||||
|
|
||||||
datasetEntity.setCreatedUid(userUtil.getId());
|
datasetEntity.setCreatedUid(userUtil.getId());
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ public class ModelDatasetEntity {
|
|||||||
@Column(name = "land_cover_cnt")
|
@Column(name = "land_cover_cnt")
|
||||||
private Long landCoverCnt;
|
private Long landCoverCnt;
|
||||||
|
|
||||||
|
@Column(name = "solar_cnt")
|
||||||
|
private Long solarCnt;
|
||||||
|
|
||||||
@ColumnDefault("now()")
|
@ColumnDefault("now()")
|
||||||
@Column(name = "created_dttm")
|
@Column(name = "created_dttm")
|
||||||
private ZonedDateTime createdDttm = ZonedDateTime.now();
|
private ZonedDateTime createdDttm = ZonedDateTime.now();
|
||||||
|
|||||||
@@ -181,15 +181,15 @@ public class ModelHyperParamEntity {
|
|||||||
private String metrics = "mFscore,mIoU";
|
private String metrics = "mFscore,mIoU";
|
||||||
|
|
||||||
/** Default: changed_fscore */
|
/** Default: changed_fscore */
|
||||||
@Size(max = 30)
|
@Size(max = 50)
|
||||||
@NotNull
|
@NotNull
|
||||||
@Column(name = "save_best", nullable = false, length = 30)
|
@Column(name = "save_best", nullable = false, length = 50)
|
||||||
private String saveBest = "changed_fscore";
|
private String saveBest = "changed_fscore";
|
||||||
|
|
||||||
/** Default: greater */
|
/** Default: greater */
|
||||||
@Size(max = 10)
|
@Size(max = 50)
|
||||||
@NotNull
|
@NotNull
|
||||||
@Column(name = "save_best_rule", nullable = false, length = 10)
|
@Column(name = "save_best_rule", nullable = false, length = 50)
|
||||||
private String saveBestRule = "greater";
|
private String saveBestRule = "greater";
|
||||||
|
|
||||||
/** Default: 1 */
|
/** Default: 1 */
|
||||||
|
|||||||
@@ -125,15 +125,15 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
|
|||||||
new CaseBuilder()
|
new CaseBuilder()
|
||||||
.when(
|
.when(
|
||||||
datasetObjEntity.targetClassCd.eq(DetectionClassification.BUILDING.getId()))
|
datasetObjEntity.targetClassCd.eq(DetectionClassification.BUILDING.getId()))
|
||||||
.then(1)
|
.then(1L)
|
||||||
.otherwise(0)
|
.otherwise(0L)
|
||||||
.sum(),
|
.sum(),
|
||||||
new CaseBuilder()
|
new CaseBuilder()
|
||||||
.when(
|
.when(
|
||||||
datasetObjEntity.targetClassCd.eq(
|
datasetObjEntity.targetClassCd.eq(
|
||||||
DetectionClassification.CONTAINER.getId()))
|
DetectionClassification.CONTAINER.getId()))
|
||||||
.then(1)
|
.then(1L)
|
||||||
.otherwise(0)
|
.otherwise(0L)
|
||||||
.sum()))
|
.sum()))
|
||||||
.from(dataset)
|
.from(dataset)
|
||||||
.leftJoin(datasetObjEntity)
|
.leftJoin(datasetObjEntity)
|
||||||
@@ -262,6 +262,7 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
|
|||||||
builder.and(dataset.deleted.isFalse());
|
builder.and(dataset.deleted.isFalse());
|
||||||
|
|
||||||
NumberExpression<Long> selectedCnt = null;
|
NumberExpression<Long> selectedCnt = null;
|
||||||
|
// G2
|
||||||
NumberExpression<Long> wasteCnt =
|
NumberExpression<Long> wasteCnt =
|
||||||
datasetObjEntity
|
datasetObjEntity
|
||||||
.targetClassCd
|
.targetClassCd
|
||||||
@@ -270,7 +271,7 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
|
|||||||
.otherwise(0L)
|
.otherwise(0L)
|
||||||
.sum();
|
.sum();
|
||||||
|
|
||||||
// G1, G2, G4 제외
|
// G3 (G1, G2, G4 제외)
|
||||||
NumberExpression<Long> elseCnt =
|
NumberExpression<Long> elseCnt =
|
||||||
new CaseBuilder()
|
new CaseBuilder()
|
||||||
.when(datasetObjEntity.targetClassCd.notIn(building, container, waste, solar))
|
.when(datasetObjEntity.targetClassCd.notIn(building, container, waste, solar))
|
||||||
@@ -278,13 +279,11 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
|
|||||||
.otherwise(0L)
|
.otherwise(0L)
|
||||||
.sum();
|
.sum();
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(req.getModelNo())) {
|
|
||||||
if (req.getModelNo().equals(ModelType.G2.getId())) {
|
if (req.getModelNo().equals(ModelType.G2.getId())) {
|
||||||
selectedCnt = wasteCnt;
|
selectedCnt = wasteCnt;
|
||||||
} else {
|
} else {
|
||||||
selectedCnt = elseCnt;
|
selectedCnt = elseCnt;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(req.getDataType())) {
|
if (StringUtils.isNotBlank(req.getDataType())) {
|
||||||
if (!"CURRENT".equals(req.getDataType())) {
|
if (!"CURRENT".equals(req.getDataType())) {
|
||||||
@@ -523,8 +522,8 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
|
|||||||
dataset.memo,
|
dataset.memo,
|
||||||
new CaseBuilder()
|
new CaseBuilder()
|
||||||
.when(datasetObjEntity.targetClassCd.eq(DetectionClassification.SOLAR.getId()))
|
.when(datasetObjEntity.targetClassCd.eq(DetectionClassification.SOLAR.getId()))
|
||||||
.then(1)
|
.then(1L)
|
||||||
.otherwise(0)
|
.otherwise(0L)
|
||||||
.sum()))
|
.sum()))
|
||||||
.from(dataset)
|
.from(dataset)
|
||||||
.leftJoin(datasetObjEntity)
|
.leftJoin(datasetObjEntity)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.kamco.cd.training.postgres.repository.model;
|
package com.kamco.cd.training.postgres.repository.model;
|
||||||
|
|
||||||
import static com.kamco.cd.training.postgres.entity.QDatasetEntity.datasetEntity;
|
import static com.kamco.cd.training.postgres.entity.QDatasetEntity.datasetEntity;
|
||||||
|
import static com.kamco.cd.training.postgres.entity.QDatasetObjEntity.datasetObjEntity;
|
||||||
import static com.kamco.cd.training.postgres.entity.QModelDatasetEntity.modelDatasetEntity;
|
import static com.kamco.cd.training.postgres.entity.QModelDatasetEntity.modelDatasetEntity;
|
||||||
import static com.kamco.cd.training.postgres.entity.QModelDatasetMappEntity.modelDatasetMappEntity;
|
import static com.kamco.cd.training.postgres.entity.QModelDatasetMappEntity.modelDatasetMappEntity;
|
||||||
import static com.kamco.cd.training.postgres.entity.QModelHyperParamEntity.modelHyperParamEntity;
|
import static com.kamco.cd.training.postgres.entity.QModelHyperParamEntity.modelHyperParamEntity;
|
||||||
@@ -9,6 +10,8 @@ import static com.kamco.cd.training.postgres.entity.QModelMetricsTestEntity.mode
|
|||||||
import static com.kamco.cd.training.postgres.entity.QModelMetricsTrainEntity.modelMetricsTrainEntity;
|
import static com.kamco.cd.training.postgres.entity.QModelMetricsTrainEntity.modelMetricsTrainEntity;
|
||||||
import static com.kamco.cd.training.postgres.entity.QModelMetricsValidationEntity.modelMetricsValidationEntity;
|
import static com.kamco.cd.training.postgres.entity.QModelMetricsValidationEntity.modelMetricsValidationEntity;
|
||||||
|
|
||||||
|
import com.kamco.cd.training.common.enums.DetectionClassification;
|
||||||
|
import com.kamco.cd.training.common.enums.ModelType;
|
||||||
import com.kamco.cd.training.common.enums.TrainStatusType;
|
import com.kamco.cd.training.common.enums.TrainStatusType;
|
||||||
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.DetailSummary;
|
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.DetailSummary;
|
||||||
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.HyperSummary;
|
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.HyperSummary;
|
||||||
@@ -25,6 +28,7 @@ import com.kamco.cd.training.postgres.entity.QModelHyperParamEntity;
|
|||||||
import com.kamco.cd.training.postgres.entity.QModelMasterEntity;
|
import com.kamco.cd.training.postgres.entity.QModelMasterEntity;
|
||||||
import com.querydsl.core.types.Expression;
|
import com.querydsl.core.types.Expression;
|
||||||
import com.querydsl.core.types.Projections;
|
import com.querydsl.core.types.Projections;
|
||||||
|
import com.querydsl.core.types.dsl.CaseBuilder;
|
||||||
import com.querydsl.jpa.JPAExpressions;
|
import com.querydsl.jpa.JPAExpressions;
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory;
|
import com.querydsl.jpa.impl.JPAQueryFactory;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -154,10 +158,78 @@ public class ModelDetailRepositoryImpl implements ModelDetailRepositoryCustom {
|
|||||||
datasetEntity.compareYyyy,
|
datasetEntity.compareYyyy,
|
||||||
datasetEntity.targetYyyy,
|
datasetEntity.targetYyyy,
|
||||||
datasetEntity.roundNo,
|
datasetEntity.roundNo,
|
||||||
modelDatasetEntity.buildingCnt,
|
|
||||||
modelDatasetEntity.containerCnt,
|
// G1 - building
|
||||||
modelDatasetEntity.wasteCnt,
|
new CaseBuilder()
|
||||||
modelDatasetEntity.landCoverCnt))
|
.when(
|
||||||
|
modelMasterEntity
|
||||||
|
.modelNo
|
||||||
|
.eq(ModelType.G1.getId())
|
||||||
|
.and(
|
||||||
|
datasetObjEntity.targetClassCd.eq(
|
||||||
|
DetectionClassification.BUILDING.getId())))
|
||||||
|
.then(1L)
|
||||||
|
.otherwise(0L)
|
||||||
|
.sum(),
|
||||||
|
|
||||||
|
// G1 - container
|
||||||
|
new CaseBuilder()
|
||||||
|
.when(
|
||||||
|
modelMasterEntity
|
||||||
|
.modelNo
|
||||||
|
.eq(ModelType.G1.getId())
|
||||||
|
.and(
|
||||||
|
datasetObjEntity.targetClassCd.eq(
|
||||||
|
DetectionClassification.CONTAINER.getId())))
|
||||||
|
.then(1L)
|
||||||
|
.otherwise(0L)
|
||||||
|
.sum(),
|
||||||
|
|
||||||
|
// G2 - waste
|
||||||
|
new CaseBuilder()
|
||||||
|
.when(
|
||||||
|
modelMasterEntity
|
||||||
|
.modelNo
|
||||||
|
.eq(ModelType.G2.getId())
|
||||||
|
.and(
|
||||||
|
datasetObjEntity.targetClassCd.eq(
|
||||||
|
DetectionClassification.WASTE.getId())))
|
||||||
|
.then(1L)
|
||||||
|
.otherwise(0L)
|
||||||
|
.sum(),
|
||||||
|
|
||||||
|
// G3 - 나머지
|
||||||
|
new CaseBuilder()
|
||||||
|
.when(
|
||||||
|
modelMasterEntity
|
||||||
|
.modelNo
|
||||||
|
.eq(ModelType.G3.getId())
|
||||||
|
.and(
|
||||||
|
datasetObjEntity
|
||||||
|
.targetClassCd
|
||||||
|
.isNotNull()
|
||||||
|
.and(
|
||||||
|
datasetObjEntity.targetClassCd.notIn(
|
||||||
|
DetectionClassification.BUILDING.getId(),
|
||||||
|
DetectionClassification.CONTAINER.getId(),
|
||||||
|
DetectionClassification.WASTE.getId(),
|
||||||
|
DetectionClassification.SOLAR.getId()))))
|
||||||
|
.then(1L)
|
||||||
|
.otherwise(0L)
|
||||||
|
.sum(),
|
||||||
|
|
||||||
|
// G4 - solar
|
||||||
|
new CaseBuilder()
|
||||||
|
.when(
|
||||||
|
modelMasterEntity
|
||||||
|
.modelNo
|
||||||
|
.eq(ModelType.G4.getId())
|
||||||
|
.and(
|
||||||
|
datasetObjEntity.targetClassCd.eq(
|
||||||
|
DetectionClassification.SOLAR.getId())))
|
||||||
|
.then(1L)
|
||||||
|
.otherwise(0L)
|
||||||
|
.sum()))
|
||||||
.from(modelMasterEntity)
|
.from(modelMasterEntity)
|
||||||
.innerJoin(modelDatasetEntity)
|
.innerJoin(modelDatasetEntity)
|
||||||
.on(modelMasterEntity.id.eq(modelDatasetEntity.model.id))
|
.on(modelMasterEntity.id.eq(modelDatasetEntity.model.id))
|
||||||
@@ -165,7 +237,16 @@ public class ModelDetailRepositoryImpl implements ModelDetailRepositoryCustom {
|
|||||||
.on(modelMasterEntity.id.eq(modelDatasetMappEntity.modelUid))
|
.on(modelMasterEntity.id.eq(modelDatasetMappEntity.modelUid))
|
||||||
.innerJoin(datasetEntity)
|
.innerJoin(datasetEntity)
|
||||||
.on(modelDatasetMappEntity.datasetUid.eq(datasetEntity.id))
|
.on(modelDatasetMappEntity.datasetUid.eq(datasetEntity.id))
|
||||||
|
.leftJoin(datasetObjEntity)
|
||||||
|
.on(datasetEntity.id.eq(datasetObjEntity.datasetUid))
|
||||||
.where(modelMasterEntity.uuid.eq(uuid))
|
.where(modelMasterEntity.uuid.eq(uuid))
|
||||||
|
.groupBy(
|
||||||
|
modelMasterEntity.id,
|
||||||
|
datasetEntity.id,
|
||||||
|
datasetEntity.dataType,
|
||||||
|
datasetEntity.compareYyyy,
|
||||||
|
datasetEntity.targetYyyy,
|
||||||
|
datasetEntity.roundNo)
|
||||||
.fetch();
|
.fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.kamco.cd.training.model.dto.ModelTrainMngDto;
|
|||||||
import com.kamco.cd.training.model.dto.ModelTrainMngDto.ListDto;
|
import com.kamco.cd.training.model.dto.ModelTrainMngDto.ListDto;
|
||||||
import com.kamco.cd.training.postgres.entity.ModelMasterEntity;
|
import com.kamco.cd.training.postgres.entity.ModelMasterEntity;
|
||||||
import com.kamco.cd.training.train.dto.TrainRunRequest;
|
import com.kamco.cd.training.train.dto.TrainRunRequest;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@@ -28,4 +29,14 @@ public interface ModelMngRepositoryCustom {
|
|||||||
TrainRunRequest findTrainRunRequest(Long modelId);
|
TrainRunRequest findTrainRunRequest(Long modelId);
|
||||||
|
|
||||||
Long findModelStep1InProgressCnt();
|
Long findModelStep1InProgressCnt();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모델 번호와 삭제 여부로 모델 조회 (최신순)
|
||||||
|
*
|
||||||
|
* @param modelNo 모델 번호 (G1, G2, G3, G4)
|
||||||
|
* @param delYn 삭제 여부
|
||||||
|
* @return 모델 목록
|
||||||
|
*/
|
||||||
|
List<ModelMasterEntity> findByModelNoAndDelYnOrderByCreatedDttmDesc(
|
||||||
|
String modelNo, Boolean delYn);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,4 +211,14 @@ public class ModelMngRepositoryImpl implements ModelMngRepositoryCustom {
|
|||||||
.or(modelMasterEntity.step2State.eq(TrainStatusType.IN_PROGRESS.getId())))
|
.or(modelMasterEntity.step2State.eq(TrainStatusType.IN_PROGRESS.getId())))
|
||||||
.fetchOne();
|
.fetchOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ModelMasterEntity> findByModelNoAndDelYnOrderByCreatedDttmDesc(
|
||||||
|
String modelNo, Boolean delYn) {
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(modelMasterEntity)
|
||||||
|
.where(modelMasterEntity.modelNo.eq(modelNo), modelMasterEntity.delYn.eq(delYn))
|
||||||
|
.orderBy(modelMasterEntity.createdDttm.desc())
|
||||||
|
.fetch();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ public interface ModelTestMetricsJobRepositoryCustom {
|
|||||||
|
|
||||||
List<ResponsePathDto> getTestMetricSaveNotYetModelIds();
|
List<ResponsePathDto> getTestMetricSaveNotYetModelIds();
|
||||||
|
|
||||||
|
ResponsePathDto getTestMetricSaveNotYetModelId(Long modelId);
|
||||||
|
|
||||||
void insertModelMetricsTest(List<Object[]> batchArgs);
|
void insertModelMetricsTest(List<Object[]> batchArgs);
|
||||||
|
|
||||||
ModelMetricJsonDto getTestMetricPackingInfo(Long modelId);
|
ModelMetricJsonDto getTestMetricPackingInfo(Long modelId);
|
||||||
|
|||||||
@@ -63,6 +63,25 @@ public class ModelTestMetricsJobRepositoryImpl extends QuerydslRepositorySupport
|
|||||||
.fetch();
|
.fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponsePathDto getTestMetricSaveNotYetModelId(Long modelId) {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
ResponsePathDto.class,
|
||||||
|
modelMasterEntity.id,
|
||||||
|
modelMasterEntity.responsePath,
|
||||||
|
modelMasterEntity.uuid))
|
||||||
|
.from(modelMasterEntity)
|
||||||
|
.where(
|
||||||
|
modelMasterEntity.id.eq(modelId),
|
||||||
|
modelMasterEntity
|
||||||
|
.step2MetricSaveYn
|
||||||
|
.isNull()
|
||||||
|
.or(modelMasterEntity.step2MetricSaveYn.isFalse()))
|
||||||
|
.fetchOne();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void insertModelMetricsTest(List<Object[]> batchArgs) {
|
public void insertModelMetricsTest(List<Object[]> batchArgs) {
|
||||||
// AS-IS
|
// AS-IS
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ public interface ModelTrainMetricsJobRepositoryCustom {
|
|||||||
|
|
||||||
List<ResponsePathDto> getTrainMetricSaveNotYetModelIds();
|
List<ResponsePathDto> getTrainMetricSaveNotYetModelIds();
|
||||||
|
|
||||||
|
ResponsePathDto getTrainMetricSaveNotYetModelId(Long modelId);
|
||||||
|
|
||||||
void insertModelMetricsTrain(List<Object[]> batchArgs);
|
void insertModelMetricsTrain(List<Object[]> batchArgs);
|
||||||
|
|
||||||
void updateModelMetricsTrainSaveYn(Long modelId, String stepNo);
|
void updateModelMetricsTrainSaveYn(Long modelId, String stepNo);
|
||||||
|
|||||||
@@ -44,6 +44,25 @@ public class ModelTrainMetricsJobRepositoryImpl extends QuerydslRepositorySuppor
|
|||||||
.fetch();
|
.fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponsePathDto getTrainMetricSaveNotYetModelId(Long modelId) {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
ResponsePathDto.class,
|
||||||
|
modelMasterEntity.id,
|
||||||
|
modelMasterEntity.responsePath,
|
||||||
|
modelMasterEntity.uuid))
|
||||||
|
.from(modelMasterEntity)
|
||||||
|
.where(
|
||||||
|
modelMasterEntity.id.eq(modelId),
|
||||||
|
modelMasterEntity
|
||||||
|
.step1MetricSaveYn
|
||||||
|
.isNull()
|
||||||
|
.or(modelMasterEntity.step1MetricSaveYn.isFalse()))
|
||||||
|
.fetchOne();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void insertModelMetricsTrain(List<Object[]> batchArgs) {
|
public void insertModelMetricsTrain(List<Object[]> batchArgs) {
|
||||||
String sql =
|
String sql =
|
||||||
|
|||||||
@@ -213,4 +213,27 @@ public class TrainApiController {
|
|||||||
Long modelId = trainJobService.getModelIdByUuid(uuid);
|
Long modelId = trainJobService.getModelIdByUuid(uuid);
|
||||||
return ApiResponseDto.ok(dataSetCountersService.getCount(modelId));
|
return ApiResponseDto.ok(dataSetCountersService.getCount(modelId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "학습 상태 확인", description = "학습 상태 확인")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "학습 상태 변경",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = String.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@PostMapping(path = "/status/{uuid}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public ApiResponseDto<String> status(
|
||||||
|
@Parameter(description = "uuid", example = "e22181eb-2ac4-4100-9941-d06efce25c49")
|
||||||
|
@PathVariable
|
||||||
|
UUID uuid) {
|
||||||
|
Long modelId = trainJobService.getModelIdByUuid(uuid);
|
||||||
|
trainJobService.status(uuid, modelId);
|
||||||
|
return ApiResponseDto.ok("ok");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.kamco.cd.training.train.dto;
|
||||||
|
|
||||||
|
public record DockerInspectState(boolean exists, boolean running, Integer exitCode, String status) {
|
||||||
|
|
||||||
|
public static DockerInspectState missing() {
|
||||||
|
return new DockerInspectState(false, false, null, "missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.kamco.cd.training.train.dto;
|
||||||
|
|
||||||
|
public class OutputResult {
|
||||||
|
|
||||||
|
private final boolean completed;
|
||||||
|
private final String reason;
|
||||||
|
|
||||||
|
public OutputResult(boolean completed, String reason) {
|
||||||
|
this.completed = completed;
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean completed() {
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String reason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,15 @@ public class DockerTrainService {
|
|||||||
@Value("${spring.profiles.active}")
|
@Value("${spring.profiles.active}")
|
||||||
private String profile;
|
private String profile;
|
||||||
|
|
||||||
|
@Value("${hyper.parameter.gpus}")
|
||||||
|
private String hyperGpus;
|
||||||
|
|
||||||
|
@Value("${hyper.parameter.gpu-ids}")
|
||||||
|
private String hyperGpuIds;
|
||||||
|
|
||||||
|
@Value("${hyper.parameter.batch-size}")
|
||||||
|
private Integer batchSize;
|
||||||
|
|
||||||
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -285,11 +294,13 @@ public class DockerTrainService {
|
|||||||
// addArg(c, "--gpu-ids", req.getGpuIds()); // null
|
// addArg(c, "--gpu-ids", req.getGpuIds()); // null
|
||||||
if ("prod".equals(profile)) {
|
if ("prod".equals(profile)) {
|
||||||
addArg(c, "--batch-size", 2); // 학습서버 GPU 1개인 곳은 batch-size:2 까지만 가능
|
addArg(c, "--batch-size", 2); // 학습서버 GPU 1개인 곳은 batch-size:2 까지만 가능
|
||||||
addArg(c, "--gpus", "1"); // 학습서버 GPU 1개인 곳은 1이어야 함
|
|
||||||
addArg(c, "--gpu-ids", "0"); // 학습서버 GPU 1개인 곳은 0이어야 함
|
|
||||||
} else {
|
} else {
|
||||||
addArg(c, "--batch-size", req.getBatchSize()); // 학습서버 GPU 1개인 곳은 batch-size:2 까지만 가능
|
addArg(c, "--batch-size", batchSize); // 학습서버 GPU 1개인 곳은 batch-size:2 까지만 가능
|
||||||
}
|
}
|
||||||
|
addArg(c, "--gpus", hyperGpus); // 학습서버 GPU 1개인 곳은 1이어야 함
|
||||||
|
addArg(c, "--gpu-ids", hyperGpuIds); // 학습서버 GPU 1개인 곳은 0이어야 함
|
||||||
|
|
||||||
addArg(c, "--lr", req.getLearningRate());
|
addArg(c, "--lr", req.getLearningRate());
|
||||||
addArg(c, "--backbone", req.getBackbone());
|
addArg(c, "--backbone", req.getBackbone());
|
||||||
addArg(c, "--epochs", req.getEpochs());
|
addArg(c, "--epochs", req.getEpochs());
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
package com.kamco.cd.training.train.service;
|
package com.kamco.cd.training.train.service;
|
||||||
|
|
||||||
import com.kamco.cd.training.model.dto.ModelTrainMngDto;
|
|
||||||
import com.kamco.cd.training.postgres.core.ModelTrainJobCoreService;
|
import com.kamco.cd.training.postgres.core.ModelTrainJobCoreService;
|
||||||
import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService;
|
import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService;
|
||||||
|
import com.kamco.cd.training.train.dto.DockerInspectState;
|
||||||
import com.kamco.cd.training.train.dto.ModelTrainJobDto;
|
import com.kamco.cd.training.train.dto.ModelTrainJobDto;
|
||||||
import java.io.BufferedReader;
|
import com.kamco.cd.training.train.dto.OutputResult;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.DirectoryStream;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.stream.Stream;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.log4j.Log4j2;
|
import lombok.extern.log4j.Log4j2;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
@@ -44,14 +35,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
||||||
private final ModelTrainMngCoreService modelTrainMngCoreService;
|
private final ModelTrainMngCoreService modelTrainMngCoreService;
|
||||||
private final ModelTrainMetricsJobService modelTrainMetricsJobService;
|
private final ModelTrainMetricsJobService modelTrainMetricsJobService;
|
||||||
|
private final TrainUtilService trainUtilService;
|
||||||
/**
|
|
||||||
* Docker 컨테이너가 쓰는 response(산출물) 디렉토리의 "호스트 측" 베이스 경로. 예) /data/train/response
|
|
||||||
*
|
|
||||||
* <p>컨테이너가 --rm 으로 삭제된 경우에도 이 경로에 val.csv / *.pth 등이 남아있으면 정상 종료 여부를 "파일 기반"으로 판정합니다.
|
|
||||||
*/
|
|
||||||
@Value("${train.docker.response_dir}")
|
|
||||||
private String responseDir;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 스프링 부팅 완료 시점(빈 생성/초기화 모두 끝난 뒤)에 복구 로직 실행.
|
* 스프링 부팅 완료 시점(빈 생성/초기화 모두 끝난 뒤)에 복구 로직 실행.
|
||||||
@@ -77,7 +61,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 2-1) docker inspect로 컨테이너 상태 조회
|
// 2-1) docker inspect로 컨테이너 상태 조회
|
||||||
DockerInspectState state = inspectContainer(containerName);
|
DockerInspectState state = trainUtilService.inspectContainer(containerName);
|
||||||
|
|
||||||
// 3) 컨테이너가 "없음"
|
// 3) 컨테이너가 "없음"
|
||||||
// - docker run --rm 로 실행한 컨테이너는 정상 종료 시 바로 삭제될 수 있음
|
// - docker run --rm 로 실행한 컨테이너는 정상 종료 시 바로 삭제될 수 있음
|
||||||
@@ -88,7 +72,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
containerName);
|
containerName);
|
||||||
|
|
||||||
// 3-1) 컨테이너가 없을 때는 산출물(responseDir)을 보고 완료 여부를 "추정"
|
// 3-1) 컨테이너가 없을 때는 산출물(responseDir)을 보고 완료 여부를 "추정"
|
||||||
OutputResult out = probeOutputs(job);
|
OutputResult out = trainUtilService.probeOutputs(job);
|
||||||
|
|
||||||
// 3-2) 산출물이 충분하면 성공 처리
|
// 3-2) 산출물이 충분하면 성공 처리
|
||||||
if (out.completed()) {
|
if (out.completed()) {
|
||||||
@@ -109,11 +93,9 @@ public class JobRecoveryOnStartupService {
|
|||||||
job.getId(),
|
job.getId(),
|
||||||
out.reason());
|
out.reason());
|
||||||
|
|
||||||
Integer modelId = job.getModelId() == null ? null : Math.toIntExact(job.getModelId());
|
|
||||||
|
|
||||||
// PAUSED/STOP
|
// PAUSED/STOP
|
||||||
modelTrainJobCoreService.markPaused(
|
modelTrainJobCoreService.markPaused(
|
||||||
job.getId(), modelId, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE");
|
job.getId(), -1, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE");
|
||||||
|
|
||||||
// 모델도 에러가 아니라 STOP으로
|
// 모델도 에러가 아니라 STOP으로
|
||||||
markStepStopByJobType(
|
markStepStopByJobType(
|
||||||
@@ -152,7 +134,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// 2) kill 후 실제로 죽었는지 확인
|
// 2) kill 후 실제로 죽었는지 확인
|
||||||
// ============================================================
|
// ============================================================
|
||||||
DockerInspectState after = inspectContainer(containerName);
|
DockerInspectState after = trainUtilService.inspectContainer(containerName);
|
||||||
if (after.exists() && after.running()) {
|
if (after.exists() && after.running()) {
|
||||||
throw new IOException("docker kill returned 0 but container still running");
|
throw new IOException("docker kill returned 0 but container still running");
|
||||||
}
|
}
|
||||||
@@ -162,10 +144,8 @@ public class JobRecoveryOnStartupService {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// 3) job 상태를 PAUSED로 변경 (서버 재기동으로 강제 중단)
|
// 3) job 상태를 PAUSED로 변경 (서버 재기동으로 강제 중단)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
Integer modelId = job.getModelId() == null ? null : Math.toIntExact(job.getModelId());
|
|
||||||
|
|
||||||
modelTrainJobCoreService.markPaused(
|
modelTrainJobCoreService.markPaused(job.getId(), -1, "AUTO_KILLED_ON_SERVER_RESTART");
|
||||||
job.getId(), modelId, "AUTO_KILLED_ON_SERVER_RESTART");
|
|
||||||
|
|
||||||
log.info("job = {}", job);
|
log.info("job = {}", job);
|
||||||
markStepStopByJobType(job, "AUTO_KILLED_ON_SERVER_RESTART");
|
markStepStopByJobType(job, "AUTO_KILLED_ON_SERVER_RESTART");
|
||||||
@@ -264,301 +244,4 @@ public class JobRecoveryOnStartupService {
|
|||||||
modelTrainMngCoreService.markError(job.getModelId(), msg);
|
modelTrainMngCoreService.markError(job.getModelId(), msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* docker inspect를 사용해서 컨테이너 상태를 조회합니다.
|
|
||||||
*
|
|
||||||
* <p>사용하는 템플릿: {{.State.Status}} {{.State.Running}} {{.State.ExitCode}}
|
|
||||||
*
|
|
||||||
* <p>예상 출력 예: - "running true 0" - "exited false 0" - "exited false 137"
|
|
||||||
*
|
|
||||||
* <p>주의: - 컨테이너가 없거나 inspect 실패 시 exitCode != 0 또는 output이 비어서 missing() 반환 - 무한 대기 방지를 위해 5초
|
|
||||||
* 타임아웃을 둠
|
|
||||||
*/
|
|
||||||
private DockerInspectState inspectContainer(String containerName)
|
|
||||||
throws IOException, InterruptedException {
|
|
||||||
|
|
||||||
ProcessBuilder pb =
|
|
||||||
new ProcessBuilder(
|
|
||||||
"docker",
|
|
||||||
"inspect",
|
|
||||||
"-f",
|
|
||||||
"{{.State.Status}} {{.State.Running}} {{.State.ExitCode}}",
|
|
||||||
containerName);
|
|
||||||
|
|
||||||
// stderr를 stdout으로 합쳐서 한 스트림으로 읽기(에러 메시지도 함께 받음)
|
|
||||||
pb.redirectErrorStream(true);
|
|
||||||
|
|
||||||
Process p = pb.start();
|
|
||||||
|
|
||||||
// inspect 출력은 1줄이면 충분하므로 readLine()만 수행
|
|
||||||
String output;
|
|
||||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
|
|
||||||
output = br.readLine();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 무한대기 방지: 5초 내에 종료되지 않으면 강제 종료
|
|
||||||
boolean finished = p.waitFor(5, TimeUnit.SECONDS);
|
|
||||||
if (!finished) {
|
|
||||||
p.destroyForcibly();
|
|
||||||
throw new IOException("docker inspect timeout");
|
|
||||||
}
|
|
||||||
|
|
||||||
// docker inspect 자체의 프로세스 exit code
|
|
||||||
int code = p.exitValue();
|
|
||||||
|
|
||||||
// 실패(코드 !=0) 또는 출력이 없으면 "컨테이너 없음"으로 간주
|
|
||||||
if (code != 0 || output == null || output.isBlank()) {
|
|
||||||
return DockerInspectState.missing();
|
|
||||||
}
|
|
||||||
|
|
||||||
// "status running exitCode" 형태로 split
|
|
||||||
String[] parts = output.trim().split("\\s+");
|
|
||||||
|
|
||||||
// status: running/exited/dead 등
|
|
||||||
String status = parts.length > 0 ? parts[0] : "unknown";
|
|
||||||
|
|
||||||
// running: true/false
|
|
||||||
boolean running = parts.length > 1 && Boolean.parseBoolean(parts[1]);
|
|
||||||
|
|
||||||
// exitCode: 정수 파싱(파싱 실패하면 null)
|
|
||||||
Integer exitCode = null;
|
|
||||||
if (parts.length > 2) {
|
|
||||||
try {
|
|
||||||
exitCode = Integer.parseInt(parts[2]);
|
|
||||||
} catch (Exception ignore) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DockerInspectState(true, running, exitCode, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* docker inspect 결과를 담는 레코드.
|
|
||||||
*
|
|
||||||
* <p>exists: - true : docker inspect 성공 (컨테이너 존재) - false : 컨테이너 없음(또는 inspect 실패를 missing으로 간주)
|
|
||||||
*/
|
|
||||||
private record DockerInspectState(
|
|
||||||
boolean exists, boolean running, Integer exitCode, String status) {
|
|
||||||
static DockerInspectState missing() {
|
|
||||||
return new DockerInspectState(false, false, null, "missing");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================================
|
|
||||||
// 컨테이너가 "없을 때" 파일 기반으로 완료/미완료를 판정하는 로직
|
|
||||||
// ============================================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컨테이너가 없을 때(responseDir 산출물만 남아있는 상태) 완료 여부를 파일 기반으로 판정합니다.
|
|
||||||
*
|
|
||||||
* <p>판정 규칙(보수적으로 설계): 1) total_epoch가 paramsJson에 있어야 함 (없으면 완료 판단 불가) 2) val.csv 존재 + 헤더 제외 라인 수
|
|
||||||
* >= total_epoch 이어야 함 3) *.pth 파일이 total_epoch 이상 존재하거나, best*.pth(또는 *best*.pth)가 존재해야 함
|
|
||||||
*
|
|
||||||
* <p>왜 이렇게? - 어떤 학습은 epoch마다 pth를 남기고 - 어떤 학습은 best만 남기기도 해서 "pthCount >= total_epoch"만 쓰면 정상 종료를
|
|
||||||
* 실패로 오판할 수 있음.
|
|
||||||
*/
|
|
||||||
private OutputResult probeOutputs(ModelTrainJobDto job) {
|
|
||||||
try {
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"[RECOVERY] probeOutputs start. jobId={}, modelId={}", job.getId(), job.getModelId());
|
|
||||||
|
|
||||||
// 1) 출력 디렉토리 확인
|
|
||||||
Path outDir = resolveOutputDir(job);
|
|
||||||
if (outDir == null || !Files.isDirectory(outDir)) {
|
|
||||||
log.warn("[RECOVERY] output directory missing. jobId={}, path={}", job.getId(), outDir);
|
|
||||||
return new OutputResult(false, "output-dir-missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("[RECOVERY] output directory found. jobId={}, path={}", job.getId(), outDir);
|
|
||||||
|
|
||||||
// 2) totalEpoch 확인
|
|
||||||
Integer totalEpoch = extractTotalEpoch(job).orElse(null);
|
|
||||||
if (totalEpoch == null || totalEpoch <= 0) {
|
|
||||||
log.warn(
|
|
||||||
"[RECOVERY] totalEpoch missing or invalid. jobId={}, totalEpoch={}",
|
|
||||||
job.getId(),
|
|
||||||
totalEpoch);
|
|
||||||
return new OutputResult(false, "total-epoch-missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
Integer valInterval = extractValInterval(job).orElse(null);
|
|
||||||
if (valInterval == null || valInterval <= 0) {
|
|
||||||
log.warn(
|
|
||||||
"[RECOVERY] valInterval missing or invalid. jobId={}, valInterval={}",
|
|
||||||
job.getId(),
|
|
||||||
valInterval);
|
|
||||||
return new OutputResult(false, "val-interval-missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"[RECOVERY] totalEpoch={}. valInterval={}. jobId={}",
|
|
||||||
totalEpoch,
|
|
||||||
valInterval,
|
|
||||||
job.getId());
|
|
||||||
|
|
||||||
// 3) val.csv 존재 확인
|
|
||||||
Path valCsv = outDir.resolve("val.csv");
|
|
||||||
if (!Files.exists(valCsv)) {
|
|
||||||
log.warn("[RECOVERY] val.csv missing. jobId={}, path={}", job.getId(), valCsv);
|
|
||||||
return new OutputResult(false, "val.csv-missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) val.csv 라인 수 확인
|
|
||||||
long lines = countNonHeaderLines(valCsv);
|
|
||||||
|
|
||||||
// expected = 실제 val 실행 횟수
|
|
||||||
int expectedLines = totalEpoch / valInterval;
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"[RECOVERY] val.csv lines counted. jobId={}, lines={}, expected={}",
|
|
||||||
job.getId(),
|
|
||||||
lines,
|
|
||||||
expectedLines);
|
|
||||||
|
|
||||||
// 5) 완료 판정
|
|
||||||
if (lines >= expectedLines) {
|
|
||||||
log.info("[RECOVERY] outputs look COMPLETE. jobId={}", job.getId());
|
|
||||||
return new OutputResult(true, "ok");
|
|
||||||
}
|
|
||||||
|
|
||||||
log.warn(
|
|
||||||
"[RECOVERY] val.csv line mismatch. jobId={}, lines={}, expected={}",
|
|
||||||
job.getId(),
|
|
||||||
lines,
|
|
||||||
expectedLines);
|
|
||||||
|
|
||||||
return new OutputResult(
|
|
||||||
false, "val.csv-lines-mismatch lines=" + lines + " expected=" + totalEpoch);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
|
|
||||||
log.error("[RECOVERY] probeOutputs error. jobId={}", job.getId(), e);
|
|
||||||
|
|
||||||
return new OutputResult(false, "probe-error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* responseDir 아래에서 job 산출물 디렉토리를 찾습니다.
|
|
||||||
*
|
|
||||||
* <p>가장 중요한 커스터마이징 포인트: - 실제 운영 환경에서 산출물이 어떤 경로 규칙으로 저장되는지에 따라 여기만 수정하면 됩니다.
|
|
||||||
*
|
|
||||||
* <p>현재 기본 탐색 순서: 1) {responseDir}/{jobId} 2) {responseDir}/{modelId} 3)
|
|
||||||
* {responseDir}/{containerName} 4) 마지막 fallback: responseDir 자체
|
|
||||||
*
|
|
||||||
* <p>추천: - 여러분 규칙이 "{responseDir}/{modelId}/{jobId}" 같은 형태라면 base.resolve(modelId).resolve(jobId)
|
|
||||||
* 형태를 1순위로 두세요.
|
|
||||||
*/
|
|
||||||
private Path resolveOutputDir(ModelTrainJobDto job) {
|
|
||||||
ModelTrainMngDto.Basic model = modelTrainMngCoreService.findModelById(job.getModelId());
|
|
||||||
|
|
||||||
Path base = Paths.get(responseDir, model.getUuid().toString(), "metrics");
|
|
||||||
|
|
||||||
return Files.isDirectory(base) ? base : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* paramsJson에서 total_epoch 값을 추출합니다.
|
|
||||||
*
|
|
||||||
* <p>키 후보: - "total_epoch" (snake_case) - "totalEpoch" (camelCase)
|
|
||||||
*
|
|
||||||
* <p>예: paramsJson = {"jobType":"TRAIN","total_epoch":50,...}
|
|
||||||
*/
|
|
||||||
private Optional<Integer> extractTotalEpoch(ModelTrainJobDto job) {
|
|
||||||
Map<String, Object> params = job.getParamsJson();
|
|
||||||
if (params == null) return Optional.empty();
|
|
||||||
|
|
||||||
Object v = params.get("total_epoch");
|
|
||||||
if (v == null) v = params.get("totalEpoch");
|
|
||||||
if (v == null) return Optional.empty();
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Optional.of(Integer.parseInt(String.valueOf(v)));
|
|
||||||
} catch (Exception ignore) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSV 파일에서 "헤더(첫 줄)"를 제외한 라인 수를 계산합니다.
|
|
||||||
*
|
|
||||||
* <p>가정: - val.csv 첫 줄은 헤더 - 이후 라인들이 epoch별 기록(또는 유사한 누적 기록)
|
|
||||||
*
|
|
||||||
* <p>주의: - 파일 인코딩은 UTF-8로 가정 - 빈 줄은 제외
|
|
||||||
*/
|
|
||||||
private long countNonHeaderLines(Path csv) throws IOException {
|
|
||||||
try (Stream<String> lines = Files.lines(csv, StandardCharsets.UTF_8)) {
|
|
||||||
return lines.skip(1).filter(s -> s != null && !s.isBlank()).count();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 디렉토리에서 glob 패턴에 맞는 파일 수를 셉니다.
|
|
||||||
*
|
|
||||||
* <p>예: - "*.pth" - "best*.pth"
|
|
||||||
*/
|
|
||||||
private long countFilesByGlob(Path dir, String glob) throws IOException {
|
|
||||||
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, glob)) {
|
|
||||||
long cnt = 0;
|
|
||||||
for (Path p : ds) {
|
|
||||||
if (Files.isRegularFile(p)) cnt++;
|
|
||||||
}
|
|
||||||
return cnt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 디렉토리에서 glob 패턴에 맞는 파일이 "하나라도" 존재하는지 체크합니다. */
|
|
||||||
private boolean existsByGlob(Path dir, String glob) throws IOException {
|
|
||||||
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, glob)) {
|
|
||||||
return ds.iterator().hasNext();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================================
|
|
||||||
// probeOutputs() 결과 객체
|
|
||||||
// ============================================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컨테이너가 없을 때(responseDir 기반) 완료 여부 판정 결과.
|
|
||||||
*
|
|
||||||
* <p>completed: - true : 산출물이 완료로 보임(성공 처리 가능) - false : 산출물이 부족/불명확(실패 또는 유예 판단)
|
|
||||||
*
|
|
||||||
* <p>reason: - 실패/미완료 사유(로그/DB 메시지로 남기기 용도)
|
|
||||||
*/
|
|
||||||
private static final class OutputResult {
|
|
||||||
|
|
||||||
private final boolean completed;
|
|
||||||
private final String reason;
|
|
||||||
|
|
||||||
private OutputResult(boolean completed, String reason) {
|
|
||||||
this.completed = completed;
|
|
||||||
this.reason = reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean completed() {
|
|
||||||
return completed;
|
|
||||||
}
|
|
||||||
|
|
||||||
String reason() {
|
|
||||||
return reason;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** paramsJson에서 valInterval 추출 */
|
|
||||||
private Optional<Integer> extractValInterval(ModelTrainJobDto job) {
|
|
||||||
Map<String, Object> params = job.getParamsJson();
|
|
||||||
if (params == null) return Optional.empty();
|
|
||||||
|
|
||||||
Object v = params.get("valInterval");
|
|
||||||
if (v == null) return Optional.empty();
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Optional.of(Integer.parseInt(String.valueOf(v)));
|
|
||||||
} catch (Exception ignore) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,28 @@ public class ModelTestMetricsJobService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (ResponsePathDto modelInfo : modelIds) {
|
for (ResponsePathDto modelInfo : modelIds) {
|
||||||
|
createFile(modelInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단건 결과 csv 파일 정보 등록 */
|
||||||
|
public void testValidMetricCsvFiles(Long modelId) {
|
||||||
|
|
||||||
|
ResponsePathDto model = modelTestMetricsJobCoreService.getTestMetricSaveNotYetModelId(modelId);
|
||||||
|
|
||||||
|
if (model == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createFile(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 베스트 에폭 zip파일 생성, 테스트결과 db등록
|
||||||
|
*
|
||||||
|
* @param modelInfo
|
||||||
|
*/
|
||||||
|
private void createFile(ResponsePathDto modelInfo) {
|
||||||
|
|
||||||
String testPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/test.csv";
|
String testPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/test.csv";
|
||||||
try (BufferedReader reader =
|
try (BufferedReader reader =
|
||||||
@@ -102,16 +124,14 @@ public class ModelTestMetricsJobService {
|
|||||||
modelTestMetricsJobCoreService.insertModelMetricsTest(batchArgs);
|
modelTestMetricsJobCoreService.insertModelMetricsTest(batchArgs);
|
||||||
|
|
||||||
// test.csv 파일 읽어서 저장한 여부로만 사용하기
|
// test.csv 파일 읽어서 저장한 여부로만 사용하기
|
||||||
modelTestMetricsJobCoreService.updateModelMetricsTrainSaveYn(
|
modelTestMetricsJobCoreService.updateModelMetricsTrainSaveYn(modelInfo.getModelId(), "step2");
|
||||||
modelInfo.getModelId(), "step2");
|
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 패키징할 파일 만들기
|
// 패키징할 파일 만들기
|
||||||
modelTestMetricsJobCoreService.updatePackingStart(
|
modelTestMetricsJobCoreService.updatePackingStart(modelInfo.getModelId(), ZonedDateTime.now());
|
||||||
modelInfo.getModelId(), ZonedDateTime.now());
|
|
||||||
|
|
||||||
ModelMetricJsonDto jsonDto =
|
ModelMetricJsonDto jsonDto =
|
||||||
modelTestMetricsJobCoreService.getTestMetricPackingInfo(modelInfo.getModelId());
|
modelTestMetricsJobCoreService.getTestMetricPackingInfo(modelInfo.getModelId());
|
||||||
@@ -119,12 +139,7 @@ public class ModelTestMetricsJobService {
|
|||||||
writeJsonFile(
|
writeJsonFile(
|
||||||
jsonDto,
|
jsonDto,
|
||||||
Paths.get(
|
Paths.get(
|
||||||
responseDir
|
responseDir + "/" + modelInfo.getUuid() + "/" + jsonDto.getModelVersion() + ".json"));
|
||||||
+ "/"
|
|
||||||
+ modelInfo.getUuid()
|
|
||||||
+ "/"
|
|
||||||
+ jsonDto.getModelVersion()
|
|
||||||
+ ".json"));
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
@@ -173,7 +188,6 @@ public class ModelTestMetricsJobService {
|
|||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private void writeJsonFile(Object data, Path outputPath) throws IOException {
|
private void writeJsonFile(Object data, Path outputPath) throws IOException {
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,28 @@ public class ModelTrainMetricsJobService {
|
|||||||
|
|
||||||
for (ResponsePathDto modelInfo : modelIds) {
|
for (ResponsePathDto modelInfo : modelIds) {
|
||||||
|
|
||||||
|
createFile(modelInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단건 결과 csv 파일 정보 등록 */
|
||||||
|
public void trainValidMetricCsvFile(Long modelId) {
|
||||||
|
|
||||||
|
ResponsePathDto modelInfo =
|
||||||
|
modelTrainMetricsJobCoreService.getTrainMetricSaveNotYetModelId(modelId);
|
||||||
|
|
||||||
|
if (modelInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createFile(modelInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학습 csv 파일 db 등록
|
||||||
|
*
|
||||||
|
* @param modelInfo
|
||||||
|
*/
|
||||||
|
private void createFile(ResponsePathDto modelInfo) {
|
||||||
String trainPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/train.csv";
|
String trainPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/train.csv";
|
||||||
try (BufferedReader reader =
|
try (BufferedReader reader =
|
||||||
Files.newBufferedReader(Paths.get(trainPath), StandardCharsets.UTF_8); ) {
|
Files.newBufferedReader(Paths.get(trainPath), StandardCharsets.UTF_8); ) {
|
||||||
@@ -152,9 +174,7 @@ public class ModelTrainMetricsJobService {
|
|||||||
// best_changed_fscore_epoch_숫자.pth -> 숫자 값 가지고 와서 베스트 에폭에 업데이트 하기
|
// best_changed_fscore_epoch_숫자.pth -> 숫자 값 가지고 와서 베스트 에폭에 업데이트 하기
|
||||||
modelTrainMetricsJobCoreService.updateModelSelectedBestEpoch(modelInfo.getModelId(), epoch);
|
modelTrainMetricsJobCoreService.updateModelSelectedBestEpoch(modelInfo.getModelId(), epoch);
|
||||||
|
|
||||||
modelTrainMetricsJobCoreService.updateModelMetricsTrainSaveYn(
|
modelTrainMetricsJobCoreService.updateModelMetricsTrainSaveYn(modelInfo.getModelId(), "step1");
|
||||||
modelInfo.getModelId(), "step1");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Float parseFloatSafe(String value) {
|
private Float parseFloatSafe(String value) {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package com.kamco.cd.training.train.service;
|
package com.kamco.cd.training.train.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.kamco.cd.training.common.enums.JobStatusType;
|
||||||
|
import com.kamco.cd.training.common.enums.TrainStatusType;
|
||||||
import com.kamco.cd.training.common.exception.CustomApiException;
|
import com.kamco.cd.training.common.exception.CustomApiException;
|
||||||
import com.kamco.cd.training.model.dto.ModelTrainMngDto;
|
import com.kamco.cd.training.model.dto.ModelTrainMngDto;
|
||||||
import com.kamco.cd.training.postgres.core.ModelTrainJobCoreService;
|
import com.kamco.cd.training.postgres.core.ModelTrainJobCoreService;
|
||||||
import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService;
|
import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService;
|
||||||
|
import com.kamco.cd.training.train.dto.DockerInspectState;
|
||||||
import com.kamco.cd.training.train.dto.ModelTrainJobDto;
|
import com.kamco.cd.training.train.dto.ModelTrainJobDto;
|
||||||
import com.kamco.cd.training.train.dto.ModelTrainJobQueuedEvent;
|
import com.kamco.cd.training.train.dto.ModelTrainJobQueuedEvent;
|
||||||
import com.kamco.cd.training.train.dto.ModelTrainLinkDto;
|
import com.kamco.cd.training.train.dto.ModelTrainLinkDto;
|
||||||
|
import com.kamco.cd.training.train.dto.OutputResult;
|
||||||
import com.kamco.cd.training.train.dto.TrainRunRequest;
|
import com.kamco.cd.training.train.dto.TrainRunRequest;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@@ -16,6 +20,7 @@ import java.nio.file.Paths;
|
|||||||
import java.time.ZonedDateTime;
|
import java.time.ZonedDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.log4j.Log4j2;
|
import lombok.extern.log4j.Log4j2;
|
||||||
@@ -33,11 +38,14 @@ public class TrainJobService {
|
|||||||
|
|
||||||
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
||||||
private final ModelTrainMngCoreService modelTrainMngCoreService;
|
private final ModelTrainMngCoreService modelTrainMngCoreService;
|
||||||
|
private final ModelTrainMetricsJobService modelTrainMetricsJobService;
|
||||||
|
private final ModelTestMetricsJobService modelTestMetricsJobService;
|
||||||
private final DockerTrainService dockerTrainService;
|
private final DockerTrainService dockerTrainService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
private final TmpDatasetService tmpDatasetService;
|
private final TmpDatasetService tmpDatasetService;
|
||||||
private final DataSetCountersService dataSetCounters;
|
private final DataSetCountersService dataSetCounters;
|
||||||
|
private final TrainUtilService trainUtilService;
|
||||||
|
|
||||||
// 학습 결과가 저장될 호스트 디렉토리
|
// 학습 결과가 저장될 호스트 디렉토리
|
||||||
@Value("${train.docker.response_dir}")
|
@Value("${train.docker.response_dir}")
|
||||||
@@ -309,4 +317,133 @@ public class TrainJobService {
|
|||||||
}
|
}
|
||||||
return modelUuid;
|
return modelUuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동작되고잇는 pid을 확인하고 pid 목록에 있으면 진행중, 없으면 종료(결과 csv, zip 파일 확인하여 완료 여부 체크)
|
||||||
|
*
|
||||||
|
* @param uuid
|
||||||
|
* @param modelId
|
||||||
|
*/
|
||||||
|
public void status(UUID uuid, Long modelId) {
|
||||||
|
|
||||||
|
ModelTrainJobDto job =
|
||||||
|
modelTrainJobCoreService
|
||||||
|
.findLatestByModelId(modelId)
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("job not found"));
|
||||||
|
|
||||||
|
ModelTrainMngDto.Basic model = modelTrainMngCoreService.findModelById(modelId);
|
||||||
|
|
||||||
|
// TODO 실행중 상태인것만 변경해야하면 주석 해제
|
||||||
|
// if(job.getStatusCd().equals(JobStatusType.RUNNING.getId())) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
String containerName = job.getContainerName();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// docker inspect로 컨테이너 상태 조회
|
||||||
|
DockerInspectState state = trainUtilService.inspectContainer(containerName);
|
||||||
|
|
||||||
|
// 컨테이너가 "없음"
|
||||||
|
// - docker run --rm 로 실행한 컨테이너는 정상 종료 시 바로 삭제될 수 있음
|
||||||
|
// - 즉 "컨테이너 없음"이 무조건 실패는 아님
|
||||||
|
if (!state.exists()) {
|
||||||
|
log.warn("container missing. try file-based reconcile. container={}", containerName);
|
||||||
|
|
||||||
|
// 컨테이너가 없을 때는 산출물(responseDir)을 보고 완료 여부를 "추정"
|
||||||
|
OutputResult out = trainUtilService.probeOutputs(job);
|
||||||
|
|
||||||
|
// 산출물이 충분하면 성공 처리
|
||||||
|
if (out.completed()) {
|
||||||
|
|
||||||
|
// 테스트 완료인지 zip파일로 확인
|
||||||
|
if (trainUtilService.existsZipFile(uuid)) {
|
||||||
|
// 테스트 완료일때
|
||||||
|
log.info("outputs look completed. mark SUCCESS. jobId={}", job.getId());
|
||||||
|
|
||||||
|
// job 완료처리
|
||||||
|
modelTrainJobCoreService.markSuccess(job.getId(), 0);
|
||||||
|
|
||||||
|
// 학습 완료가 아니면 완료 업데이트
|
||||||
|
if (!model.getStep1Status().equals(TrainStatusType.COMPLETED.getId())) {
|
||||||
|
|
||||||
|
// model 상태 변경 (학습)
|
||||||
|
modelTrainMngCoreService.markStep1Success(job.getModelId());
|
||||||
|
// 학습 결과 csv 파일 정보 등록
|
||||||
|
modelTrainMetricsJobService.trainValidMetricCsvFile(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테스트 완료가 아니면 완료 업데이트
|
||||||
|
if (!model.getStep2Status().equals(TrainStatusType.COMPLETED.getId())) {
|
||||||
|
// model 상태 변경 (테스트)
|
||||||
|
modelTrainMngCoreService.markStep2Success(job.getModelId());
|
||||||
|
// 테스트 결과 csv 파일 정보 등록
|
||||||
|
modelTestMetricsJobService.testValidMetricCsvFiles(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 학습 완료일때
|
||||||
|
log.info("outputs look completed. mark SUCCESS. jobId={}", job.getId());
|
||||||
|
modelTrainJobCoreService.markSuccess(job.getId(), 0);
|
||||||
|
// 학습 완료가 아니면 완료 업데이트
|
||||||
|
if (!model.getStep1Status().equals(TrainStatusType.COMPLETED.getId())) {
|
||||||
|
|
||||||
|
// model 상태 변경 (학습)
|
||||||
|
modelTrainMngCoreService.markStep1Success(job.getModelId());
|
||||||
|
// 학습 결과 csv 파일 정보 등록
|
||||||
|
modelTrainMetricsJobService.trainValidMetricCsvFile(modelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// 산출물이 부족하면 중단처리
|
||||||
|
// 산출물이 부족하면 "중단/보류"로 처리
|
||||||
|
// 운영자가 재시작 할 수 있게 한다.
|
||||||
|
log.warn(
|
||||||
|
"outputs incomplete. mark PAUSED/STOP for restart. jobId={} reason={}",
|
||||||
|
job.getId(),
|
||||||
|
out.reason());
|
||||||
|
|
||||||
|
// PAUSED/STOP
|
||||||
|
modelTrainJobCoreService.markPaused(
|
||||||
|
job.getId(), -1, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE");
|
||||||
|
|
||||||
|
// STOP으로 변경
|
||||||
|
markStepStopByJobType(
|
||||||
|
job, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE: " + out.reason());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 컨테이너가 있으면 진행중처리
|
||||||
|
Map<String, Object> params = job.getParamsJson();
|
||||||
|
boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType")));
|
||||||
|
if (isEval) {
|
||||||
|
// 테스트 진행중 상태로 업데이트
|
||||||
|
modelTrainMngCoreService.markStep2InProgress(job.getModelId(), job.getId());
|
||||||
|
} else {
|
||||||
|
// 학습 진행중 상태로 업데이트
|
||||||
|
modelTrainMngCoreService.markStep1InProgress(job.getModelId(), job.getId());
|
||||||
|
}
|
||||||
|
// job 테이블 진행중으로 업데이트
|
||||||
|
modelTrainJobCoreService.updateJobStatus(job.getId(), JobStatusType.RUNNING.getId());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("container inspect failed. container={}", containerName, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jobType에 따라 학습 관리 테이블의 "에러 단계"를 업데이트.
|
||||||
|
*
|
||||||
|
* <p>예: - jobType == "EVAL" → step2(평가 단계) 에러 - 그 외 → step1 혹은 전체 에러
|
||||||
|
*/
|
||||||
|
private void markStepStopByJobType(ModelTrainJobDto job, String msg) {
|
||||||
|
Map<String, Object> params = job.getParamsJson();
|
||||||
|
boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType")));
|
||||||
|
if (isEval) {
|
||||||
|
modelTrainMngCoreService.markStep2Stop(job.getModelId(), msg);
|
||||||
|
} else {
|
||||||
|
modelTrainMngCoreService.markStep1Stop(job.getModelId(), msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package com.kamco.cd.training.train.service;
|
||||||
|
|
||||||
|
import com.kamco.cd.training.model.dto.ModelTrainMngDto;
|
||||||
|
import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService;
|
||||||
|
import com.kamco.cd.training.train.dto.DockerInspectState;
|
||||||
|
import com.kamco.cd.training.train.dto.ModelTrainJobDto;
|
||||||
|
import com.kamco.cd.training.train.dto.OutputResult;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TrainUtilService {
|
||||||
|
|
||||||
|
private final ModelTrainMngCoreService modelTrainMngCoreService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docker 컨테이너가 쓰는 response(산출물) 디렉토리의 "호스트 측" 베이스 경로. 예) /data/train/response
|
||||||
|
*
|
||||||
|
* <p>컨테이너가 --rm 으로 삭제된 경우에도 이 경로에 val.csv / *.pth 등이 남아있으면 정상 종료 여부를 "파일 기반"으로 판정합니다.
|
||||||
|
*/
|
||||||
|
@Value("${train.docker.response_dir}")
|
||||||
|
private String responseDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* docker inspect를 사용해서 컨테이너 상태를 조회합니다.
|
||||||
|
*
|
||||||
|
* <p>사용하는 템플릿: {{.State.Status}} {{.State.Running}} {{.State.ExitCode}}
|
||||||
|
*
|
||||||
|
* <p>예상 출력 예: - "running true 0" - "exited false 0" - "exited false 137"
|
||||||
|
*
|
||||||
|
* <p>주의: - 컨테이너가 없거나 inspect 실패 시 exitCode != 0 또는 output이 비어서 missing() 반환 - 무한 대기 방지를 위해 5초
|
||||||
|
* 타임아웃을 둠
|
||||||
|
*/
|
||||||
|
public DockerInspectState inspectContainer(String containerName)
|
||||||
|
throws IOException, InterruptedException {
|
||||||
|
|
||||||
|
ProcessBuilder pb =
|
||||||
|
new ProcessBuilder(
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
"{{.State.Status}} {{.State.Running}} {{.State.ExitCode}}",
|
||||||
|
containerName);
|
||||||
|
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
|
||||||
|
Process p = pb.start();
|
||||||
|
|
||||||
|
String output;
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
|
||||||
|
output = br.readLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean finished = p.waitFor(5, TimeUnit.SECONDS);
|
||||||
|
if (!finished) {
|
||||||
|
p.destroyForcibly();
|
||||||
|
throw new IOException("docker inspect timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
int code = p.exitValue();
|
||||||
|
|
||||||
|
if (code != 0 || output == null || output.isBlank()) {
|
||||||
|
return DockerInspectState.missing();
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] parts = output.trim().split("\\s+");
|
||||||
|
|
||||||
|
String status = parts.length > 0 ? parts[0] : "unknown";
|
||||||
|
boolean running = parts.length > 1 && Boolean.parseBoolean(parts[1]);
|
||||||
|
|
||||||
|
Integer exitCode = null;
|
||||||
|
if (parts.length > 2) {
|
||||||
|
try {
|
||||||
|
exitCode = Integer.parseInt(parts[2]);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DockerInspectState(true, running, exitCode, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컨테이너가 없을 때(responseDir 산출물만 남아있는 상태) 완료 여부를 파일 기반으로 판정합니다.
|
||||||
|
*
|
||||||
|
* <p>판정 규칙(보수적으로 설계): 1) total_epoch가 paramsJson에 있어야 함 (없으면 완료 판단 불가) 2) val.csv 존재 + 헤더 제외 라인 수
|
||||||
|
* >= total_epoch 이어야 함 3) *.pth 파일이 total_epoch 이상 존재하거나, best*.pth(또는 *best*.pth)가 존재해야 함
|
||||||
|
*
|
||||||
|
* <p>왜 이렇게? - 어떤 학습은 epoch마다 pth를 남기고 - 어떤 학습은 best만 남기기도 해서 "pthCount >= total_epoch"만 쓰면 정상 종료를
|
||||||
|
* 실패로 오판할 수 있음.
|
||||||
|
*/
|
||||||
|
public OutputResult probeOutputs(ModelTrainJobDto job) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path outDir = resolveOutputDir(job);
|
||||||
|
if (outDir == null || !Files.isDirectory(outDir)) {
|
||||||
|
return new OutputResult(false, "output-dir-missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer totalEpoch = extractTotalEpoch(job).orElse(null);
|
||||||
|
if (totalEpoch == null || totalEpoch <= 0) {
|
||||||
|
return new OutputResult(false, "total-epoch-missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer valInterval = extractValInterval(job).orElse(null);
|
||||||
|
if (valInterval == null || valInterval <= 0) {
|
||||||
|
return new OutputResult(false, "val-interval-missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
Path valCsv = outDir.resolve("val.csv");
|
||||||
|
if (!Files.exists(valCsv)) {
|
||||||
|
return new OutputResult(false, "val.csv-missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
long lines = countNonHeaderLines(valCsv);
|
||||||
|
int expectedLines = totalEpoch / valInterval;
|
||||||
|
|
||||||
|
if (lines >= expectedLines) {
|
||||||
|
return new OutputResult(true, "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OutputResult(false, "val.csv-lines-mismatch");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new OutputResult(false, "probe-error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트 완료후 zip 파일 있는지 확인
|
||||||
|
*
|
||||||
|
* @param uuid
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
public boolean existsZipFile(UUID uuid) {
|
||||||
|
Path path = Paths.get(responseDir, uuid.toString());
|
||||||
|
|
||||||
|
if (!Files.isDirectory(path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String pattern = "*" + uuid + "*.zip";
|
||||||
|
|
||||||
|
try (DirectoryStream<Path> stream = Files.newDirectoryStream(path, pattern)) {
|
||||||
|
return stream.iterator().hasNext();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* responseDir 아래에서 job 산출물 디렉토리를 찾습니다.
|
||||||
|
*
|
||||||
|
* <p>가장 중요한 커스터마이징 포인트: - 실제 운영 환경에서 산출물이 어떤 경로 규칙으로 저장되는지에 따라 여기만 수정하면 됩니다.
|
||||||
|
*
|
||||||
|
* <p>현재 기본 탐색 순서: 1) {responseDir}/{jobId} 2) {responseDir}/{modelId} 3)
|
||||||
|
* {responseDir}/{containerName} 4) 마지막 fallback: responseDir 자체
|
||||||
|
*
|
||||||
|
* <p>추천: - 여러분 규칙이 "{responseDir}/{modelId}/{jobId}" 같은 형태라면 base.resolve(modelId).resolve(jobId)
|
||||||
|
* 형태를 1순위로 두세요.
|
||||||
|
*/
|
||||||
|
private Path resolveOutputDir(ModelTrainJobDto job) {
|
||||||
|
ModelTrainMngDto.Basic model = modelTrainMngCoreService.findModelById(job.getModelId());
|
||||||
|
|
||||||
|
Path base = Paths.get(responseDir, model.getUuid().toString(), "metrics");
|
||||||
|
|
||||||
|
return Files.isDirectory(base) ? base : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* paramsJson에서 total_epoch 값을 추출합니다.
|
||||||
|
*
|
||||||
|
* <p>키 후보: - "total_epoch" (snake_case) - "totalEpoch" (camelCase)
|
||||||
|
*
|
||||||
|
* <p>예: paramsJson = {"jobType":"TRAIN","total_epoch":50,...}
|
||||||
|
*/
|
||||||
|
private Optional<Integer> extractTotalEpoch(ModelTrainJobDto job) {
|
||||||
|
Map<String, Object> params = job.getParamsJson();
|
||||||
|
if (params == null) return Optional.empty();
|
||||||
|
|
||||||
|
Object v = params.get("total_epoch");
|
||||||
|
if (v == null) v = params.get("totalEpoch");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return v == null ? Optional.empty() : Optional.of(Integer.parseInt(String.valueOf(v)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** paramsJson에서 valInterval 추출 */
|
||||||
|
private Optional<Integer> extractValInterval(ModelTrainJobDto job) {
|
||||||
|
Map<String, Object> params = job.getParamsJson();
|
||||||
|
if (params == null) return Optional.empty();
|
||||||
|
|
||||||
|
Object v = params.get("valInterval");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return v == null ? Optional.empty() : Optional.of(Integer.parseInt(String.valueOf(v)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV 파일에서 "헤더(첫 줄)"를 제외한 라인 수를 계산합니다.
|
||||||
|
*
|
||||||
|
* <p>가정: - val.csv 첫 줄은 헤더 - 이후 라인들이 epoch별 기록(또는 유사한 누적 기록)
|
||||||
|
*
|
||||||
|
* <p>주의: - 파일 인코딩은 UTF-8로 가정 - 빈 줄은 제외
|
||||||
|
*/
|
||||||
|
private long countNonHeaderLines(Path csv) throws IOException {
|
||||||
|
try (Stream<String> lines = Files.lines(csv, StandardCharsets.UTF_8)) {
|
||||||
|
return lines.skip(1).filter(s -> s != null && !s.isBlank()).count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,3 +41,8 @@ train:
|
|||||||
container_prefix: kamco-cd-train
|
container_prefix: kamco-cd-train
|
||||||
shm_size: 16g
|
shm_size: 16g
|
||||||
ipc_host: true
|
ipc_host: true
|
||||||
|
hyper:
|
||||||
|
parameter:
|
||||||
|
gpus: 4
|
||||||
|
gpu-ids: 0,1,2,3
|
||||||
|
batch-size: 30
|
||||||
|
|||||||
@@ -78,3 +78,8 @@ management:
|
|||||||
exposure:
|
exposure:
|
||||||
include:
|
include:
|
||||||
- "health"
|
- "health"
|
||||||
|
hyper:
|
||||||
|
parameter:
|
||||||
|
gpus: 1
|
||||||
|
gpu-ids: 0
|
||||||
|
batch-size: 2
|
||||||
|
|||||||
964
상태전의 STATUS 가이드.md
Normal file
964
상태전의 STATUS 가이드.md
Normal file
@@ -0,0 +1,964 @@
|
|||||||
|
# POST /api/train/status/{uuid} - 상태 전이 다이어그램 완전 가이드
|
||||||
|
|
||||||
|
> **프론트엔드 표시 방법 및 상태 제어 API 포함**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 1. DB 테이블 상태 필드
|
||||||
|
|
||||||
|
### 1.1 **Job 테이블** (`tb_model_train_job`)
|
||||||
|
| 필드명 | 설명 | 가능한 값 | UI 표시명 |
|
||||||
|
|--------|------|-----------|-----------|
|
||||||
|
| `status_cd` | Job 실행 상태 | `QUEUED`, `RUNNING`, `SUCCESS`, `FAILED`, `STOPPED`, `CANCELED` | 대기중, 실행중, 성공, 실패, 중단됨, 취소 |
|
||||||
|
| `exit_code` | 종료 코드 | 정수 (0: 정상, -1: 중단, 기타: 에러) | - |
|
||||||
|
| `error_message` | 에러 메시지 | 문자열 | 에러 상세 내용 |
|
||||||
|
| `finished_dttm` | 완료 시간 | timestamp | YYYY-MM-DD HH:mm:ss |
|
||||||
|
|
||||||
|
### 1.2 **Model Master 테이블** (`tb_model_master`)
|
||||||
|
| 필드명 | 설명 | 가능한 값 | UI 표시명 |
|
||||||
|
|--------|------|-----------|-----------|
|
||||||
|
| `status_cd` | 전체 모델 상태 | `READY`, `IN_PROGRESS`, `COMPLETED`, `STOPPED`, `ERROR` | 대기, 진행중, 완료, 중단됨, 오류 |
|
||||||
|
| `step1_state` | Step1(학습) 상태 | `READY`, `IN_PROGRESS`, `COMPLETED`, `STOPPED`, `ERROR` | 대기, 진행중, 완료, 중단됨, 오류 |
|
||||||
|
| `step2_state` | Step2(테스트) 상태 | `READY`, `IN_PROGRESS`, `COMPLETED`, `STOPPED`, `ERROR` | 대기, 진행중, 완료, 중단됨, 오류 |
|
||||||
|
| `step1_strt_dttm` | Step1 시작 시간 | timestamp | YYYY-MM-DD HH:mm:ss |
|
||||||
|
| `step1_end_dttm` | Step1 종료 시간 | timestamp | YYYY-MM-DD HH:mm:ss |
|
||||||
|
| `step2_strt_dttm` | Step2 시작 시간 | timestamp | YYYY-MM-DD HH:mm:ss |
|
||||||
|
| `step2_end_dttm` | Step2 종료 시간 | timestamp | YYYY-MM-DD HH:mm:ss |
|
||||||
|
| `step1_metric_save_yn` | Step1 메트릭 저장 여부 | `true`, `false` | - |
|
||||||
|
| `step2_metric_save_yn` | Step2 메트릭 저장 여부 | `true`, `false` | - |
|
||||||
|
| `current_attempt_id` | 현재 실행 중인 Job ID | Long | - |
|
||||||
|
| `best_epoch` | 최적 에폭 | Integer | Best Epoch: {값} |
|
||||||
|
| `last_error` | 마지막 에러 메시지 | 문자열 | 에러 상세 내용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 2. 프론트엔드 API 응답 및 표시
|
||||||
|
|
||||||
|
### 2.1 모델 목록 조회 API
|
||||||
|
|
||||||
|
**엔드포인트**: `GET /api/models/list`
|
||||||
|
|
||||||
|
**요청 파라미터**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "IN_PROGRESS", // "", "IN_PROGRESS", "COMPLETED"
|
||||||
|
"modelNo": "G1", // "G1", "G2", "G3", "G4"
|
||||||
|
"page": 0,
|
||||||
|
"size": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답 예시**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"uuid": "e22181eb-2ac4-4100-9941-d06efce25c49",
|
||||||
|
"modelVer": "v1.0.0",
|
||||||
|
"statusCd": "IN_PROGRESS",
|
||||||
|
"statusName": "진행중",
|
||||||
|
"step1Status": "COMPLETED",
|
||||||
|
"step1StatusName": "완료",
|
||||||
|
"step2Status": "IN_PROGRESS",
|
||||||
|
"step2StatusName": "진행중",
|
||||||
|
"step1StrtDttm": "2026-04-05 10:30:00",
|
||||||
|
"step1EndDttm": "2026-04-05 12:45:00",
|
||||||
|
"step1Duration": "2시간 15분 0초",
|
||||||
|
"step2StrtDttm": "2026-04-05 12:50:00",
|
||||||
|
"step2EndDttm": null,
|
||||||
|
"step2Duration": null,
|
||||||
|
"modelNo": "G1",
|
||||||
|
"trainType": "GENERAL",
|
||||||
|
"trainTypeName": "일반",
|
||||||
|
"currentAttemptId": 456,
|
||||||
|
"memo": "테스트 학습"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalElements": 100,
|
||||||
|
"totalPages": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 프론트엔드 표시 예시
|
||||||
|
|
||||||
|
#### 📋 **모델 목록 테이블**
|
||||||
|
|
||||||
|
| 모델 번호 | 버전 | 전체 상태 | 학습(Step1) | 테스트(Step2) | 학습 시간 | 테스트 시간 | 작업 |
|
||||||
|
|----------|------|----------|------------|------------|----------|----------|------|
|
||||||
|
| G1 | v1.0.0 | 🟢 진행중 | ✅ 완료 (2시간 15분) | 🔄 진행중 | 2026-04-05 10:30 ~ 12:45 | 2026-04-05 12:50 ~ | [취소] |
|
||||||
|
| G2 | v1.0.1 | ✅ 완료 | ✅ 완료 (1시간 30분) | ✅ 완료 (45분) | 2026-04-04 14:00 ~ 15:30 | 2026-04-04 15:35 ~ 16:20 | [결과보기] |
|
||||||
|
| G3 | v1.0.2 | ⚠️ 중단됨 | ⚠️ 중단됨 | - | 2026-04-03 09:00 ~ | - | [재시작] [이어하기] |
|
||||||
|
| G4 | v1.0.3 | ❌ 오류 | ❌ 오류 | - | 2026-04-02 11:00 ~ | - | [재시작] |
|
||||||
|
|
||||||
|
#### 🎯 **상태별 UI 컬러 가이드**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 상태별 배지 색상 매핑
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
'READY': { bg: '#e3f2fd', text: '#1976d2', icon: '⏸️' }, // 파란색 - 대기
|
||||||
|
'IN_PROGRESS': { bg: '#e8f5e9', text: '#388e3c', icon: '🔄' }, // 녹색 - 진행중
|
||||||
|
'COMPLETED': { bg: '#f1f8e9', text: '#689f38', icon: '✅' }, // 연두색 - 완료
|
||||||
|
'STOPPED': { bg: '#fff3e0', text: '#f57c00', icon: '⚠️' }, // 주황색 - 중단됨
|
||||||
|
'ERROR': { bg: '#ffebee', text: '#d32f2f', icon: '❌' } // 빨간색 - 오류
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상태명 한글 변환
|
||||||
|
const STATUS_NAMES = {
|
||||||
|
'READY': '대기',
|
||||||
|
'IN_PROGRESS': '진행중',
|
||||||
|
'COMPLETED': '완료',
|
||||||
|
'STOPPED': '중단됨',
|
||||||
|
'ERROR': '오류'
|
||||||
|
};
|
||||||
|
|
||||||
|
// React 컴포넌트 예시
|
||||||
|
function StatusBadge({ statusCd }) {
|
||||||
|
const { bg, text, icon } = STATUS_COLORS[statusCd] || {};
|
||||||
|
const name = STATUS_NAMES[statusCd] || statusCd;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={{ backgroundColor: bg, color: text, padding: '4px 12px', borderRadius: '12px' }}>
|
||||||
|
{icon} {name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 📊 **상세 진행 상황 표시**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Step별 진행률 계산
|
||||||
|
function ModelProgressBar({ model }) {
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
name: '학습 (Step1)',
|
||||||
|
status: model.step1Status,
|
||||||
|
statusName: model.step1StatusName,
|
||||||
|
startTime: model.step1StrtDttm,
|
||||||
|
endTime: model.step1EndDttm,
|
||||||
|
duration: model.step1Duration
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '테스트 (Step2)',
|
||||||
|
status: model.step2Status,
|
||||||
|
statusName: model.step2StatusName,
|
||||||
|
startTime: model.step2StrtDttm,
|
||||||
|
endTime: model.step2EndDttm,
|
||||||
|
duration: model.step2Duration
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="progress-container">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={index} className="step-item">
|
||||||
|
<div className="step-header">
|
||||||
|
<span className="step-name">{step.name}</span>
|
||||||
|
<StatusBadge statusCd={step.status} />
|
||||||
|
</div>
|
||||||
|
<div className="step-time">
|
||||||
|
{step.startTime && (
|
||||||
|
<>
|
||||||
|
<span>시작: {step.startTime}</span>
|
||||||
|
{step.endTime && <span> ~ 종료: {step.endTime}</span>}
|
||||||
|
{step.duration && <span> (소요시간: {step.duration})</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step.status === 'IN_PROGRESS' && (
|
||||||
|
<div className="loading-bar">
|
||||||
|
<div className="loading-animation"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 3. 상태 전이 플로우차트 (상태값 명시)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ POST /api/train/status/{uuid} API 호출 │
|
||||||
|
└────────────────────────────┬────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌────────────────────┐
|
||||||
|
│ UUID → ModelID │
|
||||||
|
│ 조회 및 Job 조회 │
|
||||||
|
└────────┬───────────┘
|
||||||
|
↓
|
||||||
|
┌────────────────────┐
|
||||||
|
│ Docker Inspect 실행 │
|
||||||
|
│ (containerName) │
|
||||||
|
└────────┬───────────┘
|
||||||
|
↓
|
||||||
|
┌──────────────┴──────────────┐
|
||||||
|
│ │
|
||||||
|
[exists=false] [exists=true]
|
||||||
|
컨테이너 없음 컨테이너 존재
|
||||||
|
↓ ↓
|
||||||
|
┌─────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ TrainUtilService │ │ jobType 확인 │
|
||||||
|
│ .probeOutputs() │ │ (paramsJson) │
|
||||||
|
│ │ └──────────┬───────────┘
|
||||||
|
│ 1. total_epoch 추출 │ │
|
||||||
|
│ 2. valInterval 추출 │ ┌──────────┴──────────┐
|
||||||
|
│ 3. val.csv 존재확인 │ │ │
|
||||||
|
│ 4. 라인수 검증 │ [TRAIN] [EVAL]
|
||||||
|
└─────────┬───────────┘ │ │
|
||||||
|
│ ↓ ↓
|
||||||
|
┌─────────┴─────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ │ │ Step1 진행중 │ │ Step2 진행중 │
|
||||||
|
[completed=true] [completed=false] └──────────────┘ └──────────────┘
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌────▼─────────────────────▼────────┐
|
||||||
|
│ │ │ ✅ tb_model_train_job │
|
||||||
|
│ │ │ status_cd = "RUNNING" │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ ✅ tb_model_master │
|
||||||
|
│ │ │ status_cd = "IN_PROGRESS" │
|
||||||
|
│ │ │ step1_state = "IN_PROGRESS" │ [TRAIN]
|
||||||
|
│ │ │ step1_strt_dttm = now() │
|
||||||
|
│ │ │ OR │
|
||||||
|
│ │ │ step2_state = "IN_PROGRESS" │ [EVAL]
|
||||||
|
│ │ │ step2_strt_dttm = now() │
|
||||||
|
│ │ │ current_attempt_id = jobId │
|
||||||
|
│ │ └──────────────────────────────────┘
|
||||||
|
│ │ 【프론트 표시】
|
||||||
|
│ │ 🔄 진행중 - 학습 중... / 테스트 중...
|
||||||
|
│ │
|
||||||
|
│ ↓
|
||||||
|
│ ┌─────────────────────┐
|
||||||
|
│ │ ⚠️ 산출물 부족 │
|
||||||
|
│ │ (val.csv 부족 등) │
|
||||||
|
│ └─────────┬───────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌─────────▼────────────────────────────────┐
|
||||||
|
│ │ ⚠️ tb_model_train_job │
|
||||||
|
│ │ status_cd = "STOPPED" │
|
||||||
|
│ │ exit_code = -1 │
|
||||||
|
│ │ error_message = "SERVER_RESTART_..." │
|
||||||
|
│ │ │
|
||||||
|
│ │ ⚠️ tb_model_master │
|
||||||
|
│ │ status_cd = "STOPPED" │
|
||||||
|
│ │ step1_state = "STOPPED" [TRAIN] │
|
||||||
|
│ │ OR │
|
||||||
|
│ │ step2_state = "STOPPED" [EVAL] │
|
||||||
|
│ │ last_error = "컨테이너 없음, 산출물 부족" │
|
||||||
|
│ └──────────────────────────────────────────┘
|
||||||
|
│ 【프론트 표시】
|
||||||
|
│ ⚠️ 중단됨 - 산출물 부족으로 중단됨
|
||||||
|
│ [재시작] [이어하기] 버튼 활성화
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌──────────────────┐
|
||||||
|
│ existsZipFile() │
|
||||||
|
│ ZIP 파일 존재? │
|
||||||
|
└────┬────────┬────┘
|
||||||
|
│ │
|
||||||
|
[YES] [NO]
|
||||||
|
│ │
|
||||||
|
│ ↓
|
||||||
|
│ ┌──────────────────────────────────────────────┐
|
||||||
|
│ │ 📁 학습 완료 (Step1만) │
|
||||||
|
│ └─────────┬────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌─────────▼──────────────────────────────────┐
|
||||||
|
│ │ ✅ tb_model_train_job │
|
||||||
|
│ │ status_cd = "SUCCESS" │
|
||||||
|
│ │ exit_code = 0 │
|
||||||
|
│ │ finished_dttm = now() │
|
||||||
|
│ │ │
|
||||||
|
│ │ ✅ tb_model_master (Step1 미완료 시) │
|
||||||
|
│ │ status_cd = "COMPLETED" │
|
||||||
|
│ │ step1_state = "COMPLETED" │
|
||||||
|
│ │ step1_end_dttm = now() │
|
||||||
|
│ │ step1_metric_save_yn = true │
|
||||||
|
│ │ │
|
||||||
|
│ │ 📊 Metrics 저장 │
|
||||||
|
│ │ - train.csv → tb_model_metrics_train │
|
||||||
|
│ │ - val.csv → tb_model_metrics_validation │
|
||||||
|
│ └────────────────────────────────────────────┘
|
||||||
|
│ 【프론트 표시】
|
||||||
|
│ ✅ 완료 - 학습 완료 (테스트 대기)
|
||||||
|
│ [테스트 실행] 버튼 활성화
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ 📦 학습+테스트 완료 (Step1 + Step2) │
|
||||||
|
└─────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────▼──────────────────────────────────┐
|
||||||
|
│ ✅ tb_model_train_job │
|
||||||
|
│ status_cd = "SUCCESS" │
|
||||||
|
│ exit_code = 0 │
|
||||||
|
│ finished_dttm = now() │
|
||||||
|
│ │
|
||||||
|
│ ✅ tb_model_master (Step1 미완료 시) │
|
||||||
|
│ status_cd = "COMPLETED" │
|
||||||
|
│ step1_state = "COMPLETED" │
|
||||||
|
│ step1_end_dttm = now() │
|
||||||
|
│ step1_metric_save_yn = true │
|
||||||
|
│ │
|
||||||
|
│ ✅ tb_model_master (Step2 미완료 시) │
|
||||||
|
│ step2_state = "COMPLETED" │
|
||||||
|
│ step2_end_dttm = now() │
|
||||||
|
│ step2_metric_save_yn = true │
|
||||||
|
│ best_epoch = 최적값 │
|
||||||
|
│ │
|
||||||
|
│ 📊 Metrics 저장 │
|
||||||
|
│ - train.csv → tb_model_metrics_train │
|
||||||
|
│ - val.csv → tb_model_metrics_validation │
|
||||||
|
│ - test.csv → 테스트 메트릭 테이블 │
|
||||||
|
│ - *.zip → 테스트 결과 파일 생성 │
|
||||||
|
└────────────────────────────────────────────┘
|
||||||
|
【프론트 표시】
|
||||||
|
✅ 완료 - 모든 학습 및 테스트 완료
|
||||||
|
[결과 보기] [다운로드] 버튼 활성화
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 4. 상태 제어 API 목록
|
||||||
|
|
||||||
|
### 4.1 학습 실행 제어 API
|
||||||
|
|
||||||
|
| API | HTTP | 엔드포인트 | 설명 | 상태 전환 | 프론트 버튼 표시 조건 |
|
||||||
|
|-----|------|-----------|------|----------|---------------------|
|
||||||
|
| **학습 실행** | POST | `/api/train/run/{uuid}` | 최초 학습 시작 | `READY` → `IN_PROGRESS` | `statusCd == 'READY'` |
|
||||||
|
| **학습 재실행** | POST | `/api/train/restart/{uuid}` | 중단/오류 후 처음부터 재실행 | `STOPPED/ERROR` → `IN_PROGRESS` | `statusCd == 'STOPPED' \|\| statusCd == 'ERROR'` |
|
||||||
|
| **학습 이어하기** | POST | `/api/train/resume/{uuid}` | 중단된 지점부터 계속 실행 | `STOPPED` → `IN_PROGRESS` | `statusCd == 'STOPPED'` |
|
||||||
|
| **학습 취소** | POST | `/api/train/cancel/{uuid}` | 실행 중인 학습 중단 | `IN_PROGRESS` → `STOPPED` | `statusCd == 'IN_PROGRESS' && step1Status == 'IN_PROGRESS'` |
|
||||||
|
| **학습 상태 확인** | POST | `/api/train/status/{uuid}` | 현재 상태 동기화 | 현재 상태 유지/갱신 | 주기적 호출 (폴링) |
|
||||||
|
|
||||||
|
### 4.2 테스트 실행 제어 API
|
||||||
|
|
||||||
|
| API | HTTP | 엔드포인트 | 설명 | 상태 전환 | 프론트 버튼 표시 조건 |
|
||||||
|
|-----|------|-----------|------|----------|---------------------|
|
||||||
|
| **테스트 실행** | POST | `/api/train/test/run/{epoch}/{uuid}` | 특정 에폭으로 테스트 실행 | `step2_state: READY` → `IN_PROGRESS` | `step1Status == 'COMPLETED' && step2Status != 'IN_PROGRESS'` |
|
||||||
|
| **테스트 취소** | POST | `/api/train/test/cancel/{uuid}` | 실행 중인 테스트 중단 | `step2_state: IN_PROGRESS` → `STOPPED` | `step2Status == 'IN_PROGRESS'` |
|
||||||
|
|
||||||
|
### 4.3 기타 API
|
||||||
|
|
||||||
|
| API | HTTP | 엔드포인트 | 설명 |
|
||||||
|
|-----|------|-----------|------|
|
||||||
|
| **데이터셋 임시 파일 생성** | POST | `/api/train/create-tmp/{uuid}` | 학습용 임시 데이터셋 생성 |
|
||||||
|
| **데이터셋 카운트 조회** | GET | `/api/train/counts/{uuid}` | 데이터셋 통계 정보 조회 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 5. 상태별 프론트엔드 액션 매트릭스
|
||||||
|
|
||||||
|
### 5.1 전체 상태 (`statusCd`) 기준
|
||||||
|
|
||||||
|
| 상태 코드 | 상태명 | 활성화 버튼 | 비활성화 버튼 | 표시 메시지 | UI 색상 |
|
||||||
|
|-----------|-------|------------|--------------|------------|---------|
|
||||||
|
| `READY` | 대기 | [학습 실행] | [취소] [재실행] [이어하기] | "학습 준비 완료" | 파란색 |
|
||||||
|
| `IN_PROGRESS` | 진행중 | [취소] | [학습 실행] [재실행] [이어하기] | "학습 진행 중..." | 녹색 |
|
||||||
|
| `COMPLETED` | 완료 | [결과 보기] [다운로드] | [학습 실행] [취소] | "학습 완료" | 연두색 |
|
||||||
|
| `STOPPED` | 중단됨 | [재실행] [이어하기] | [취소] | "학습 중단됨 - 재시작 가능" | 주황색 |
|
||||||
|
| `ERROR` | 오류 | [재실행] | [취소] [이어하기] | "오류 발생 - 재시작 필요" | 빨간색 |
|
||||||
|
|
||||||
|
### 5.2 Step1 상태 (`step1Status`) 기준
|
||||||
|
|
||||||
|
| 상태 코드 | 버튼/액션 | 조건 | 설명 |
|
||||||
|
|-----------|----------|------|------|
|
||||||
|
| `READY` | [학습 실행] | `statusCd == 'READY'` | 최초 학습 시작 |
|
||||||
|
| `IN_PROGRESS` | [학습 취소] | `statusCd == 'IN_PROGRESS'` | 진행 중인 학습 중단 |
|
||||||
|
| `COMPLETED` | [테스트 실행] | `step2Status == 'READY'` | 학습 완료 후 테스트 가능 |
|
||||||
|
| `STOPPED` | [재실행] [이어하기] | `statusCd == 'STOPPED'` | 중단된 학습 복구 |
|
||||||
|
| `ERROR` | [재실행] | `statusCd == 'ERROR'` | 오류 발생 후 재시도 |
|
||||||
|
|
||||||
|
### 5.3 Step2 상태 (`step2Status`) 기준
|
||||||
|
|
||||||
|
| 상태 코드 | 버튼/액션 | 조건 | 설명 |
|
||||||
|
|-----------|----------|------|------|
|
||||||
|
| `READY` | [테스트 실행] | `step1Status == 'COMPLETED'` | 학습 완료 후 테스트 가능 |
|
||||||
|
| `IN_PROGRESS` | [테스트 취소] | `statusCd == 'IN_PROGRESS'` | 진행 중인 테스트 중단 |
|
||||||
|
| `COMPLETED` | [결과 보기] [다운로드] | 항상 | 테스트 완료 후 결과 확인 |
|
||||||
|
| `STOPPED` | [테스트 재실행] | `step1Status == 'COMPLETED'` | 중단된 테스트 재시작 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 6. 프론트엔드 구현 예시
|
||||||
|
|
||||||
|
### 6.1 상태별 버튼 렌더링 (React)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function ModelActionButtons({ model }) {
|
||||||
|
const { uuid, statusCd, step1Status, step2Status } = model;
|
||||||
|
|
||||||
|
// 학습 실행 버튼
|
||||||
|
const canRun = statusCd === 'READY';
|
||||||
|
|
||||||
|
// 학습 취소 버튼
|
||||||
|
const canCancel = statusCd === 'IN_PROGRESS' && step1Status === 'IN_PROGRESS';
|
||||||
|
|
||||||
|
// 재실행 버튼
|
||||||
|
const canRestart = ['STOPPED', 'ERROR'].includes(statusCd);
|
||||||
|
|
||||||
|
// 이어하기 버튼
|
||||||
|
const canResume = statusCd === 'STOPPED';
|
||||||
|
|
||||||
|
// 테스트 실행 버튼
|
||||||
|
const canTest = step1Status === 'COMPLETED' &&
|
||||||
|
step2Status !== 'IN_PROGRESS' &&
|
||||||
|
step2Status !== 'COMPLETED';
|
||||||
|
|
||||||
|
// 테스트 취소 버튼
|
||||||
|
const canCancelTest = step2Status === 'IN_PROGRESS';
|
||||||
|
|
||||||
|
// 결과 보기 버튼
|
||||||
|
const canViewResult = statusCd === 'COMPLETED' &&
|
||||||
|
step1Status === 'COMPLETED' &&
|
||||||
|
step2Status === 'COMPLETED';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="action-buttons">
|
||||||
|
{canRun && (
|
||||||
|
<button onClick={() => runTrain(uuid)} className="btn-primary">
|
||||||
|
🚀 학습 실행
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCancel && (
|
||||||
|
<button onClick={() => cancelTrain(uuid)} className="btn-danger">
|
||||||
|
⏹️ 학습 취소
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canRestart && (
|
||||||
|
<button onClick={() => restartTrain(uuid)} className="btn-warning">
|
||||||
|
🔄 재실행
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canResume && (
|
||||||
|
<button onClick={() => resumeTrain(uuid)} className="btn-info">
|
||||||
|
▶️ 이어하기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canTest && (
|
||||||
|
<button onClick={() => runTest(uuid, model.bestEpoch)} className="btn-success">
|
||||||
|
🧪 테스트 실행
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCancelTest && (
|
||||||
|
<button onClick={() => cancelTest(uuid)} className="btn-danger">
|
||||||
|
⏹️ 테스트 취소
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canViewResult && (
|
||||||
|
<button onClick={() => viewResult(uuid)} className="btn-primary">
|
||||||
|
📊 결과 보기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 API 호출 함수
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE = '/api/train';
|
||||||
|
|
||||||
|
// 학습 실행
|
||||||
|
async function runTrain(uuid) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE}/run/${uuid}`);
|
||||||
|
if (response.data.success) {
|
||||||
|
alert('학습이 시작되었습니다.');
|
||||||
|
refreshModelList(); // 목록 갱신
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('학습 실행 실패: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 학습 취소
|
||||||
|
async function cancelTrain(uuid) {
|
||||||
|
if (!confirm('학습을 취소하시겠습니까?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE}/cancel/${uuid}`);
|
||||||
|
if (response.data.success) {
|
||||||
|
alert('학습이 취소되었습니다.');
|
||||||
|
refreshModelList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('학습 취소 실패: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 학습 재실행
|
||||||
|
async function restartTrain(uuid) {
|
||||||
|
if (!confirm('처음부터 다시 학습을 시작하시겠습니까?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE}/restart/${uuid}`);
|
||||||
|
if (response.data.success) {
|
||||||
|
alert('학습이 재시작되었습니다.');
|
||||||
|
refreshModelList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('학습 재시작 실패: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 학습 이어하기
|
||||||
|
async function resumeTrain(uuid) {
|
||||||
|
if (!confirm('중단된 지점부터 학습을 이어가시겠습니까?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE}/resume/${uuid}`);
|
||||||
|
if (response.data.success) {
|
||||||
|
alert('학습이 재개되었습니다.');
|
||||||
|
refreshModelList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('학습 이어하기 실패: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테스트 실행
|
||||||
|
async function runTest(uuid, epoch) {
|
||||||
|
if (!confirm(`Epoch ${epoch}으로 테스트를 실행하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE}/test/run/${epoch}/${uuid}`);
|
||||||
|
if (response.data.success) {
|
||||||
|
alert('테스트가 시작되었습니다.');
|
||||||
|
refreshModelList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('테스트 실행 실패: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테스트 취소
|
||||||
|
async function cancelTest(uuid) {
|
||||||
|
if (!confirm('테스트를 취소하시겠습니까?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE}/test/cancel/${uuid}`);
|
||||||
|
if (response.data.success) {
|
||||||
|
alert('테스트가 취소되었습니다.');
|
||||||
|
refreshModelList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('테스트 취소 실패: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 학습 상태 확인 (폴링용)
|
||||||
|
async function checkTrainStatus(uuid) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE}/status/${uuid}`);
|
||||||
|
return response.data.success;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('상태 확인 실패:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 주기적 상태 업데이트 (폴링)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function ModelMonitor({ uuid }) {
|
||||||
|
const [isPolling, setIsPolling] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let intervalId;
|
||||||
|
|
||||||
|
// 진행 중인 모델만 폴링
|
||||||
|
if (isPolling) {
|
||||||
|
intervalId = setInterval(async () => {
|
||||||
|
const success = await checkTrainStatus(uuid);
|
||||||
|
if (success) {
|
||||||
|
refreshModelList(); // 상태 업데이트 후 목록 갱신
|
||||||
|
}
|
||||||
|
}, 10000); // 10초마다 상태 확인
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [uuid, isPolling]);
|
||||||
|
|
||||||
|
// 모델 상태에 따라 폴링 활성화/비활성화
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldPoll = model.statusCd === 'IN_PROGRESS';
|
||||||
|
setIsPolling(shouldPoll);
|
||||||
|
}, [model.statusCd]);
|
||||||
|
|
||||||
|
return null; // 백그라운드 작업
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 7. 상태 수정 시나리오별 가이드
|
||||||
|
|
||||||
|
### 시나리오 1: 학습 중단 후 재시작
|
||||||
|
|
||||||
|
**현재 상태**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCd": "STOPPED",
|
||||||
|
"step1Status": "STOPPED",
|
||||||
|
"lastError": "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트 표시**:
|
||||||
|
- ⚠️ 중단됨 - 산출물 부족으로 중단됨
|
||||||
|
- 버튼: [재실행] [이어하기]
|
||||||
|
|
||||||
|
**액션**:
|
||||||
|
|
||||||
|
**옵션 A) 재실행** (`POST /api/train/restart/{uuid}`):
|
||||||
|
```javascript
|
||||||
|
// 처음부터 다시 시작
|
||||||
|
await axios.post(`/api/train/restart/${uuid}`);
|
||||||
|
|
||||||
|
// 결과 상태
|
||||||
|
{
|
||||||
|
"statusCd": "IN_PROGRESS",
|
||||||
|
"step1Status": "IN_PROGRESS",
|
||||||
|
"currentAttemptId": 새로운JobId
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**옵션 B) 이어하기** (`POST /api/train/resume/{uuid}`):
|
||||||
|
```javascript
|
||||||
|
// 중단된 지점부터 계속
|
||||||
|
await axios.post(`/api/train/resume/${uuid}`);
|
||||||
|
|
||||||
|
// 결과 상태
|
||||||
|
{
|
||||||
|
"statusCd": "IN_PROGRESS",
|
||||||
|
"step1Status": "IN_PROGRESS",
|
||||||
|
"currentAttemptId": 새로운JobId,
|
||||||
|
// paramsJson에 resumeFrom 설정됨
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 시나리오 2: 학습 완료 후 테스트 실행
|
||||||
|
|
||||||
|
**현재 상태**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCd": "COMPLETED",
|
||||||
|
"step1Status": "COMPLETED",
|
||||||
|
"step2Status": "READY",
|
||||||
|
"bestEpoch": 45
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트 표시**:
|
||||||
|
- ✅ 완료 - 학습 완료 (테스트 대기)
|
||||||
|
- 버튼: [테스트 실행]
|
||||||
|
|
||||||
|
**액션**:
|
||||||
|
```javascript
|
||||||
|
// 테스트 실행 (bestEpoch 사용)
|
||||||
|
await axios.post(`/api/train/test/run/45/${uuid}`);
|
||||||
|
|
||||||
|
// 결과 상태
|
||||||
|
{
|
||||||
|
"statusCd": "IN_PROGRESS",
|
||||||
|
"step1Status": "COMPLETED",
|
||||||
|
"step2Status": "IN_PROGRESS",
|
||||||
|
"step2StrtDttm": "2026-04-06 14:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트 표시 변경**:
|
||||||
|
- 🔄 진행중 - 테스트 진행 중...
|
||||||
|
- 버튼: [테스트 취소]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 시나리오 3: 실행 중인 학습 취소
|
||||||
|
|
||||||
|
**현재 상태**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCd": "IN_PROGRESS",
|
||||||
|
"step1Status": "IN_PROGRESS",
|
||||||
|
"currentAttemptId": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트 표시**:
|
||||||
|
- 🔄 진행중 - 학습 진행 중...
|
||||||
|
- 버튼: [학습 취소]
|
||||||
|
|
||||||
|
**액션**:
|
||||||
|
```javascript
|
||||||
|
// 학습 취소
|
||||||
|
await axios.post(`/api/train/cancel/${uuid}`);
|
||||||
|
|
||||||
|
// 결과 상태
|
||||||
|
{
|
||||||
|
"statusCd": "STOPPED",
|
||||||
|
"step1Status": "STOPPED",
|
||||||
|
"currentAttemptId": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트 표시 변경**:
|
||||||
|
- ⚠️ 중단됨 - 사용자가 학습을 취소함
|
||||||
|
- 버튼: [재실행] [이어하기]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 시나리오 4: 서버 재시작 후 자동 상태 복구
|
||||||
|
|
||||||
|
**서버 재시작 전**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCd": "IN_PROGRESS",
|
||||||
|
"step1Status": "IN_PROGRESS"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**서버 재시작 후 API 호출**:
|
||||||
|
```javascript
|
||||||
|
// 주기적 폴링 또는 수동 호출
|
||||||
|
await axios.post(`/api/train/status/${uuid}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
**케이스 A) 학습 완료된 경우**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCd": "COMPLETED",
|
||||||
|
"step1Status": "COMPLETED",
|
||||||
|
"step1EndDttm": "2026-04-06 15:00:00",
|
||||||
|
"step1MetricSaveYn": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- ✅ 완료 - 학습 완료
|
||||||
|
- 버튼: [테스트 실행] [결과 보기]
|
||||||
|
|
||||||
|
**케이스 B) 산출물 부족한 경우**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCd": "STOPPED",
|
||||||
|
"step1Status": "STOPPED",
|
||||||
|
"lastError": "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE: val.csv-lines-mismatch"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- ⚠️ 중단됨 - 산출물 부족으로 중단됨
|
||||||
|
- 버튼: [재실행] [이어하기]
|
||||||
|
|
||||||
|
**케이스 C) 여전히 실행 중인 경우**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCd": "IN_PROGRESS",
|
||||||
|
"step1Status": "IN_PROGRESS"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 🔄 진행중 - 학습 진행 중...
|
||||||
|
- 버튼: [학습 취소]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 8. 에러 메시지 처리
|
||||||
|
|
||||||
|
### 8.1 `last_error` 필드 해석
|
||||||
|
|
||||||
|
| 에러 메시지 | 의미 | 프론트 표시 | 권장 액션 |
|
||||||
|
|------------|------|-----------|----------|
|
||||||
|
| `SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE` | 서버 재시작 후 컨테이너 없음 + 산출물 부족 | "서버 재시작으로 인한 중단 - 산출물 확인 필요" | [재실행] |
|
||||||
|
| `val.csv-lines-mismatch` | val.csv 라인 수 부족 | "검증 데이터 부족 - 학습이 완료되지 않음" | [재실행] [이어하기] |
|
||||||
|
| `val.csv-missing` | val.csv 파일 없음 | "검증 파일 없음 - 학습 실패" | [재실행] |
|
||||||
|
| `total-epoch-missing` | paramsJson에 total_epoch 없음 | "설정 오류 - 에폭 정보 없음" | [재실행] |
|
||||||
|
| `output-dir-missing` | 산출물 디렉토리 없음 | "산출물 디렉토리 없음 - 학습 실패" | [재실행] |
|
||||||
|
|
||||||
|
### 8.2 에러 표시 컴포넌트
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function ErrorMessage({ lastError }) {
|
||||||
|
if (!lastError) return null;
|
||||||
|
|
||||||
|
const errorMessages = {
|
||||||
|
'SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE': {
|
||||||
|
icon: '⚠️',
|
||||||
|
title: '서버 재시작으로 인한 중단',
|
||||||
|
message: '서버가 재시작되면서 학습이 중단되었습니다. 산출물을 확인하여 학습 상태를 복구합니다.',
|
||||||
|
severity: 'warning'
|
||||||
|
},
|
||||||
|
'val.csv-lines-mismatch': {
|
||||||
|
icon: '📊',
|
||||||
|
title: '검증 데이터 부족',
|
||||||
|
message: '검증 데이터 라인 수가 예상보다 적습니다. 학습이 완료되지 않았을 가능성이 있습니다.',
|
||||||
|
severity: 'warning'
|
||||||
|
},
|
||||||
|
'val.csv-missing': {
|
||||||
|
icon: '❌',
|
||||||
|
title: '검증 파일 없음',
|
||||||
|
message: 'val.csv 파일을 찾을 수 없습니다. 학습이 실패했을 가능성이 높습니다.',
|
||||||
|
severity: 'error'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 에러 메시지 파싱
|
||||||
|
const errorKey = Object.keys(errorMessages).find(key => lastError.includes(key));
|
||||||
|
const errorInfo = errorMessages[errorKey] || {
|
||||||
|
icon: '⚠️',
|
||||||
|
title: '오류 발생',
|
||||||
|
message: lastError,
|
||||||
|
severity: 'error'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`error-message severity-${errorInfo.severity}`}>
|
||||||
|
<div className="error-header">
|
||||||
|
<span className="error-icon">{errorInfo.icon}</span>
|
||||||
|
<span className="error-title">{errorInfo.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="error-description">{errorInfo.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 9. 상태 일관성 체크리스트
|
||||||
|
|
||||||
|
### 프론트엔드 개발 시 확인사항
|
||||||
|
|
||||||
|
#### ✅ 상태 표시
|
||||||
|
- [ ] `statusCd`를 UI에 정확히 매핑 (READY, IN_PROGRESS, COMPLETED, STOPPED, ERROR)
|
||||||
|
- [ ] `step1Status`, `step2Status` 개별 표시
|
||||||
|
- [ ] 한글 상태명 표시 (statusName, step1StatusName, step2StatusName 활용)
|
||||||
|
- [ ] 상태별 적절한 색상/아이콘 사용
|
||||||
|
|
||||||
|
#### ✅ 버튼 활성화/비활성화
|
||||||
|
- [ ] `statusCd`에 따른 버튼 조건 확인
|
||||||
|
- [ ] `step1Status`, `step2Status` 조합 고려
|
||||||
|
- [ ] 중복 실행 방지 (disabled 속성)
|
||||||
|
|
||||||
|
#### ✅ 시간 정보 표시
|
||||||
|
- [ ] `step1StrtDttm`, `step1EndDttm` 표시
|
||||||
|
- [ ] `step2StrtDttm`, `step2EndDttm` 표시
|
||||||
|
- [ ] `step1Duration`, `step2Duration` 계산 표시
|
||||||
|
|
||||||
|
#### ✅ 에러 처리
|
||||||
|
- [ ] `lastError` 메시지 파싱 및 표시
|
||||||
|
- [ ] 사용자 친화적 에러 메시지 변환
|
||||||
|
- [ ] 에러 발생 시 복구 방법 안내
|
||||||
|
|
||||||
|
#### ✅ 주기적 업데이트
|
||||||
|
- [ ] 진행 중인 모델 폴링 (10초 간격 권장)
|
||||||
|
- [ ] `POST /api/train/status/{uuid}` 주기 호출
|
||||||
|
- [ ] 목록 자동 갱신
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 10. 베스트 프랙티스
|
||||||
|
|
||||||
|
### 10.1 상태 동기화
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ❌ 나쁜 예: 프론트에서 임의로 상태 변경
|
||||||
|
function handleCancel() {
|
||||||
|
model.statusCd = 'STOPPED'; // 직접 변경 X
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 좋은 예: API 호출 후 서버 응답 기준으로 업데이트
|
||||||
|
async function handleCancel() {
|
||||||
|
await axios.post(`/api/train/cancel/${uuid}`);
|
||||||
|
const updated = await axios.get(`/api/models/list`);
|
||||||
|
setModels(updated.data.content);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 낙관적 UI 업데이트
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ 사용자 경험 향상: 즉시 UI 업데이트 + 백그라운드 검증
|
||||||
|
async function handleRun() {
|
||||||
|
// 1. 즉시 UI 업데이트 (낙관적)
|
||||||
|
setModel(prev => ({ ...prev, statusCd: 'IN_PROGRESS' }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. API 호출
|
||||||
|
await axios.post(`/api/train/run/${uuid}`);
|
||||||
|
|
||||||
|
// 3. 실제 상태 확인
|
||||||
|
setTimeout(() => checkTrainStatus(uuid), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
// 4. 실패 시 롤백
|
||||||
|
setModel(prev => ({ ...prev, statusCd: 'READY' }));
|
||||||
|
alert('실행 실패: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 상태별 UI 일관성
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 상태별 일관된 색상 스타일 */
|
||||||
|
.status-badge.ready { background: #e3f2fd; color: #1976d2; }
|
||||||
|
.status-badge.in-progress { background: #e8f5e9; color: #388e3c; }
|
||||||
|
.status-badge.completed { background: #f1f8e9; color: #689f38; }
|
||||||
|
.status-badge.stopped { background: #fff3e0; color: #f57c00; }
|
||||||
|
.status-badge.error { background: #ffebee; color: #d32f2f; }
|
||||||
|
|
||||||
|
/* 버튼 스타일 */
|
||||||
|
.btn-primary { background: #1976d2; color: white; }
|
||||||
|
.btn-success { background: #388e3c; color: white; }
|
||||||
|
.btn-warning { background: #f57c00; color: white; }
|
||||||
|
.btn-danger { background: #d32f2f; color: white; }
|
||||||
|
.btn-info { background: #0288d1; color: white; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 요약
|
||||||
|
|
||||||
|
### 상태 확인 방법
|
||||||
|
1. **API 호출**: `POST /api/train/status/{uuid}`
|
||||||
|
2. **응답 확인**: `statusCd`, `step1Status`, `step2Status`
|
||||||
|
3. **UI 반영**: 상태별 배지/아이콘 표시
|
||||||
|
|
||||||
|
### 상태 변경 방법
|
||||||
|
1. **적절한 제어 API 호출**:
|
||||||
|
- 실행: `POST /api/train/run/{uuid}`
|
||||||
|
- 취소: `POST /api/train/cancel/{uuid}`
|
||||||
|
- 재실행: `POST /api/train/restart/{uuid}`
|
||||||
|
- 이어하기: `POST /api/train/resume/{uuid}`
|
||||||
|
|
||||||
|
2. **버튼 조건부 표시**:
|
||||||
|
- `statusCd`, `step1Status`, `step2Status` 조합 확인
|
||||||
|
|
||||||
|
3. **주기적 폴링**:
|
||||||
|
- 진행 중(`IN_PROGRESS`) 상태일 때만 10초 간격 폴링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user