update
This commit is contained in:
29
src/main/java/research/loghunter/LogHunterApplication.java
Normal file
29
src/main/java/research/loghunter/LogHunterApplication.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package research.loghunter;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
||||
import java.awt.Desktop;
|
||||
import java.net.URI;
|
||||
|
||||
@SpringBootApplication
|
||||
public class LogHunterApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(LogHunterApplication.class, args);
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void openBrowser() {
|
||||
String url = "http://localhost:8080";
|
||||
try {
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
Desktop.getDesktop().browse(new URI(url));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("브라우저를 자동으로 열 수 없습니다. 직접 " + url + " 에 접속해주세요.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package research.loghunter.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import research.loghunter.dto.ErrorLogDto;
|
||||
import research.loghunter.service.ErrorLogService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/error-logs")
|
||||
@RequiredArgsConstructor
|
||||
public class ErrorLogController {
|
||||
|
||||
private final ErrorLogService errorLogService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<ErrorLogDto>> search(
|
||||
@RequestParam(required = false) Long serverId,
|
||||
@RequestParam(required = false) Long patternId,
|
||||
@RequestParam(required = false) String severity,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
return ResponseEntity.ok(errorLogService.search(
|
||||
serverId, patternId, severity, startDate, endDate, keyword, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ErrorLogDto> findById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(errorLogService.findById(id));
|
||||
}
|
||||
|
||||
@GetMapping("/server/{serverId}")
|
||||
public ResponseEntity<Page<ErrorLogDto>> findByServerId(
|
||||
@PathVariable Long serverId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
return ResponseEntity.ok(errorLogService.findByServerId(serverId, page, size));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package research.loghunter.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import research.loghunter.service.ExportService;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/export")
|
||||
@RequiredArgsConstructor
|
||||
public class ExportController {
|
||||
|
||||
private final ExportService exportService;
|
||||
|
||||
@GetMapping("/html")
|
||||
public ResponseEntity<byte[]> exportHtml(
|
||||
@RequestParam(required = false) Long serverId,
|
||||
@RequestParam(required = false) Long patternId,
|
||||
@RequestParam(required = false) String severity,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
|
||||
@RequestParam(required = false) String keyword
|
||||
) {
|
||||
ExportService.ExportRequest request = new ExportService.ExportRequest(
|
||||
serverId, patternId, severity, startDate, endDate, keyword);
|
||||
|
||||
ExportService.ExportResult result = exportService.exportHtml(request);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + encodeFilename(result.filename()) + "\"")
|
||||
.contentType(MediaType.TEXT_HTML)
|
||||
.body(result.content());
|
||||
}
|
||||
|
||||
@GetMapping("/txt")
|
||||
public ResponseEntity<byte[]> exportTxt(
|
||||
@RequestParam(required = false) Long serverId,
|
||||
@RequestParam(required = false) Long patternId,
|
||||
@RequestParam(required = false) String severity,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
|
||||
@RequestParam(required = false) String keyword
|
||||
) {
|
||||
ExportService.ExportRequest request = new ExportService.ExportRequest(
|
||||
serverId, patternId, severity, startDate, endDate, keyword);
|
||||
|
||||
ExportService.ExportResult result = exportService.exportTxt(request);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + encodeFilename(result.filename()) + "\"")
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.body(result.content());
|
||||
}
|
||||
|
||||
private String encodeFilename(String filename) {
|
||||
return URLEncoder.encode(filename, StandardCharsets.UTF_8)
|
||||
.replace("+", "%20");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package research.loghunter.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import research.loghunter.dto.PatternDto;
|
||||
import research.loghunter.service.PatternService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/patterns")
|
||||
@RequiredArgsConstructor
|
||||
public class PatternController {
|
||||
|
||||
private final PatternService patternService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<PatternDto>> findAll() {
|
||||
return ResponseEntity.ok(patternService.findAll());
|
||||
}
|
||||
|
||||
@GetMapping("/active")
|
||||
public ResponseEntity<List<PatternDto>> findAllActive() {
|
||||
return ResponseEntity.ok(patternService.findAllActive());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<PatternDto> findById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(patternService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<PatternDto> create(@RequestBody PatternDto dto) {
|
||||
return ResponseEntity.ok(patternService.create(dto));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<PatternDto> update(@PathVariable Long id, @RequestBody PatternDto dto) {
|
||||
return ResponseEntity.ok(patternService.update(id, dto));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
patternService.delete(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
public ResponseEntity<PatternService.PatternTestResult> testPattern(
|
||||
@RequestParam String regex,
|
||||
@RequestParam String sampleText) {
|
||||
return ResponseEntity.ok(patternService.testPattern(regex, sampleText));
|
||||
}
|
||||
}
|
||||
136
src/main/java/research/loghunter/controller/ScanController.java
Normal file
136
src/main/java/research/loghunter/controller/ScanController.java
Normal file
@@ -0,0 +1,136 @@
|
||||
package research.loghunter.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import research.loghunter.entity.ScanHistory;
|
||||
import research.loghunter.service.ScanService;
|
||||
import research.loghunter.service.SftpService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/scan")
|
||||
@RequiredArgsConstructor
|
||||
public class ScanController {
|
||||
|
||||
private final ScanService scanService;
|
||||
private final SftpService sftpService;
|
||||
private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||
|
||||
/**
|
||||
* 연결 테스트
|
||||
*/
|
||||
@PostMapping("/test-connection/{serverId}")
|
||||
public ResponseEntity<SftpService.ConnectionTestResult> testConnection(@PathVariable Long serverId) {
|
||||
return ResponseEntity.ok(sftpService.testConnection(serverId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 서버 스캔 (SSE로 진행상황 전송)
|
||||
*/
|
||||
@GetMapping(value = "/start/{serverId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter startScan(@PathVariable Long serverId) {
|
||||
SseEmitter emitter = new SseEmitter(300000L); // 5분 타임아웃
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
ScanService.ScanResult result = scanService.scanServer(serverId, progress -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("progress")
|
||||
.data(progress));
|
||||
} catch (IOException e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("complete")
|
||||
.data(result));
|
||||
emitter.complete();
|
||||
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("error")
|
||||
.data(e.getMessage()));
|
||||
} catch (IOException ignored) {}
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 서버 스캔 (SSE로 진행상황 전송)
|
||||
*/
|
||||
@GetMapping(value = "/start-all", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter startAllScan() {
|
||||
SseEmitter emitter = new SseEmitter(600000L); // 10분 타임아웃
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
List<ScanService.ScanResult> results = scanService.scanAllServers(progress -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("progress")
|
||||
.data(progress));
|
||||
} catch (IOException e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("complete")
|
||||
.data(results));
|
||||
emitter.complete();
|
||||
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("error")
|
||||
.data(e.getMessage()));
|
||||
} catch (IOException ignored) {}
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 서버 스캔 (동기 방식)
|
||||
*/
|
||||
@PostMapping("/execute/{serverId}")
|
||||
public ResponseEntity<ScanService.ScanResult> executeScan(@PathVariable Long serverId) {
|
||||
ScanService.ScanResult result = scanService.scanServer(serverId, null);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 진행 상황 조회
|
||||
*/
|
||||
@GetMapping("/progress/{serverId}")
|
||||
public ResponseEntity<ScanService.ScanProgress> getProgress(@PathVariable Long serverId) {
|
||||
ScanService.ScanProgress progress = scanService.getProgress(serverId);
|
||||
if (progress == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(progress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스캔 이력 조회
|
||||
*/
|
||||
@GetMapping("/history/{serverId}")
|
||||
public ResponseEntity<List<ScanHistory>> getHistory(@PathVariable Long serverId) {
|
||||
return ResponseEntity.ok(scanService.getHistory(serverId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package research.loghunter.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import research.loghunter.dto.ServerDto;
|
||||
import research.loghunter.service.ServerService;
|
||||
import research.loghunter.service.SftpService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/servers")
|
||||
@RequiredArgsConstructor
|
||||
public class ServerController {
|
||||
|
||||
private final ServerService serverService;
|
||||
private final SftpService sftpService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<ServerDto>> findAll() {
|
||||
return ResponseEntity.ok(serverService.findAll());
|
||||
}
|
||||
|
||||
@GetMapping("/active")
|
||||
public ResponseEntity<List<ServerDto>> findAllActive() {
|
||||
return ResponseEntity.ok(serverService.findAllActive());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ServerDto> findById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(serverService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ServerDto> create(@RequestBody ServerDto dto) {
|
||||
return ResponseEntity.ok(serverService.create(dto));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ServerDto> update(@PathVariable Long id, @RequestBody ServerDto dto) {
|
||||
return ResponseEntity.ok(serverService.update(id, dto));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
serverService.delete(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 테스트
|
||||
*/
|
||||
@PostMapping("/{id}/test-connection")
|
||||
public ResponseEntity<SftpService.ConnectionTestResult> testConnection(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(sftpService.testConnection(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package research.loghunter.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import research.loghunter.dto.ServerLogPathDto;
|
||||
import research.loghunter.service.ServerLogPathService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/log-paths")
|
||||
@RequiredArgsConstructor
|
||||
public class ServerLogPathController {
|
||||
|
||||
private final ServerLogPathService logPathService;
|
||||
|
||||
@GetMapping("/server/{serverId}")
|
||||
public ResponseEntity<List<ServerLogPathDto>> findByServerId(@PathVariable Long serverId) {
|
||||
return ResponseEntity.ok(logPathService.findByServerId(serverId));
|
||||
}
|
||||
|
||||
@GetMapping("/server/{serverId}/active")
|
||||
public ResponseEntity<List<ServerLogPathDto>> findActiveByServerId(@PathVariable Long serverId) {
|
||||
return ResponseEntity.ok(logPathService.findActiveByServerId(serverId));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ServerLogPathDto> findById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(logPathService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ServerLogPathDto> create(@RequestBody ServerLogPathDto dto) {
|
||||
return ResponseEntity.ok(logPathService.create(dto));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ServerLogPathDto> update(@PathVariable Long id, @RequestBody ServerLogPathDto dto) {
|
||||
return ResponseEntity.ok(logPathService.update(id, dto));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
logPathService.delete(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package research.loghunter.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import research.loghunter.dto.SettingDto;
|
||||
import research.loghunter.service.SettingService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/settings")
|
||||
@RequiredArgsConstructor
|
||||
public class SettingController {
|
||||
|
||||
private final SettingService settingService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<SettingDto>> findAll() {
|
||||
return ResponseEntity.ok(settingService.findAll());
|
||||
}
|
||||
|
||||
@GetMapping("/map")
|
||||
public ResponseEntity<Map<String, String>> findAllAsMap() {
|
||||
return ResponseEntity.ok(settingService.findAllAsMap());
|
||||
}
|
||||
|
||||
@GetMapping("/{key}")
|
||||
public ResponseEntity<String> getValue(@PathVariable String key) {
|
||||
String value = settingService.getValue(key);
|
||||
return value != null ? ResponseEntity.ok(value) : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<SettingDto> save(@RequestBody SettingDto dto) {
|
||||
return ResponseEntity.ok(settingService.save(dto));
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public ResponseEntity<Void> saveAll(@RequestBody Map<String, String> settings) {
|
||||
settingService.saveAll(settings);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{key}")
|
||||
public ResponseEntity<Void> delete(@PathVariable String key) {
|
||||
settingService.delete(key);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
24
src/main/java/research/loghunter/dto/ErrorLogDto.java
Normal file
24
src/main/java/research/loghunter/dto/ErrorLogDto.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package research.loghunter.dto;
|
||||
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ErrorLogDto {
|
||||
private Long id;
|
||||
private Long serverId;
|
||||
private String serverName;
|
||||
private Long patternId;
|
||||
private String patternName;
|
||||
private String filePath;
|
||||
private Integer lineNumber;
|
||||
private String summary;
|
||||
private String context;
|
||||
private String severity;
|
||||
private LocalDateTime occurredAt;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
20
src/main/java/research/loghunter/dto/PatternDto.java
Normal file
20
src/main/java/research/loghunter/dto/PatternDto.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package research.loghunter.dto;
|
||||
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PatternDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String regex;
|
||||
private String severity;
|
||||
private Integer contextLines;
|
||||
private String description;
|
||||
private Boolean active;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
27
src/main/java/research/loghunter/dto/ServerDto.java
Normal file
27
src/main/java/research/loghunter/dto/ServerDto.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package research.loghunter.dto;
|
||||
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ServerDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String host;
|
||||
private Integer port;
|
||||
private String username;
|
||||
private String authType;
|
||||
private String password; // 입력용 (저장 시 암호화)
|
||||
private String keyFilePath;
|
||||
private String passphrase; // 입력용 (저장 시 암호화)
|
||||
private Boolean active;
|
||||
private LocalDateTime lastScanAt;
|
||||
private LocalDateTime lastErrorAt;
|
||||
private LocalDateTime createdAt;
|
||||
private List<ServerLogPathDto> logPaths;
|
||||
}
|
||||
17
src/main/java/research/loghunter/dto/ServerLogPathDto.java
Normal file
17
src/main/java/research/loghunter/dto/ServerLogPathDto.java
Normal file
@@ -0,0 +1,17 @@
|
||||
package research.loghunter.dto;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ServerLogPathDto {
|
||||
private Long id;
|
||||
private Long serverId;
|
||||
private String path;
|
||||
private String filePattern;
|
||||
private String description;
|
||||
private Boolean active;
|
||||
}
|
||||
14
src/main/java/research/loghunter/dto/SettingDto.java
Normal file
14
src/main/java/research/loghunter/dto/SettingDto.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package research.loghunter.dto;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SettingDto {
|
||||
private String key;
|
||||
private String value;
|
||||
private String description;
|
||||
}
|
||||
61
src/main/java/research/loghunter/entity/ErrorLog.java
Normal file
61
src/main/java/research/loghunter/entity/ErrorLog.java
Normal file
@@ -0,0 +1,61 @@
|
||||
package research.loghunter.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "error_logs")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ErrorLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "server_id", nullable = false)
|
||||
private Server server;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "pattern_id", nullable = false)
|
||||
private Pattern pattern;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "scan_history_id")
|
||||
private ScanHistory scanHistory;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String filePath; // 로그 파일 경로
|
||||
|
||||
private Integer lineNumber; // 에러 발생 라인 번호
|
||||
|
||||
@Column(nullable = false, length = 500)
|
||||
private String summary; // 에러 요약 (첫 줄 또는 일부)
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String context; // 캡처된 전체 컨텍스트
|
||||
|
||||
@Column(nullable = false)
|
||||
private String severity; // CRITICAL, ERROR, WARN
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime occurredAt; // 에러 발생 시간 (로그 파일 내 시간 파싱)
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime scannedAt; // 스캔/분석 시간
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt; // DB 저장 시간
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (scannedAt == null) scannedAt = LocalDateTime.now();
|
||||
if (occurredAt == null) occurredAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
55
src/main/java/research/loghunter/entity/Pattern.java
Normal file
55
src/main/java/research/loghunter/entity/Pattern.java
Normal file
@@ -0,0 +1,55 @@
|
||||
package research.loghunter.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "patterns")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Pattern {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name; // 패턴명 (예: NullPointer, DB Connection)
|
||||
|
||||
@Column(nullable = false, length = 1000)
|
||||
private String regex; // 정규식
|
||||
|
||||
@Column(nullable = false)
|
||||
private String severity; // CRITICAL, ERROR, WARN
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer contextLines; // 에러 전후 캡처할 라인 수
|
||||
|
||||
private String description; // 설명
|
||||
|
||||
@Column(nullable = false)
|
||||
private Boolean active;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (active == null) active = true;
|
||||
if (contextLines == null) contextLines = 5;
|
||||
if (severity == null) severity = "ERROR";
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
46
src/main/java/research/loghunter/entity/ScanHistory.java
Normal file
46
src/main/java/research/loghunter/entity/ScanHistory.java
Normal file
@@ -0,0 +1,46 @@
|
||||
package research.loghunter.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "scan_history")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ScanHistory {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "server_id", nullable = false)
|
||||
private Server server;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime startedAt; // 분석 시작 시간
|
||||
|
||||
private LocalDateTime finishedAt; // 분석 종료 시간
|
||||
|
||||
@Column(nullable = false)
|
||||
private String status; // RUNNING, SUCCESS, FAILED
|
||||
|
||||
private Integer filesScanned; // 스캔한 파일 수
|
||||
|
||||
private Integer errorsFound; // 발견된 에러 수
|
||||
|
||||
@Column(length = 2000)
|
||||
private String errorMessage; // 실패 시 에러 메시지
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (startedAt == null) startedAt = LocalDateTime.now();
|
||||
if (status == null) status = "RUNNING";
|
||||
if (filesScanned == null) filesScanned = 0;
|
||||
if (errorsFound == null) errorsFound = 0;
|
||||
}
|
||||
}
|
||||
61
src/main/java/research/loghunter/entity/ScannedFile.java
Normal file
61
src/main/java/research/loghunter/entity/ScannedFile.java
Normal file
@@ -0,0 +1,61 @@
|
||||
package research.loghunter.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "scanned_files", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = {"server_id", "file_path", "file_size"})
|
||||
})
|
||||
public class ScannedFile {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "server_id", nullable = false)
|
||||
private Long serverId;
|
||||
|
||||
@Column(name = "log_path_id", nullable = false)
|
||||
private Long logPathId;
|
||||
|
||||
@Column(name = "file_path", nullable = false, length = 1000)
|
||||
private String filePath;
|
||||
|
||||
@Column(name = "file_name", nullable = false, length = 500)
|
||||
private String fileName;
|
||||
|
||||
@Column(name = "file_size", nullable = false)
|
||||
private Long fileSize;
|
||||
|
||||
@Column(name = "scanned_at", nullable = false)
|
||||
private LocalDateTime scannedAt;
|
||||
|
||||
@Column(name = "error_count")
|
||||
private Integer errorCount = 0;
|
||||
|
||||
// Getters and Setters
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Long getServerId() { return serverId; }
|
||||
public void setServerId(Long serverId) { this.serverId = serverId; }
|
||||
|
||||
public Long getLogPathId() { return logPathId; }
|
||||
public void setLogPathId(Long logPathId) { this.logPathId = logPathId; }
|
||||
|
||||
public String getFilePath() { return filePath; }
|
||||
public void setFilePath(String filePath) { this.filePath = filePath; }
|
||||
|
||||
public String getFileName() { return fileName; }
|
||||
public void setFileName(String fileName) { this.fileName = fileName; }
|
||||
|
||||
public Long getFileSize() { return fileSize; }
|
||||
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
|
||||
|
||||
public LocalDateTime getScannedAt() { return scannedAt; }
|
||||
public void setScannedAt(LocalDateTime scannedAt) { this.scannedAt = scannedAt; }
|
||||
|
||||
public Integer getErrorCount() { return errorCount; }
|
||||
public void setErrorCount(Integer errorCount) { this.errorCount = errorCount; }
|
||||
}
|
||||
80
src/main/java/research/loghunter/entity/Server.java
Normal file
80
src/main/java/research/loghunter/entity/Server.java
Normal file
@@ -0,0 +1,80 @@
|
||||
package research.loghunter.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "servers")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Server {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name; // 서버 별칭
|
||||
|
||||
@Column(nullable = false)
|
||||
private String host;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer port; // 기본 22
|
||||
|
||||
@Column(nullable = false)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String authType; // PASSWORD or KEY_FILE
|
||||
|
||||
private String encryptedPassword; // 암호화된 비밀번호
|
||||
|
||||
private String keyFilePath; // 키 파일 경로
|
||||
|
||||
private String encryptedPassphrase; // 암호화된 passphrase
|
||||
|
||||
@Column(nullable = false)
|
||||
private Boolean active; // 활성화 여부
|
||||
|
||||
// 서버 시간과 로컬 시간의 차이 (분 단위)
|
||||
// 예: 서버가 UTC이고 로컬이 KST(+9)면 -540
|
||||
// 로그시간 + offset = 로컬시간
|
||||
private Integer timeOffsetMinutes;
|
||||
|
||||
// 마지막으로 시간 동기화 체크한 일시
|
||||
private LocalDateTime lastTimeSyncAt;
|
||||
|
||||
private LocalDateTime lastScanAt; // 마지막 분석 일시
|
||||
|
||||
private LocalDateTime lastErrorAt; // 마지막 에러 발생 일시
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@OneToMany(mappedBy = "server", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@Builder.Default
|
||||
private List<ServerLogPath> logPaths = new ArrayList<>();
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (active == null) active = true;
|
||||
if (port == null) port = 22;
|
||||
if (timeOffsetMinutes == null) timeOffsetMinutes = 0;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
43
src/main/java/research/loghunter/entity/ServerLogPath.java
Normal file
43
src/main/java/research/loghunter/entity/ServerLogPath.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package research.loghunter.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "server_log_paths")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ServerLogPath {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "server_id", nullable = false)
|
||||
private Server server;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String path; // 예: /var/log/tomcat/
|
||||
|
||||
@Column(nullable = false)
|
||||
private String filePattern; // 예: catalina.*.log, *.log
|
||||
|
||||
private String description; // 설명 (예: Tomcat 로그)
|
||||
|
||||
@Column(nullable = false)
|
||||
private Boolean active;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (active == null) active = true;
|
||||
}
|
||||
}
|
||||
32
src/main/java/research/loghunter/entity/Setting.java
Normal file
32
src/main/java/research/loghunter/entity/Setting.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package research.loghunter.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "settings")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Setting {
|
||||
|
||||
@Id
|
||||
@Column(name = "setting_key", nullable = false, unique = true)
|
||||
private String key;
|
||||
|
||||
@Column(nullable = false, length = 2000)
|
||||
private String value;
|
||||
|
||||
private String description;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
protected void onSave() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package research.loghunter.repository;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import research.loghunter.entity.ErrorLog;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface ErrorLogRepository extends JpaRepository<ErrorLog, Long> {
|
||||
|
||||
List<ErrorLog> findByServerIdOrderByOccurredAtDesc(Long serverId);
|
||||
|
||||
Page<ErrorLog> findByServerIdOrderByOccurredAtDesc(Long serverId, Pageable pageable);
|
||||
|
||||
@Query("SELECT e FROM ErrorLog e WHERE " +
|
||||
"(:serverId IS NULL OR e.server.id = :serverId) AND " +
|
||||
"(:patternId IS NULL OR e.pattern.id = :patternId) AND " +
|
||||
"(:severity IS NULL OR e.severity = :severity) AND " +
|
||||
"(:startDate IS NULL OR e.occurredAt >= :startDate) AND " +
|
||||
"(:endDate IS NULL OR e.occurredAt <= :endDate) AND " +
|
||||
"(:keyword IS NULL OR LOWER(e.summary) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(e.context) LIKE LOWER(CONCAT('%', :keyword, '%'))) " +
|
||||
"ORDER BY e.occurredAt DESC")
|
||||
Page<ErrorLog> searchErrors(
|
||||
@Param("serverId") Long serverId,
|
||||
@Param("patternId") Long patternId,
|
||||
@Param("severity") String severity,
|
||||
@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate,
|
||||
@Param("keyword") String keyword,
|
||||
Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package research.loghunter.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import research.loghunter.entity.Pattern;
|
||||
import java.util.List;
|
||||
|
||||
public interface PatternRepository extends JpaRepository<Pattern, Long> {
|
||||
List<Pattern> findByActiveTrue();
|
||||
List<Pattern> findByActiveTrueOrderBySeverityAscNameAsc();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package research.loghunter.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import research.loghunter.entity.ScanHistory;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ScanHistoryRepository extends JpaRepository<ScanHistory, Long> {
|
||||
List<ScanHistory> findByServerIdOrderByStartedAtDesc(Long serverId);
|
||||
Optional<ScanHistory> findTopByServerIdOrderByStartedAtDesc(Long serverId);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package research.loghunter.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import research.loghunter.entity.ScannedFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ScannedFileRepository extends JpaRepository<ScannedFile, Long> {
|
||||
|
||||
// 파일 경로와 크기로 이미 스캔된 파일인지 확인
|
||||
Optional<ScannedFile> findByServerIdAndFilePathAndFileSize(Long serverId, String filePath, Long fileSize);
|
||||
|
||||
// 서버별 스캔된 파일 목록
|
||||
List<ScannedFile> findByServerId(Long serverId);
|
||||
|
||||
// 경로별 스캔된 파일 목록
|
||||
List<ScannedFile> findByLogPathId(Long logPathId);
|
||||
|
||||
// 서버와 파일경로로 스캔 이력 조회 (크기 무관 - 변경 체크용)
|
||||
@Query("SELECT sf FROM ScannedFile sf WHERE sf.serverId = :serverId AND sf.filePath = :filePath ORDER BY sf.scannedAt DESC")
|
||||
List<ScannedFile> findByServerIdAndFilePath(@Param("serverId") Long serverId, @Param("filePath") String filePath);
|
||||
|
||||
// 서버별 스캔 파일 수
|
||||
long countByServerId(Long serverId);
|
||||
|
||||
// 경로별 스캔 파일 수
|
||||
long countByLogPathId(Long logPathId);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package research.loghunter.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import research.loghunter.entity.ServerLogPath;
|
||||
import java.util.List;
|
||||
|
||||
public interface ServerLogPathRepository extends JpaRepository<ServerLogPath, Long> {
|
||||
List<ServerLogPath> findByServerId(Long serverId);
|
||||
List<ServerLogPath> findByServerIdAndActiveTrue(Long serverId);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package research.loghunter.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import research.loghunter.entity.Server;
|
||||
import java.util.List;
|
||||
|
||||
public interface ServerRepository extends JpaRepository<Server, Long> {
|
||||
List<Server> findByActiveTrue();
|
||||
List<Server> findByActiveTrueOrderByNameAsc();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package research.loghunter.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import research.loghunter.entity.Setting;
|
||||
|
||||
public interface SettingRepository extends JpaRepository<Setting, String> {
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package research.loghunter.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import research.loghunter.dto.ErrorLogDto;
|
||||
import research.loghunter.entity.ErrorLog;
|
||||
import research.loghunter.repository.ErrorLogRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ErrorLogService {
|
||||
|
||||
private final ErrorLogRepository errorLogRepository;
|
||||
|
||||
public Page<ErrorLogDto> search(
|
||||
Long serverId,
|
||||
Long patternId,
|
||||
String severity,
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate,
|
||||
String keyword,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
Page<ErrorLog> errorLogs = errorLogRepository.searchErrors(
|
||||
serverId, patternId, severity, startDate, endDate, keyword, pageable);
|
||||
|
||||
return errorLogs.map(this::toDto);
|
||||
}
|
||||
|
||||
public Page<ErrorLogDto> findByServerId(Long serverId, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
Page<ErrorLog> errorLogs = errorLogRepository.findByServerIdOrderByOccurredAtDesc(serverId, pageable);
|
||||
return errorLogs.map(this::toDto);
|
||||
}
|
||||
|
||||
public ErrorLogDto findById(Long id) {
|
||||
return errorLogRepository.findById(id)
|
||||
.map(this::toDto)
|
||||
.orElseThrow(() -> new RuntimeException("ErrorLog not found: " + id));
|
||||
}
|
||||
|
||||
private ErrorLogDto toDto(ErrorLog errorLog) {
|
||||
return ErrorLogDto.builder()
|
||||
.id(errorLog.getId())
|
||||
.serverId(errorLog.getServer().getId())
|
||||
.serverName(errorLog.getServer().getName())
|
||||
.patternId(errorLog.getPattern().getId())
|
||||
.patternName(errorLog.getPattern().getName())
|
||||
.filePath(errorLog.getFilePath())
|
||||
.lineNumber(errorLog.getLineNumber())
|
||||
.summary(errorLog.getSummary())
|
||||
.context(errorLog.getContext())
|
||||
.severity(errorLog.getSeverity())
|
||||
.occurredAt(errorLog.getOccurredAt())
|
||||
.createdAt(errorLog.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
285
src/main/java/research/loghunter/service/ExportService.java
Normal file
285
src/main/java/research/loghunter/service/ExportService.java
Normal file
@@ -0,0 +1,285 @@
|
||||
package research.loghunter.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import research.loghunter.dto.ErrorLogDto;
|
||||
import research.loghunter.entity.Server;
|
||||
import research.loghunter.repository.ServerRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ExportService {
|
||||
|
||||
private final ErrorLogService errorLogService;
|
||||
private final ServerRepository serverRepository;
|
||||
private final SettingService settingService;
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final DateTimeFormatter FILE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
|
||||
|
||||
/**
|
||||
* HTML 리포트 생성
|
||||
*/
|
||||
public ExportResult exportHtml(ExportRequest request) {
|
||||
List<ErrorLogDto> errors = getErrors(request);
|
||||
String serverName = getServerName(request.serverId());
|
||||
String title = generateTitle(serverName, request);
|
||||
|
||||
StringBuilder html = new StringBuilder();
|
||||
html.append("<!DOCTYPE html>\n");
|
||||
html.append("<html lang=\"ko\">\n");
|
||||
html.append("<head>\n");
|
||||
html.append(" <meta charset=\"UTF-8\">\n");
|
||||
html.append(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
|
||||
html.append(" <title>").append(escapeHtml(title)).append("</title>\n");
|
||||
html.append(" <style>\n");
|
||||
html.append(getHtmlStyles());
|
||||
html.append(" </style>\n");
|
||||
html.append("</head>\n");
|
||||
html.append("<body>\n");
|
||||
|
||||
// Header
|
||||
html.append(" <div class=\"header\">\n");
|
||||
html.append(" <h1>LogHunter 에러 리포트</h1>\n");
|
||||
html.append(" <p class=\"subtitle\">").append(escapeHtml(title)).append("</p>\n");
|
||||
html.append(" <p class=\"generated\">생성일시: ").append(LocalDateTime.now().format(DATE_FORMAT)).append("</p>\n");
|
||||
html.append(" </div>\n");
|
||||
|
||||
// Summary
|
||||
html.append(" <div class=\"summary\">\n");
|
||||
html.append(" <h2>요약</h2>\n");
|
||||
html.append(" <ul>\n");
|
||||
html.append(" <li>총 에러 수: <strong>").append(errors.size()).append("</strong>건</li>\n");
|
||||
html.append(" <li>CRITICAL: <strong>").append(countBySeverity(errors, "CRITICAL")).append("</strong>건</li>\n");
|
||||
html.append(" <li>ERROR: <strong>").append(countBySeverity(errors, "ERROR")).append("</strong>건</li>\n");
|
||||
html.append(" <li>WARN: <strong>").append(countBySeverity(errors, "WARN")).append("</strong>건</li>\n");
|
||||
html.append(" </ul>\n");
|
||||
html.append(" </div>\n");
|
||||
|
||||
// Error List
|
||||
html.append(" <div class=\"error-list\">\n");
|
||||
html.append(" <h2>에러 목록</h2>\n");
|
||||
|
||||
if (errors.isEmpty()) {
|
||||
html.append(" <p class=\"no-data\">검출된 에러가 없습니다.</p>\n");
|
||||
} else {
|
||||
for (int i = 0; i < errors.size(); i++) {
|
||||
ErrorLogDto error = errors.get(i);
|
||||
html.append(renderErrorHtml(error, i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
html.append(" </div>\n");
|
||||
|
||||
// Footer
|
||||
html.append(" <div class=\"footer\">\n");
|
||||
html.append(" <p>Generated by LogHunter</p>\n");
|
||||
html.append(" </div>\n");
|
||||
|
||||
html.append("</body>\n");
|
||||
html.append("</html>");
|
||||
|
||||
String filename = "loghunter_report_" + LocalDateTime.now().format(FILE_DATE_FORMAT) + ".html";
|
||||
return new ExportResult(filename, html.toString().getBytes(), "text/html");
|
||||
}
|
||||
|
||||
/**
|
||||
* TXT 리포트 생성
|
||||
*/
|
||||
public ExportResult exportTxt(ExportRequest request) {
|
||||
List<ErrorLogDto> errors = getErrors(request);
|
||||
String serverName = getServerName(request.serverId());
|
||||
String title = generateTitle(serverName, request);
|
||||
|
||||
StringBuilder txt = new StringBuilder();
|
||||
txt.append("=".repeat(80)).append("\n");
|
||||
txt.append("LogHunter 에러 리포트\n");
|
||||
txt.append("=".repeat(80)).append("\n\n");
|
||||
|
||||
txt.append("제목: ").append(title).append("\n");
|
||||
txt.append("생성일시: ").append(LocalDateTime.now().format(DATE_FORMAT)).append("\n\n");
|
||||
|
||||
// Summary
|
||||
txt.append("-".repeat(40)).append("\n");
|
||||
txt.append("요약\n");
|
||||
txt.append("-".repeat(40)).append("\n");
|
||||
txt.append("총 에러 수: ").append(errors.size()).append("건\n");
|
||||
txt.append(" - CRITICAL: ").append(countBySeverity(errors, "CRITICAL")).append("건\n");
|
||||
txt.append(" - ERROR: ").append(countBySeverity(errors, "ERROR")).append("건\n");
|
||||
txt.append(" - WARN: ").append(countBySeverity(errors, "WARN")).append("건\n\n");
|
||||
|
||||
// Error List
|
||||
txt.append("-".repeat(40)).append("\n");
|
||||
txt.append("에러 목록\n");
|
||||
txt.append("-".repeat(40)).append("\n\n");
|
||||
|
||||
if (errors.isEmpty()) {
|
||||
txt.append("검출된 에러가 없습니다.\n");
|
||||
} else {
|
||||
for (int i = 0; i < errors.size(); i++) {
|
||||
ErrorLogDto error = errors.get(i);
|
||||
txt.append(renderErrorTxt(error, i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
txt.append("\n").append("=".repeat(80)).append("\n");
|
||||
txt.append("Generated by LogHunter\n");
|
||||
txt.append("=".repeat(80)).append("\n");
|
||||
|
||||
String filename = "loghunter_report_" + LocalDateTime.now().format(FILE_DATE_FORMAT) + ".txt";
|
||||
return new ExportResult(filename, txt.toString().getBytes(), "text/plain");
|
||||
}
|
||||
|
||||
private List<ErrorLogDto> getErrors(ExportRequest request) {
|
||||
return errorLogService.search(
|
||||
request.serverId(),
|
||||
request.patternId(),
|
||||
request.severity(),
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
request.keyword(),
|
||||
0,
|
||||
10000 // 최대 10000건
|
||||
).getContent();
|
||||
}
|
||||
|
||||
private String getServerName(Long serverId) {
|
||||
if (serverId == null) return "전체 서버";
|
||||
return serverRepository.findById(serverId)
|
||||
.map(Server::getName)
|
||||
.orElse("알 수 없음");
|
||||
}
|
||||
|
||||
private String generateTitle(String serverName, ExportRequest request) {
|
||||
StringBuilder title = new StringBuilder();
|
||||
title.append(serverName);
|
||||
|
||||
if (request.startDate() != null || request.endDate() != null) {
|
||||
title.append(" (");
|
||||
if (request.startDate() != null) {
|
||||
title.append(request.startDate().toLocalDate());
|
||||
}
|
||||
title.append(" ~ ");
|
||||
if (request.endDate() != null) {
|
||||
title.append(request.endDate().toLocalDate());
|
||||
}
|
||||
title.append(")");
|
||||
}
|
||||
|
||||
return title.toString();
|
||||
}
|
||||
|
||||
private long countBySeverity(List<ErrorLogDto> errors, String severity) {
|
||||
return errors.stream()
|
||||
.filter(e -> severity.equals(e.getSeverity()))
|
||||
.count();
|
||||
}
|
||||
|
||||
private String renderErrorHtml(ErrorLogDto error, int index) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(" <div class=\"error-item\">\n");
|
||||
sb.append(" <div class=\"error-header\">\n");
|
||||
sb.append(" <span class=\"index\">#").append(index).append("</span>\n");
|
||||
sb.append(" <span class=\"severity ").append(error.getSeverity().toLowerCase()).append("\">")
|
||||
.append(error.getSeverity()).append("</span>\n");
|
||||
sb.append(" <span class=\"time\">").append(formatDateTime(error.getOccurredAt())).append("</span>\n");
|
||||
sb.append(" </div>\n");
|
||||
sb.append(" <div class=\"error-meta\">\n");
|
||||
sb.append(" <span><strong>서버:</strong> ").append(escapeHtml(error.getServerName())).append("</span>\n");
|
||||
sb.append(" <span><strong>패턴:</strong> ").append(escapeHtml(error.getPatternName())).append("</span>\n");
|
||||
sb.append(" <span><strong>파일:</strong> ").append(escapeHtml(error.getFilePath())).append("</span>\n");
|
||||
sb.append(" <span><strong>라인:</strong> ").append(error.getLineNumber()).append("</span>\n");
|
||||
sb.append(" </div>\n");
|
||||
sb.append(" <div class=\"error-summary\">\n");
|
||||
sb.append(" <strong>요약:</strong> ").append(escapeHtml(error.getSummary())).append("\n");
|
||||
sb.append(" </div>\n");
|
||||
sb.append(" <div class=\"error-context\">\n");
|
||||
sb.append(" <strong>컨텍스트:</strong>\n");
|
||||
sb.append(" <pre>").append(escapeHtml(error.getContext())).append("</pre>\n");
|
||||
sb.append(" </div>\n");
|
||||
sb.append(" </div>\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String renderErrorTxt(ErrorLogDto error, int index) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("[#").append(index).append("] ").append(error.getSeverity()).append("\n");
|
||||
sb.append("발생시간: ").append(formatDateTime(error.getOccurredAt())).append("\n");
|
||||
sb.append("서버: ").append(error.getServerName()).append("\n");
|
||||
sb.append("패턴: ").append(error.getPatternName()).append("\n");
|
||||
sb.append("파일: ").append(error.getFilePath()).append("\n");
|
||||
sb.append("라인: ").append(error.getLineNumber()).append("\n");
|
||||
sb.append("요약: ").append(error.getSummary()).append("\n");
|
||||
sb.append("컨텍스트:\n");
|
||||
sb.append(error.getContext()).append("\n");
|
||||
sb.append("-".repeat(60)).append("\n\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String formatDateTime(LocalDateTime dt) {
|
||||
return dt != null ? dt.format(DATE_FORMAT) : "-";
|
||||
}
|
||||
|
||||
private String escapeHtml(String str) {
|
||||
if (str == null) return "";
|
||||
return str.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
private String getHtmlStyles() {
|
||||
return """
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Malgun Gothic', sans-serif; background: #f5f5f5; padding: 20px; }
|
||||
.header { background: #2c3e50; color: white; padding: 30px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.header h1 { margin-bottom: 10px; }
|
||||
.header .subtitle { font-size: 18px; opacity: 0.9; }
|
||||
.header .generated { font-size: 14px; opacity: 0.7; margin-top: 10px; }
|
||||
.summary { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.summary h2 { margin-bottom: 15px; color: #2c3e50; }
|
||||
.summary ul { list-style: none; }
|
||||
.summary li { padding: 8px 0; border-bottom: 1px solid #eee; }
|
||||
.summary li:last-child { border-bottom: none; }
|
||||
.error-list { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.error-list h2 { margin-bottom: 20px; color: #2c3e50; }
|
||||
.error-item { border: 1px solid #ddd; border-radius: 8px; margin-bottom: 15px; overflow: hidden; }
|
||||
.error-header { background: #f8f9fa; padding: 12px 15px; display: flex; align-items: center; gap: 15px; }
|
||||
.error-header .index { font-weight: bold; color: #666; }
|
||||
.error-header .severity { padding: 4px 10px; border-radius: 4px; font-size: 12px; font-weight: bold; color: white; }
|
||||
.error-header .severity.critical { background: #c0392b; }
|
||||
.error-header .severity.error { background: #e74c3c; }
|
||||
.error-header .severity.warn { background: #f39c12; }
|
||||
.error-header .time { margin-left: auto; color: #666; font-size: 14px; }
|
||||
.error-meta { padding: 12px 15px; background: #fafafa; display: flex; flex-wrap: wrap; gap: 20px; font-size: 14px; }
|
||||
.error-summary { padding: 12px 15px; border-bottom: 1px solid #eee; }
|
||||
.error-context { padding: 12px 15px; }
|
||||
.error-context pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 4px; overflow-x: auto; font-size: 12px; line-height: 1.5; margin-top: 10px; white-space: pre-wrap; }
|
||||
.no-data { text-align: center; color: #666; padding: 40px; }
|
||||
.footer { text-align: center; padding: 20px; color: #666; font-size: 14px; }
|
||||
""";
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public record ExportRequest(
|
||||
Long serverId,
|
||||
Long patternId,
|
||||
String severity,
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate,
|
||||
String keyword
|
||||
) {}
|
||||
|
||||
public record ExportResult(
|
||||
String filename,
|
||||
byte[] content,
|
||||
String contentType
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package research.loghunter.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import research.loghunter.entity.Pattern;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class LogParserService {
|
||||
|
||||
/**
|
||||
* 로그 내용에서 패턴 매칭
|
||||
*/
|
||||
public List<MatchResult> parseAndMatch(String content, List<Pattern> patterns, String filePath) {
|
||||
List<MatchResult> results = new ArrayList<>();
|
||||
String[] lines = content.split("\n");
|
||||
|
||||
for (Pattern pattern : patterns) {
|
||||
if (!pattern.getActive()) continue;
|
||||
|
||||
try {
|
||||
java.util.regex.Pattern compiledPattern = java.util.regex.Pattern.compile(pattern.getRegex());
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
Matcher matcher = compiledPattern.matcher(lines[i]);
|
||||
|
||||
if (matcher.find()) {
|
||||
// 컨텍스트 추출
|
||||
String context = extractContext(lines, i, pattern.getContextLines());
|
||||
String summary = createSummary(lines[i]);
|
||||
|
||||
results.add(new MatchResult(
|
||||
pattern,
|
||||
filePath,
|
||||
i + 1, // 1-based line number
|
||||
summary,
|
||||
context,
|
||||
pattern.getSeverity()
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to match pattern {}: {}", pattern.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨텍스트 추출 (에러 전후 라인)
|
||||
*/
|
||||
private String extractContext(String[] lines, int matchIndex, int contextLines) {
|
||||
int start = Math.max(0, matchIndex - contextLines);
|
||||
int end = Math.min(lines.length - 1, matchIndex + contextLines);
|
||||
|
||||
StringBuilder context = new StringBuilder();
|
||||
for (int i = start; i <= end; i++) {
|
||||
if (i == matchIndex) {
|
||||
context.append(">>> "); // 에러 라인 표시
|
||||
}
|
||||
context.append(String.format("[%d] %s\n", i + 1, lines[i]));
|
||||
}
|
||||
return context.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 생성 (첫 줄, 최대 200자)
|
||||
*/
|
||||
private String createSummary(String line) {
|
||||
if (line == null) return "";
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.length() <= 200) return trimmed;
|
||||
return trimmed.substring(0, 200) + "...";
|
||||
}
|
||||
|
||||
// DTO
|
||||
public record MatchResult(
|
||||
Pattern pattern,
|
||||
String filePath,
|
||||
int lineNumber,
|
||||
String summary,
|
||||
String context,
|
||||
String severity
|
||||
) {}
|
||||
}
|
||||
126
src/main/java/research/loghunter/service/PatternService.java
Normal file
126
src/main/java/research/loghunter/service/PatternService.java
Normal file
@@ -0,0 +1,126 @@
|
||||
package research.loghunter.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import research.loghunter.dto.PatternDto;
|
||||
import research.loghunter.entity.Pattern;
|
||||
import research.loghunter.repository.PatternRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class PatternService {
|
||||
|
||||
private final PatternRepository patternRepository;
|
||||
|
||||
public List<PatternDto> findAll() {
|
||||
return patternRepository.findAll().stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<PatternDto> findAllActive() {
|
||||
return patternRepository.findByActiveTrueOrderBySeverityAscNameAsc().stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public PatternDto findById(Long id) {
|
||||
return patternRepository.findById(id)
|
||||
.map(this::toDto)
|
||||
.orElseThrow(() -> new RuntimeException("Pattern not found: " + id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PatternDto create(PatternDto dto) {
|
||||
validateRegex(dto.getRegex());
|
||||
|
||||
Pattern pattern = Pattern.builder()
|
||||
.name(dto.getName())
|
||||
.regex(dto.getRegex())
|
||||
.severity(dto.getSeverity() != null ? dto.getSeverity() : "ERROR")
|
||||
.contextLines(dto.getContextLines() != null ? dto.getContextLines() : 5)
|
||||
.description(dto.getDescription())
|
||||
.active(dto.getActive() != null ? dto.getActive() : true)
|
||||
.build();
|
||||
|
||||
pattern = patternRepository.save(pattern);
|
||||
return toDto(pattern);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PatternDto update(Long id, PatternDto dto) {
|
||||
validateRegex(dto.getRegex());
|
||||
|
||||
Pattern pattern = patternRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Pattern not found: " + id));
|
||||
|
||||
pattern.setName(dto.getName());
|
||||
pattern.setRegex(dto.getRegex());
|
||||
pattern.setSeverity(dto.getSeverity());
|
||||
pattern.setContextLines(dto.getContextLines());
|
||||
pattern.setDescription(dto.getDescription());
|
||||
pattern.setActive(dto.getActive());
|
||||
|
||||
return toDto(patternRepository.save(pattern));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
patternRepository.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규식 패턴 테스트
|
||||
*/
|
||||
public PatternTestResult testPattern(String regex, String sampleText) {
|
||||
try {
|
||||
java.util.regex.Pattern compiledPattern = java.util.regex.Pattern.compile(regex);
|
||||
java.util.regex.Matcher matcher = compiledPattern.matcher(sampleText);
|
||||
|
||||
boolean found = matcher.find();
|
||||
String matchedText = found ? matcher.group() : null;
|
||||
int matchStart = found ? matcher.start() : -1;
|
||||
int matchEnd = found ? matcher.end() : -1;
|
||||
|
||||
return new PatternTestResult(true, found, matchedText, matchStart, matchEnd, null);
|
||||
} catch (PatternSyntaxException e) {
|
||||
return new PatternTestResult(false, false, null, -1, -1, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRegex(String regex) {
|
||||
try {
|
||||
java.util.regex.Pattern.compile(regex);
|
||||
} catch (PatternSyntaxException e) {
|
||||
throw new RuntimeException("Invalid regex pattern: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private PatternDto toDto(Pattern pattern) {
|
||||
return PatternDto.builder()
|
||||
.id(pattern.getId())
|
||||
.name(pattern.getName())
|
||||
.regex(pattern.getRegex())
|
||||
.severity(pattern.getSeverity())
|
||||
.contextLines(pattern.getContextLines())
|
||||
.description(pattern.getDescription())
|
||||
.active(pattern.getActive())
|
||||
.createdAt(pattern.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
public record PatternTestResult(
|
||||
boolean validRegex,
|
||||
boolean matched,
|
||||
String matchedText,
|
||||
int matchStart,
|
||||
int matchEnd,
|
||||
String errorMessage
|
||||
) {}
|
||||
}
|
||||
453
src/main/java/research/loghunter/service/ScanService.java
Normal file
453
src/main/java/research/loghunter/service/ScanService.java
Normal file
@@ -0,0 +1,453 @@
|
||||
package research.loghunter.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import research.loghunter.entity.*;
|
||||
import research.loghunter.repository.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ScanService {
|
||||
|
||||
private final ServerRepository serverRepository;
|
||||
private final ServerLogPathRepository logPathRepository;
|
||||
private final PatternRepository patternRepository;
|
||||
private final ScanHistoryRepository scanHistoryRepository;
|
||||
private final ErrorLogRepository errorLogRepository;
|
||||
private final ScannedFileRepository scannedFileRepository;
|
||||
private final SftpService sftpService;
|
||||
private final LogParserService logParserService;
|
||||
|
||||
// 진행 상황 저장 (serverId -> ScanProgress)
|
||||
private final ConcurrentHashMap<Long, ScanProgress> progressMap = new ConcurrentHashMap<>();
|
||||
|
||||
// 로그 시간 파싱용 패턴들
|
||||
private static final List<DateTimeFormatter> DATE_FORMATTERS = List.of(
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss,SSS"), // 2026-01-06 09:57:01,114
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"), // 2026-01-06 09:57:01.114
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), // 2026-01-06 09:57:01
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"), // ISO format
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm:ss"), // 06-Jan-2026 09:57:01
|
||||
DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm:ss"), // Jan 06, 2026 09:57:01
|
||||
DateTimeFormatter.ofPattern("MMM dd HH:mm:ss") // Jan 06 09:57:01 (년도 없음)
|
||||
);
|
||||
|
||||
// 로그 라인에서 날짜 추출용 정규식
|
||||
private static final Pattern DATE_PATTERN = Pattern.compile(
|
||||
"(\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}[,.]?\\d{0,3})" +
|
||||
"|(\\d{2}-[A-Za-z]{3}-\\d{4} \\d{2}:\\d{2}:\\d{2})" +
|
||||
"|([A-Za-z]{3} \\d{2},? \\d{4} \\d{2}:\\d{2}:\\d{2})" +
|
||||
"|([A-Za-z]{3} \\d{2} \\d{2}:\\d{2}:\\d{2})"
|
||||
);
|
||||
|
||||
/**
|
||||
* 단일 서버 스캔
|
||||
*/
|
||||
@Transactional
|
||||
public ScanResult scanServer(Long serverId, Consumer<ScanProgress> progressCallback) {
|
||||
Server server = serverRepository.findById(serverId)
|
||||
.orElseThrow(() -> new RuntimeException("Server not found: " + serverId));
|
||||
|
||||
return executeScan(server, progressCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 활성 서버 스캔
|
||||
*/
|
||||
@Transactional
|
||||
public List<ScanResult> scanAllServers(Consumer<ScanProgress> progressCallback) {
|
||||
List<Server> servers = serverRepository.findByActiveTrue();
|
||||
return servers.stream()
|
||||
.map(server -> executeScan(server, progressCallback))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 스캔 실행
|
||||
*/
|
||||
private ScanResult executeScan(Server server, Consumer<ScanProgress> progressCallback) {
|
||||
ScanProgress progress = new ScanProgress(server.getId(), server.getName());
|
||||
progressMap.put(server.getId(), progress);
|
||||
|
||||
LocalDateTime scanStartTime = LocalDateTime.now();
|
||||
|
||||
// 스캔 이력 생성
|
||||
ScanHistory history = ScanHistory.builder()
|
||||
.server(server)
|
||||
.startedAt(scanStartTime)
|
||||
.status("RUNNING")
|
||||
.build();
|
||||
history = scanHistoryRepository.save(history);
|
||||
|
||||
try {
|
||||
// 1. 서버 시간 체크 (시차 확인)
|
||||
progress.setMessage("서버 시간 확인 중...");
|
||||
notifyProgress(progressCallback, progress);
|
||||
|
||||
int timeOffsetMinutes = checkAndUpdateServerTimeOffset(server);
|
||||
log.info("Server {} time offset: {} minutes", server.getId(), timeOffsetMinutes);
|
||||
|
||||
// 2. 활성 로그 경로 조회
|
||||
List<ServerLogPath> logPaths = logPathRepository.findByServerIdAndActiveTrue(server.getId());
|
||||
if (logPaths.isEmpty()) {
|
||||
throw new RuntimeException("등록된 로그 경로가 없습니다.");
|
||||
}
|
||||
|
||||
// 3. 활성 패턴 조회
|
||||
List<research.loghunter.entity.Pattern> patterns = patternRepository.findByActiveTrue();
|
||||
if (patterns.isEmpty()) {
|
||||
throw new RuntimeException("등록된 패턴이 없습니다.");
|
||||
}
|
||||
|
||||
progress.setTotalPaths(logPaths.size());
|
||||
notifyProgress(progressCallback, progress);
|
||||
|
||||
AtomicInteger totalFilesScanned = new AtomicInteger(0);
|
||||
AtomicInteger totalFilesSkipped = new AtomicInteger(0);
|
||||
AtomicInteger totalErrorsFound = new AtomicInteger(0);
|
||||
|
||||
for (ServerLogPath logPath : logPaths) {
|
||||
progress.setCurrentPath(logPath.getPath());
|
||||
progress.setMessage("파일 목록 조회 중...");
|
||||
notifyProgress(progressCallback, progress);
|
||||
|
||||
try {
|
||||
// 4. 해당 경로의 모든 파일 조회 (패턴 매칭)
|
||||
List<SftpService.RemoteFile> allFiles = sftpService.listAllFiles(server, logPath);
|
||||
|
||||
// 5. 스캔 대상 파일 필터링 (중복 제외)
|
||||
List<SftpService.RemoteFile> filesToScan = new ArrayList<>();
|
||||
for (SftpService.RemoteFile file : allFiles) {
|
||||
if (shouldScanFile(server.getId(), logPath.getId(), file)) {
|
||||
filesToScan.add(file);
|
||||
} else {
|
||||
totalFilesSkipped.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
progress.setTotalFiles(progress.getTotalFiles() + filesToScan.size());
|
||||
progress.setMessage(String.format("전체 %d개 중 %d개 스캔 대상 (기존 %d개 스킵)",
|
||||
allFiles.size(), filesToScan.size(), allFiles.size() - filesToScan.size()));
|
||||
notifyProgress(progressCallback, progress);
|
||||
|
||||
for (SftpService.RemoteFile file : filesToScan) {
|
||||
progress.setCurrentFile(file.name());
|
||||
notifyProgress(progressCallback, progress);
|
||||
|
||||
try {
|
||||
// 파일 내용 다운로드
|
||||
String content = sftpService.downloadFileContent(server, file.path());
|
||||
|
||||
// 패턴 매칭
|
||||
List<LogParserService.MatchResult> matches =
|
||||
logParserService.parseAndMatch(content, patterns, file.path());
|
||||
|
||||
// 에러 저장
|
||||
for (LogParserService.MatchResult match : matches) {
|
||||
// 로그에서 시간 파싱 (시차 보정 적용)
|
||||
LocalDateTime occurredAt = parseLogTime(
|
||||
match.context(),
|
||||
file.modifiedAt(),
|
||||
timeOffsetMinutes
|
||||
);
|
||||
|
||||
ErrorLog errorLog = ErrorLog.builder()
|
||||
.server(server)
|
||||
.pattern(match.pattern())
|
||||
.scanHistory(history)
|
||||
.filePath(match.filePath())
|
||||
.lineNumber(match.lineNumber())
|
||||
.summary(match.summary())
|
||||
.context(match.context())
|
||||
.severity(match.severity())
|
||||
.occurredAt(occurredAt) // 로그 내 시간 (보정됨)
|
||||
.scannedAt(scanStartTime) // 스캔 시작 시간
|
||||
.build();
|
||||
errorLogRepository.save(errorLog);
|
||||
totalErrorsFound.incrementAndGet();
|
||||
}
|
||||
|
||||
// 스캔 완료된 파일 기록
|
||||
saveScannedFile(server.getId(), logPath.getId(), file, matches.size());
|
||||
|
||||
totalFilesScanned.incrementAndGet();
|
||||
progress.setScannedFiles(totalFilesScanned.get());
|
||||
progress.setErrorsFound(totalErrorsFound.get());
|
||||
notifyProgress(progressCallback, progress);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to process file {}: {}", file.path(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to process log path {}: {}", logPath.getPath(), e.getMessage());
|
||||
}
|
||||
|
||||
progress.setScannedPaths(progress.getScannedPaths() + 1);
|
||||
notifyProgress(progressCallback, progress);
|
||||
}
|
||||
|
||||
// 스캔 완료
|
||||
history.setStatus("SUCCESS");
|
||||
history.setFinishedAt(LocalDateTime.now());
|
||||
history.setFilesScanned(totalFilesScanned.get());
|
||||
history.setErrorsFound(totalErrorsFound.get());
|
||||
scanHistoryRepository.save(history);
|
||||
|
||||
// 서버 마지막 스캔 시간 업데이트
|
||||
server.setLastScanAt(LocalDateTime.now());
|
||||
if (totalErrorsFound.get() > 0) {
|
||||
server.setLastErrorAt(LocalDateTime.now());
|
||||
}
|
||||
serverRepository.save(server);
|
||||
|
||||
progress.setStatus("SUCCESS");
|
||||
progress.setMessage(String.format("완료! (스캔: %d, 스킵: %d, 에러: %d)",
|
||||
totalFilesScanned.get(), totalFilesSkipped.get(), totalErrorsFound.get()));
|
||||
notifyProgress(progressCallback, progress);
|
||||
|
||||
return new ScanResult(server.getId(), server.getName(), true,
|
||||
totalFilesScanned.get(), totalErrorsFound.get(), null);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Scan failed for server {}: {}", server.getId(), e.getMessage());
|
||||
|
||||
history.setStatus("FAILED");
|
||||
history.setFinishedAt(LocalDateTime.now());
|
||||
history.setErrorMessage(e.getMessage());
|
||||
scanHistoryRepository.save(history);
|
||||
|
||||
progress.setStatus("FAILED");
|
||||
progress.setMessage("스캔 실패: " + e.getMessage());
|
||||
notifyProgress(progressCallback, progress);
|
||||
|
||||
return new ScanResult(server.getId(), server.getName(), false, 0, 0, e.getMessage());
|
||||
} finally {
|
||||
progressMap.remove(server.getId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 시간 오프셋 확인 및 업데이트
|
||||
*/
|
||||
private int checkAndUpdateServerTimeOffset(Server server) {
|
||||
try {
|
||||
SftpService.TimeCheckResult result = sftpService.checkServerTime(server);
|
||||
|
||||
if (result.success()) {
|
||||
server.setTimeOffsetMinutes(result.offsetMinutes());
|
||||
server.setLastTimeSyncAt(LocalDateTime.now());
|
||||
serverRepository.save(server);
|
||||
|
||||
log.info("Server {} time sync - Server: {}, Local: {}, Offset: {} min",
|
||||
server.getName(), result.serverTime(), result.localTime(), result.offsetMinutes());
|
||||
|
||||
return result.offsetMinutes();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to check server time for {}: {}", server.getId(), e.getMessage());
|
||||
}
|
||||
|
||||
// 실패 시 기존 값 사용 (없으면 0)
|
||||
return server.getTimeOffsetMinutes() != null ? server.getTimeOffsetMinutes() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일을 스캔해야 하는지 확인 (중복 체크)
|
||||
* - 파일경로 + 크기가 동일하면 스킵
|
||||
* - 파일경로는 같은데 크기가 다르면 재스캔
|
||||
*/
|
||||
private boolean shouldScanFile(Long serverId, Long logPathId, SftpService.RemoteFile file) {
|
||||
Optional<ScannedFile> existing = scannedFileRepository.findByServerIdAndFilePathAndFileSize(
|
||||
serverId, file.path(), file.size());
|
||||
|
||||
if (existing.isPresent()) {
|
||||
log.debug("Skipping already scanned file: {} (size: {})", file.path(), file.size());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 파일경로는 같은데 크기가 다른 경우 확인 (변경된 파일)
|
||||
List<ScannedFile> previousVersions = scannedFileRepository.findByServerIdAndFilePath(serverId, file.path());
|
||||
if (!previousVersions.isEmpty()) {
|
||||
log.info("File size changed, will rescan: {} (old: {}, new: {})",
|
||||
file.path(), previousVersions.get(0).getFileSize(), file.size());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스캔 완료된 파일 정보 저장
|
||||
*/
|
||||
private void saveScannedFile(Long serverId, Long logPathId, SftpService.RemoteFile file, int errorCount) {
|
||||
ScannedFile scannedFile = new ScannedFile();
|
||||
scannedFile.setServerId(serverId);
|
||||
scannedFile.setLogPathId(logPathId);
|
||||
scannedFile.setFilePath(file.path());
|
||||
scannedFile.setFileName(file.name());
|
||||
scannedFile.setFileSize(file.size());
|
||||
scannedFile.setScannedAt(LocalDateTime.now());
|
||||
scannedFile.setErrorCount(errorCount);
|
||||
|
||||
scannedFileRepository.save(scannedFile);
|
||||
log.debug("Saved scanned file record: {} (size: {}, errors: {})", file.path(), file.size(), errorCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 내용에서 시간 파싱 (시차 보정 적용)
|
||||
*/
|
||||
private LocalDateTime parseLogTime(String logContent, LocalDateTime fallback, int offsetMinutes) {
|
||||
if (logContent == null || logContent.isEmpty()) {
|
||||
return applyOffset(fallback, offsetMinutes);
|
||||
}
|
||||
|
||||
// 첫 줄에서 시간 추출 시도
|
||||
String[] lines = logContent.split("\n");
|
||||
for (String line : lines) {
|
||||
LocalDateTime parsed = extractDateTime(line);
|
||||
if (parsed != null) {
|
||||
return applyOffset(parsed, offsetMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
return applyOffset(fallback, offsetMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열에서 날짜/시간 추출
|
||||
*/
|
||||
private LocalDateTime extractDateTime(String text) {
|
||||
Matcher matcher = DATE_PATTERN.matcher(text);
|
||||
if (!matcher.find()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String dateStr = matcher.group();
|
||||
|
||||
for (DateTimeFormatter formatter : DATE_FORMATTERS) {
|
||||
try {
|
||||
return LocalDateTime.parse(dateStr, formatter);
|
||||
} catch (DateTimeParseException e) {
|
||||
// 다음 포맷 시도
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간에 오프셋 적용
|
||||
* offset > 0: 로컬이 서버보다 앞섬 (로그시간 + offset = 로컬시간)
|
||||
*/
|
||||
private LocalDateTime applyOffset(LocalDateTime time, int offsetMinutes) {
|
||||
if (time == null || offsetMinutes == 0) {
|
||||
return time;
|
||||
}
|
||||
return time.plusMinutes(offsetMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 진행 상황 조회
|
||||
*/
|
||||
public ScanProgress getProgress(Long serverId) {
|
||||
return progressMap.get(serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스캔 이력 조회
|
||||
*/
|
||||
public List<ScanHistory> getHistory(Long serverId) {
|
||||
return scanHistoryRepository.findByServerIdOrderByStartedAtDesc(serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스캔된 파일 목록 조회 (서버별)
|
||||
*/
|
||||
public List<ScannedFile> getScannedFiles(Long serverId) {
|
||||
return scannedFileRepository.findByServerId(serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스캔된 파일 기록 초기화 (재스캔 허용)
|
||||
*/
|
||||
@Transactional
|
||||
public void resetScannedFiles(Long serverId) {
|
||||
List<ScannedFile> files = scannedFileRepository.findByServerId(serverId);
|
||||
scannedFileRepository.deleteAll(files);
|
||||
log.info("Reset {} scanned file records for server {}", files.size(), serverId);
|
||||
}
|
||||
|
||||
private void notifyProgress(Consumer<ScanProgress> callback, ScanProgress progress) {
|
||||
if (callback != null) {
|
||||
callback.accept(progress);
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public static class ScanProgress {
|
||||
private Long serverId;
|
||||
private String serverName;
|
||||
private String status = "RUNNING";
|
||||
private String message;
|
||||
private String currentPath;
|
||||
private String currentFile;
|
||||
private int totalPaths;
|
||||
private int scannedPaths;
|
||||
private int totalFiles;
|
||||
private int scannedFiles;
|
||||
private int errorsFound;
|
||||
|
||||
public ScanProgress(Long serverId, String serverName) {
|
||||
this.serverId = serverId;
|
||||
this.serverName = serverName;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public Long getServerId() { return serverId; }
|
||||
public String getServerName() { return serverName; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public String getCurrentPath() { return currentPath; }
|
||||
public void setCurrentPath(String currentPath) { this.currentPath = currentPath; }
|
||||
public String getCurrentFile() { return currentFile; }
|
||||
public void setCurrentFile(String currentFile) { this.currentFile = currentFile; }
|
||||
public int getTotalPaths() { return totalPaths; }
|
||||
public void setTotalPaths(int totalPaths) { this.totalPaths = totalPaths; }
|
||||
public int getScannedPaths() { return scannedPaths; }
|
||||
public void setScannedPaths(int scannedPaths) { this.scannedPaths = scannedPaths; }
|
||||
public int getTotalFiles() { return totalFiles; }
|
||||
public void setTotalFiles(int totalFiles) { this.totalFiles = totalFiles; }
|
||||
public int getScannedFiles() { return scannedFiles; }
|
||||
public void setScannedFiles(int scannedFiles) { this.scannedFiles = scannedFiles; }
|
||||
public int getErrorsFound() { return errorsFound; }
|
||||
public void setErrorsFound(int errorsFound) { this.errorsFound = errorsFound; }
|
||||
}
|
||||
|
||||
public record ScanResult(
|
||||
Long serverId,
|
||||
String serverName,
|
||||
boolean success,
|
||||
int filesScanned,
|
||||
int errorsFound,
|
||||
String error
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package research.loghunter.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import research.loghunter.dto.ServerLogPathDto;
|
||||
import research.loghunter.entity.Server;
|
||||
import research.loghunter.entity.ServerLogPath;
|
||||
import research.loghunter.repository.ServerLogPathRepository;
|
||||
import research.loghunter.repository.ServerRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ServerLogPathService {
|
||||
|
||||
private final ServerLogPathRepository logPathRepository;
|
||||
private final ServerRepository serverRepository;
|
||||
|
||||
public List<ServerLogPathDto> findByServerId(Long serverId) {
|
||||
return logPathRepository.findByServerId(serverId).stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<ServerLogPathDto> findActiveByServerId(Long serverId) {
|
||||
return logPathRepository.findByServerIdAndActiveTrue(serverId).stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public ServerLogPathDto findById(Long id) {
|
||||
return logPathRepository.findById(id)
|
||||
.map(this::toDto)
|
||||
.orElseThrow(() -> new RuntimeException("LogPath not found: " + id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ServerLogPathDto create(ServerLogPathDto dto) {
|
||||
Server server = serverRepository.findById(dto.getServerId())
|
||||
.orElseThrow(() -> new RuntimeException("Server not found: " + dto.getServerId()));
|
||||
|
||||
ServerLogPath logPath = ServerLogPath.builder()
|
||||
.server(server)
|
||||
.path(dto.getPath())
|
||||
.filePattern(dto.getFilePattern())
|
||||
.description(dto.getDescription())
|
||||
.active(dto.getActive() != null ? dto.getActive() : true)
|
||||
.build();
|
||||
|
||||
logPath = logPathRepository.save(logPath);
|
||||
return toDto(logPath);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ServerLogPathDto update(Long id, ServerLogPathDto dto) {
|
||||
ServerLogPath logPath = logPathRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("LogPath not found: " + id));
|
||||
|
||||
logPath.setPath(dto.getPath());
|
||||
logPath.setFilePattern(dto.getFilePattern());
|
||||
logPath.setDescription(dto.getDescription());
|
||||
logPath.setActive(dto.getActive());
|
||||
|
||||
return toDto(logPathRepository.save(logPath));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
logPathRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private ServerLogPathDto toDto(ServerLogPath logPath) {
|
||||
return ServerLogPathDto.builder()
|
||||
.id(logPath.getId())
|
||||
.serverId(logPath.getServer().getId())
|
||||
.path(logPath.getPath())
|
||||
.filePattern(logPath.getFilePattern())
|
||||
.description(logPath.getDescription())
|
||||
.active(logPath.getActive())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
162
src/main/java/research/loghunter/service/ServerService.java
Normal file
162
src/main/java/research/loghunter/service/ServerService.java
Normal file
@@ -0,0 +1,162 @@
|
||||
package research.loghunter.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import research.loghunter.dto.ServerDto;
|
||||
import research.loghunter.dto.ServerLogPathDto;
|
||||
import research.loghunter.entity.Server;
|
||||
import research.loghunter.entity.ServerLogPath;
|
||||
import research.loghunter.repository.ServerRepository;
|
||||
import research.loghunter.util.CryptoUtil;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ServerService {
|
||||
|
||||
private final ServerRepository serverRepository;
|
||||
private final CryptoUtil cryptoUtil;
|
||||
|
||||
public List<ServerDto> findAll() {
|
||||
return serverRepository.findAll().stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<ServerDto> findAllActive() {
|
||||
return serverRepository.findByActiveTrueOrderByNameAsc().stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public ServerDto findById(Long id) {
|
||||
return serverRepository.findById(id)
|
||||
.map(this::toDto)
|
||||
.orElseThrow(() -> new RuntimeException("Server not found: " + id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ServerDto create(ServerDto dto) {
|
||||
Server server = toEntity(dto);
|
||||
server = serverRepository.save(server);
|
||||
return toDto(server);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ServerDto update(Long id, ServerDto dto) {
|
||||
Server server = serverRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Server not found: " + id));
|
||||
|
||||
server.setName(dto.getName());
|
||||
server.setHost(dto.getHost());
|
||||
server.setPort(dto.getPort());
|
||||
server.setUsername(dto.getUsername());
|
||||
server.setAuthType(dto.getAuthType());
|
||||
server.setActive(dto.getActive());
|
||||
|
||||
if ("PASSWORD".equals(dto.getAuthType())) {
|
||||
if (dto.getPassword() != null && !dto.getPassword().isEmpty()) {
|
||||
server.setEncryptedPassword(cryptoUtil.encrypt(dto.getPassword()));
|
||||
}
|
||||
server.setKeyFilePath(null);
|
||||
server.setEncryptedPassphrase(null);
|
||||
} else if ("KEY_FILE".equals(dto.getAuthType())) {
|
||||
server.setKeyFilePath(dto.getKeyFilePath());
|
||||
if (dto.getPassphrase() != null && !dto.getPassphrase().isEmpty()) {
|
||||
server.setEncryptedPassphrase(cryptoUtil.encrypt(dto.getPassphrase()));
|
||||
}
|
||||
server.setEncryptedPassword(null);
|
||||
}
|
||||
|
||||
return toDto(serverRepository.save(server));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
serverRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateLastScanAt(Long id) {
|
||||
serverRepository.findById(id).ifPresent(server -> {
|
||||
server.setLastScanAt(java.time.LocalDateTime.now());
|
||||
serverRepository.save(server);
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateLastErrorAt(Long id) {
|
||||
serverRepository.findById(id).ifPresent(server -> {
|
||||
server.setLastErrorAt(java.time.LocalDateTime.now());
|
||||
serverRepository.save(server);
|
||||
});
|
||||
}
|
||||
|
||||
public String getDecryptedPassword(Long id) {
|
||||
Server server = serverRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Server not found: " + id));
|
||||
return cryptoUtil.decrypt(server.getEncryptedPassword());
|
||||
}
|
||||
|
||||
public String getDecryptedPassphrase(Long id) {
|
||||
Server server = serverRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Server not found: " + id));
|
||||
return cryptoUtil.decrypt(server.getEncryptedPassphrase());
|
||||
}
|
||||
|
||||
private ServerDto toDto(Server server) {
|
||||
return ServerDto.builder()
|
||||
.id(server.getId())
|
||||
.name(server.getName())
|
||||
.host(server.getHost())
|
||||
.port(server.getPort())
|
||||
.username(server.getUsername())
|
||||
.authType(server.getAuthType())
|
||||
.keyFilePath(server.getKeyFilePath())
|
||||
.active(server.getActive())
|
||||
.lastScanAt(server.getLastScanAt())
|
||||
.lastErrorAt(server.getLastErrorAt())
|
||||
.createdAt(server.getCreatedAt())
|
||||
.logPaths(server.getLogPaths().stream()
|
||||
.map(this::toLogPathDto)
|
||||
.collect(Collectors.toList()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private ServerLogPathDto toLogPathDto(ServerLogPath logPath) {
|
||||
return ServerLogPathDto.builder()
|
||||
.id(logPath.getId())
|
||||
.serverId(logPath.getServer().getId())
|
||||
.path(logPath.getPath())
|
||||
.filePattern(logPath.getFilePattern())
|
||||
.description(logPath.getDescription())
|
||||
.active(logPath.getActive())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Server toEntity(ServerDto dto) {
|
||||
Server server = Server.builder()
|
||||
.name(dto.getName())
|
||||
.host(dto.getHost())
|
||||
.port(dto.getPort() != null ? dto.getPort() : 22)
|
||||
.username(dto.getUsername())
|
||||
.authType(dto.getAuthType())
|
||||
.active(dto.getActive() != null ? dto.getActive() : true)
|
||||
.build();
|
||||
|
||||
if ("PASSWORD".equals(dto.getAuthType()) && dto.getPassword() != null) {
|
||||
server.setEncryptedPassword(cryptoUtil.encrypt(dto.getPassword()));
|
||||
} else if ("KEY_FILE".equals(dto.getAuthType())) {
|
||||
server.setKeyFilePath(dto.getKeyFilePath());
|
||||
if (dto.getPassphrase() != null) {
|
||||
server.setEncryptedPassphrase(cryptoUtil.encrypt(dto.getPassphrase()));
|
||||
}
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
}
|
||||
116
src/main/java/research/loghunter/service/SettingService.java
Normal file
116
src/main/java/research/loghunter/service/SettingService.java
Normal file
@@ -0,0 +1,116 @@
|
||||
package research.loghunter.service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import research.loghunter.dto.SettingDto;
|
||||
import research.loghunter.entity.Setting;
|
||||
import research.loghunter.repository.SettingRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class SettingService {
|
||||
|
||||
private final SettingRepository settingRepository;
|
||||
|
||||
// 기본 설정 키
|
||||
public static final String KEY_EXPORT_PATH = "export.path";
|
||||
public static final String KEY_RETENTION_DAYS = "retention.days";
|
||||
public static final String KEY_SERVER_PORT = "server.port";
|
||||
|
||||
@PostConstruct
|
||||
@Transactional
|
||||
public void initDefaultSettings() {
|
||||
createIfNotExists(KEY_EXPORT_PATH, "./exports", "HTML/TXT 내보내기 경로");
|
||||
createIfNotExists(KEY_RETENTION_DAYS, "30", "로그 보관 기간 (일)");
|
||||
createIfNotExists(KEY_SERVER_PORT, "8080", "웹 서버 포트");
|
||||
}
|
||||
|
||||
public List<SettingDto> findAll() {
|
||||
return settingRepository.findAll().stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Map<String, String> findAllAsMap() {
|
||||
return settingRepository.findAll().stream()
|
||||
.collect(Collectors.toMap(Setting::getKey, Setting::getValue));
|
||||
}
|
||||
|
||||
public String getValue(String key) {
|
||||
return settingRepository.findById(key)
|
||||
.map(Setting::getValue)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public String getValue(String key, String defaultValue) {
|
||||
return settingRepository.findById(key)
|
||||
.map(Setting::getValue)
|
||||
.orElse(defaultValue);
|
||||
}
|
||||
|
||||
public Integer getIntValue(String key, Integer defaultValue) {
|
||||
String value = getValue(key);
|
||||
if (value == null) return defaultValue;
|
||||
try {
|
||||
return Integer.parseInt(value);
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SettingDto save(SettingDto dto) {
|
||||
Setting setting = settingRepository.findById(dto.getKey())
|
||||
.orElse(new Setting());
|
||||
|
||||
setting.setKey(dto.getKey());
|
||||
setting.setValue(dto.getValue());
|
||||
if (dto.getDescription() != null) {
|
||||
setting.setDescription(dto.getDescription());
|
||||
}
|
||||
|
||||
return toDto(settingRepository.save(setting));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void saveAll(Map<String, String> settings) {
|
||||
settings.forEach((key, value) -> {
|
||||
Setting setting = settingRepository.findById(key)
|
||||
.orElse(new Setting());
|
||||
setting.setKey(key);
|
||||
setting.setValue(value);
|
||||
settingRepository.save(setting);
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(String key) {
|
||||
settingRepository.deleteById(key);
|
||||
}
|
||||
|
||||
private void createIfNotExists(String key, String defaultValue, String description) {
|
||||
if (!settingRepository.existsById(key)) {
|
||||
Setting setting = Setting.builder()
|
||||
.key(key)
|
||||
.value(defaultValue)
|
||||
.description(description)
|
||||
.build();
|
||||
settingRepository.save(setting);
|
||||
}
|
||||
}
|
||||
|
||||
private SettingDto toDto(Setting setting) {
|
||||
return SettingDto.builder()
|
||||
.key(setting.getKey())
|
||||
.value(setting.getValue())
|
||||
.description(setting.getDescription())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
403
src/main/java/research/loghunter/service/SftpService.java
Normal file
403
src/main/java/research/loghunter/service/SftpService.java
Normal file
@@ -0,0 +1,403 @@
|
||||
package research.loghunter.service;
|
||||
|
||||
import com.jcraft.jsch.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import research.loghunter.entity.Server;
|
||||
import research.loghunter.entity.ServerLogPath;
|
||||
import research.loghunter.repository.ServerRepository;
|
||||
import research.loghunter.util.CryptoUtil;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SftpService {
|
||||
|
||||
private final ServerRepository serverRepository;
|
||||
private final CryptoUtil cryptoUtil;
|
||||
|
||||
private static final int CONNECT_TIMEOUT = 10000; // 10초
|
||||
private static final int CHANNEL_TIMEOUT = 30000; // 30초
|
||||
|
||||
/**
|
||||
* SFTP 연결 테스트
|
||||
*/
|
||||
public ConnectionTestResult testConnection(Long serverId) {
|
||||
Server server = serverRepository.findById(serverId)
|
||||
.orElseThrow(() -> new RuntimeException("Server not found: " + serverId));
|
||||
|
||||
Session session = null;
|
||||
ChannelSftp channel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
|
||||
log.info("Connecting to {}:{} as user '{}' with auth type '{}'",
|
||||
server.getHost(), server.getPort(), server.getUsername(), server.getAuthType());
|
||||
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
|
||||
channel = (ChannelSftp) session.openChannel("sftp");
|
||||
channel.connect(CHANNEL_TIMEOUT);
|
||||
|
||||
// 홈 디렉토리 확인
|
||||
String home = channel.pwd();
|
||||
|
||||
String detail = String.format("연결 성공!\n- 호스트: %s:%d\n- 사용자: %s\n- 인증방식: %s\n- 홈 디렉토리: %s",
|
||||
server.getHost(), server.getPort(), server.getUsername(),
|
||||
"PASSWORD".equals(server.getAuthType()) ? "비밀번호" : "키 파일",
|
||||
home);
|
||||
|
||||
return new ConnectionTestResult(true, detail, null);
|
||||
} catch (JSchException e) {
|
||||
log.error("SFTP connection test failed for server {}: {}", serverId, e.getMessage(), e);
|
||||
String errorDetail = analyzeJSchException(e, server);
|
||||
return new ConnectionTestResult(false, null, errorDetail);
|
||||
} catch (SftpException e) {
|
||||
log.error("SFTP operation failed for server {}: {}", serverId, e.getMessage());
|
||||
return new ConnectionTestResult(false, null, "SFTP 오류: " + e.getMessage());
|
||||
} finally {
|
||||
disconnect(channel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 시간 조회 및 로컬 시간과의 차이 계산 (분 단위)
|
||||
* 반환값: 로그시간 + offset = 로컬시간
|
||||
*/
|
||||
public TimeCheckResult checkServerTime(Long serverId) {
|
||||
Server server = serverRepository.findById(serverId)
|
||||
.orElseThrow(() -> new RuntimeException("Server not found: " + serverId));
|
||||
|
||||
return checkServerTime(server);
|
||||
}
|
||||
|
||||
public TimeCheckResult checkServerTime(Server server) {
|
||||
Session session = null;
|
||||
ChannelExec channel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
|
||||
// 서버 시간 조회 (epoch seconds)
|
||||
channel = (ChannelExec) session.openChannel("exec");
|
||||
channel.setCommand("date +%s");
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
channel.setOutputStream(outputStream);
|
||||
channel.connect(CHANNEL_TIMEOUT);
|
||||
|
||||
// 명령 실행 대기
|
||||
while (!channel.isClosed()) {
|
||||
Thread.sleep(100);
|
||||
}
|
||||
|
||||
String serverTimeStr = outputStream.toString().trim();
|
||||
long serverEpoch = Long.parseLong(serverTimeStr);
|
||||
long localEpoch = Instant.now().getEpochSecond();
|
||||
|
||||
// 분 단위 차이 계산 (로컬 - 서버)
|
||||
int offsetMinutes = (int) ((localEpoch - serverEpoch) / 60);
|
||||
|
||||
LocalDateTime serverTime = LocalDateTime.ofInstant(
|
||||
Instant.ofEpochSecond(serverEpoch), ZoneId.systemDefault());
|
||||
LocalDateTime localTime = LocalDateTime.now();
|
||||
|
||||
log.info("Server {} time check - Server: {}, Local: {}, Offset: {} minutes",
|
||||
server.getId(), serverTime, localTime, offsetMinutes);
|
||||
|
||||
return new TimeCheckResult(true, serverTime, localTime, offsetMinutes, null);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to check server time for {}: {}", server.getId(), e.getMessage());
|
||||
return new TimeCheckResult(false, null, LocalDateTime.now(), 0, e.getMessage());
|
||||
} finally {
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSchException 분석하여 상세 에러 메시지 생성
|
||||
*/
|
||||
private String analyzeJSchException(JSchException e, Server server) {
|
||||
String msg = e.getMessage();
|
||||
StringBuilder detail = new StringBuilder();
|
||||
|
||||
detail.append(String.format("대상 서버: %s:%d\n", server.getHost(), server.getPort()));
|
||||
detail.append(String.format("사용자: %s\n", server.getUsername()));
|
||||
detail.append(String.format("인증방식: %s\n\n",
|
||||
"PASSWORD".equals(server.getAuthType()) ? "비밀번호" : "키 파일"));
|
||||
|
||||
if (msg.contains("Auth fail") || msg.contains("Auth cancel")) {
|
||||
detail.append("❌ 인증 실패\n\n");
|
||||
detail.append("✔ IP/포트 연결은 성공했습니다.\n\n");
|
||||
detail.append("확인 사항:\n");
|
||||
if ("PASSWORD".equals(server.getAuthType())) {
|
||||
detail.append("- 사용자명이 정확한가요?\n");
|
||||
detail.append("- 비밀번호가 정확한가요?\n");
|
||||
detail.append("- 서버에서 비밀번호 인증이 허용되어 있나요?");
|
||||
} else {
|
||||
detail.append("- 키 파일 경로가 정확한가요? (").append(server.getKeyFilePath()).append(")\n");
|
||||
detail.append("- 키 파일이 존재하나요?\n");
|
||||
detail.append("- Passphrase가 필요한 경우 입력했나요?\n");
|
||||
detail.append("- 서버에 공개키가 등록되어 있나요?");
|
||||
}
|
||||
} else if (msg.contains("Connection refused")) {
|
||||
detail.append("❌ 연결 거부\n\n");
|
||||
detail.append("확인 사항:\n");
|
||||
detail.append("- 포트 번호가 정확한가요? (현재: ").append(server.getPort()).append(")\n");
|
||||
detail.append("- 서버에서 SSH 서비스가 실행 중인가요?\n");
|
||||
detail.append("- 방화벽에서 해당 포트가 열려 있나요?");
|
||||
} else if (msg.contains("UnknownHost") || msg.contains("No route to host")) {
|
||||
detail.append("❌ 호스트 연결 불가\n\n");
|
||||
detail.append("확인 사항:\n");
|
||||
detail.append("- IP 주소가 정확한가요? (현재: ").append(server.getHost()).append(")\n");
|
||||
detail.append("- 서버가 실행 중인가요?\n");
|
||||
detail.append("- 네트워크 연결이 가능한가요?");
|
||||
} else if (msg.contains("timeout") || msg.contains("timed out")) {
|
||||
detail.append("❌ 연결 시간 초과\n\n");
|
||||
detail.append("확인 사항:\n");
|
||||
detail.append("- IP 주소가 정확한가요?\n");
|
||||
detail.append("- 방화벽에서 차단되어 있지 않나요?\n");
|
||||
detail.append("- 서버가 응답하는지 확인해주세요.");
|
||||
} else if (msg.contains("invalid privatekey")) {
|
||||
detail.append("❌ 키 파일 오류\n\n");
|
||||
detail.append("확인 사항:\n");
|
||||
detail.append("- 키 파일 형식이 올바른가요? (OpenSSH 형식 필요)\n");
|
||||
detail.append("- 키 파일이 손상되지 않았나요?");
|
||||
} else {
|
||||
detail.append("❌ 연결 실패\n\n");
|
||||
detail.append("에러 메시지: ").append(msg);
|
||||
}
|
||||
|
||||
return detail.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 경로의 모든 파일 목록 조회 (패턴 매칭 포함)
|
||||
*/
|
||||
public List<RemoteFile> listAllFiles(Server server, ServerLogPath logPath) {
|
||||
List<RemoteFile> files = new ArrayList<>();
|
||||
Session session = null;
|
||||
ChannelSftp channel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
|
||||
channel = (ChannelSftp) session.openChannel("sftp");
|
||||
channel.connect(CHANNEL_TIMEOUT);
|
||||
|
||||
String path = logPath.getPath();
|
||||
String filePattern = logPath.getFilePattern();
|
||||
Pattern pattern = convertGlobToRegex(filePattern);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Vector<ChannelSftp.LsEntry> entries = channel.ls(path);
|
||||
|
||||
for (ChannelSftp.LsEntry entry : entries) {
|
||||
if (entry.getAttrs().isDir()) continue;
|
||||
|
||||
String fileName = entry.getFilename();
|
||||
if (!pattern.matcher(fileName).matches()) continue;
|
||||
|
||||
long mtime = entry.getAttrs().getMTime() * 1000L;
|
||||
LocalDateTime fileTime = LocalDateTime.ofInstant(
|
||||
Instant.ofEpochMilli(mtime), ZoneId.systemDefault());
|
||||
|
||||
files.add(new RemoteFile(
|
||||
path + (path.endsWith("/") ? "" : "/") + fileName,
|
||||
fileName,
|
||||
entry.getAttrs().getSize(),
|
||||
fileTime
|
||||
));
|
||||
}
|
||||
|
||||
// 최신 파일 순 정렬
|
||||
files.sort((a, b) -> b.modifiedAt().compareTo(a.modifiedAt()));
|
||||
|
||||
log.info("Found {} files matching pattern '{}' in path '{}' on server {}",
|
||||
files.size(), filePattern, path, server.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to list files from server {}: {}", server.getId(), e.getMessage());
|
||||
throw new RuntimeException("파일 목록 조회 실패: " + e.getMessage(), e);
|
||||
} finally {
|
||||
disconnect(channel, session);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 파일 목록 조회 (since 이후 수정된 파일만)
|
||||
*/
|
||||
public List<RemoteFile> listLogFiles(Server server, ServerLogPath logPath, LocalDateTime since) {
|
||||
List<RemoteFile> allFiles = listAllFiles(server, logPath);
|
||||
|
||||
if (since == null) {
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
return allFiles.stream()
|
||||
.filter(f -> f.modifiedAt().isAfter(since))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 다운로드 (내용 반환)
|
||||
*/
|
||||
public String downloadFileContent(Server server, String remotePath) {
|
||||
Session session = null;
|
||||
ChannelSftp channel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
|
||||
channel = (ChannelSftp) session.openChannel("sftp");
|
||||
channel.connect(CHANNEL_TIMEOUT);
|
||||
|
||||
try (InputStream is = channel.get(remotePath);
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
|
||||
|
||||
StringBuilder content = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
content.append(line).append("\n");
|
||||
}
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to download file {} from server {}: {}",
|
||||
remotePath, server.getId(), e.getMessage());
|
||||
throw new RuntimeException("파일 다운로드 실패: " + e.getMessage(), e);
|
||||
} finally {
|
||||
disconnect(channel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 다운로드 (로컬 파일로)
|
||||
*/
|
||||
public Path downloadFileToLocal(Server server, String remotePath, Path localDir) {
|
||||
Session session = null;
|
||||
ChannelSftp channel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
|
||||
channel = (ChannelSftp) session.openChannel("sftp");
|
||||
channel.connect(CHANNEL_TIMEOUT);
|
||||
|
||||
String fileName = remotePath.substring(remotePath.lastIndexOf('/') + 1);
|
||||
Path localPath = localDir.resolve(fileName);
|
||||
|
||||
Files.createDirectories(localDir);
|
||||
|
||||
try (OutputStream os = Files.newOutputStream(localPath)) {
|
||||
channel.get(remotePath, os);
|
||||
}
|
||||
|
||||
return localPath;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to download file {} from server {}: {}",
|
||||
remotePath, server.getId(), e.getMessage());
|
||||
throw new RuntimeException("파일 다운로드 실패: " + e.getMessage(), e);
|
||||
} finally {
|
||||
disconnect(channel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 생성
|
||||
*/
|
||||
private Session createSession(Server server) throws JSchException {
|
||||
JSch jsch = new JSch();
|
||||
|
||||
if ("KEY_FILE".equals(server.getAuthType())) {
|
||||
String passphrase = cryptoUtil.decrypt(server.getEncryptedPassphrase());
|
||||
if (passphrase != null && !passphrase.isEmpty()) {
|
||||
jsch.addIdentity(server.getKeyFilePath(), passphrase);
|
||||
} else {
|
||||
jsch.addIdentity(server.getKeyFilePath());
|
||||
}
|
||||
}
|
||||
|
||||
Session session = jsch.getSession(server.getUsername(), server.getHost(), server.getPort());
|
||||
|
||||
if ("PASSWORD".equals(server.getAuthType())) {
|
||||
String password = cryptoUtil.decrypt(server.getEncryptedPassword());
|
||||
session.setPassword(password);
|
||||
}
|
||||
|
||||
// StrictHostKeyChecking 비활성화 (실제 운영에서는 known_hosts 사용 권장)
|
||||
Properties config = new Properties();
|
||||
config.put("StrictHostKeyChecking", "no");
|
||||
session.setConfig(config);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 해제
|
||||
*/
|
||||
private void disconnect(ChannelSftp channel, Session session) {
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob 패턴을 정규식으로 변환
|
||||
*/
|
||||
private Pattern convertGlobToRegex(String glob) {
|
||||
StringBuilder regex = new StringBuilder("^");
|
||||
for (char c : glob.toCharArray()) {
|
||||
switch (c) {
|
||||
case '*': regex.append(".*"); break;
|
||||
case '?': regex.append("."); break;
|
||||
case '.': regex.append("\\."); break;
|
||||
default: regex.append(c);
|
||||
}
|
||||
}
|
||||
regex.append("$");
|
||||
return Pattern.compile(regex.toString());
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public record ConnectionTestResult(boolean success, String message, String error) {}
|
||||
|
||||
public record TimeCheckResult(
|
||||
boolean success,
|
||||
LocalDateTime serverTime,
|
||||
LocalDateTime localTime,
|
||||
int offsetMinutes,
|
||||
String error
|
||||
) {}
|
||||
|
||||
public record RemoteFile(String path, String name, long size, LocalDateTime modifiedAt) {}
|
||||
}
|
||||
86
src/main/java/research/loghunter/util/CryptoUtil.java
Normal file
86
src/main/java/research/loghunter/util/CryptoUtil.java
Normal file
@@ -0,0 +1,86 @@
|
||||
package research.loghunter.util;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
|
||||
@Component
|
||||
public class CryptoUtil {
|
||||
|
||||
private static final String ALGORITHM = "AES";
|
||||
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
|
||||
@Value("${app.crypto.key}")
|
||||
private String secretKey;
|
||||
|
||||
public String encrypt(String plainText) {
|
||||
if (plainText == null || plainText.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] keyBytes = getKeyBytes();
|
||||
SecretKeySpec key = new SecretKeySpec(keyBytes, ALGORITHM);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
new SecureRandom().nextBytes(iv);
|
||||
|
||||
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, parameterSpec);
|
||||
|
||||
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// IV + 암호문 합치기
|
||||
byte[] combined = new byte[iv.length + encrypted.length];
|
||||
System.arraycopy(iv, 0, combined, 0, iv.length);
|
||||
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
|
||||
|
||||
return Base64.getEncoder().encodeToString(combined);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Encryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String encryptedText) {
|
||||
if (encryptedText == null || encryptedText.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] keyBytes = getKeyBytes();
|
||||
SecretKeySpec key = new SecretKeySpec(keyBytes, ALGORITHM);
|
||||
|
||||
byte[] combined = Base64.getDecoder().decode(encryptedText);
|
||||
|
||||
// IV와 암호문 분리
|
||||
byte[] iv = Arrays.copyOfRange(combined, 0, GCM_IV_LENGTH);
|
||||
byte[] encrypted = Arrays.copyOfRange(combined, GCM_IV_LENGTH, combined.length);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, parameterSpec);
|
||||
|
||||
byte[] decrypted = cipher.doFinal(encrypted);
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Decryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] getKeyBytes() {
|
||||
byte[] keyBytes = new byte[32]; // AES-256
|
||||
byte[] sourceBytes = secretKey.getBytes(StandardCharsets.UTF_8);
|
||||
System.arraycopy(sourceBytes, 0, keyBytes, 0, Math.min(sourceBytes.length, keyBytes.length));
|
||||
return keyBytes;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user