815 lines
33 KiB
Java
815 lines
33 KiB
Java
package research.loghunter.service;
|
|
|
|
import jakarta.persistence.EntityManager;
|
|
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 javax.sql.DataSource;
|
|
import java.sql.Connection;
|
|
import java.sql.Statement;
|
|
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDateTime;
|
|
import java.time.format.DateTimeFormatter;
|
|
import java.time.format.DateTimeParseException;
|
|
import java.util.*;
|
|
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;
|
|
import java.util.stream.Collectors;
|
|
|
|
@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;
|
|
private final EntityManager entityManager;
|
|
private final DataSource dataSource;
|
|
|
|
// 진행 상황 저장 (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());
|
|
// DB 최적화 (삭제된 데이터 공간 회수)
|
|
vacuumDatabase();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 서버 시간 오프셋 확인 및 업데이트
|
|
*/
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SQLite VACUUM 실행 (DB 최적화)
|
|
* 삭제된 데이터가 차지하는 공간을 회수함
|
|
*/
|
|
private void vacuumDatabase() {
|
|
try (Connection conn = dataSource.getConnection();
|
|
Statement stmt = conn.createStatement()) {
|
|
log.info("Running VACUUM to optimize database...");
|
|
long startTime = System.currentTimeMillis();
|
|
stmt.execute("VACUUM");
|
|
long elapsed = System.currentTimeMillis() - startTime;
|
|
log.info("VACUUM completed in {}ms", elapsed);
|
|
} catch (Exception e) {
|
|
log.warn("Failed to vacuum database: {}", e.getMessage());
|
|
}
|
|
}
|
|
|
|
// 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
|
|
) {}
|
|
|
|
// === 분석 결과 초기화 ===
|
|
|
|
/**
|
|
* 서버별 분석 결과 초기화
|
|
*/
|
|
@Transactional
|
|
public ResetResult resetScanData(Long serverId) {
|
|
long deletedErrors = errorLogRepository.countByServerId(serverId);
|
|
errorLogRepository.deleteByServerId(serverId);
|
|
|
|
List<ScannedFile> scannedFiles = scannedFileRepository.findByServerId(serverId);
|
|
int deletedFiles = scannedFiles.size();
|
|
scannedFileRepository.deleteAll(scannedFiles);
|
|
|
|
List<ScanHistory> histories = scanHistoryRepository.findByServerIdOrderByStartedAtDesc(serverId);
|
|
int deletedHistories = histories.size();
|
|
scanHistoryRepository.deleteAll(histories);
|
|
|
|
log.info("Reset scan data for server {}: {} errors, {} files, {} histories",
|
|
serverId, deletedErrors, deletedFiles, deletedHistories);
|
|
|
|
return new ResetResult((int) deletedErrors, deletedFiles, deletedHistories);
|
|
}
|
|
|
|
/**
|
|
* 전체 분석 결과 초기화
|
|
*/
|
|
@Transactional
|
|
public ResetResult resetAllScanData() {
|
|
long deletedErrors = errorLogRepository.count();
|
|
errorLogRepository.deleteAll();
|
|
|
|
long deletedFiles = scannedFileRepository.count();
|
|
scannedFileRepository.deleteAll();
|
|
|
|
long deletedHistories = scanHistoryRepository.count();
|
|
scanHistoryRepository.deleteAll();
|
|
|
|
log.info("Reset all scan data: {} errors, {} files, {} histories",
|
|
deletedErrors, deletedFiles, deletedHistories);
|
|
|
|
return new ResetResult((int) deletedErrors, (int) deletedFiles, (int) deletedHistories);
|
|
}
|
|
|
|
// === 에러 통계 ===
|
|
|
|
/**
|
|
* 파일별 에러 통계
|
|
*/
|
|
public List<FileErrorStats> getErrorStatsByFile(Long serverId) {
|
|
List<Object[]> results = errorLogRepository.getErrorStatsByFile(serverId);
|
|
return results.stream()
|
|
.map(row -> new FileErrorStats(
|
|
(String) row[0], // filePath
|
|
(String) row[1], // serverName
|
|
((Number) row[2]).intValue(), // totalCount
|
|
((Number) row[3]).intValue(), // criticalCount
|
|
((Number) row[4]).intValue(), // errorCount
|
|
((Number) row[5]).intValue(), // warnCount
|
|
(LocalDateTime) row[6] // lastOccurredAt
|
|
))
|
|
.toList();
|
|
}
|
|
|
|
/**
|
|
* 서버별 에러 통계
|
|
*/
|
|
public List<ServerErrorStats> getErrorStatsByServer() {
|
|
List<Object[]> results = errorLogRepository.getErrorStatsByServer();
|
|
return results.stream()
|
|
.map(row -> new ServerErrorStats(
|
|
((Number) row[0]).longValue(), // serverId
|
|
(String) row[1], // serverName
|
|
((Number) row[2]).intValue(), // totalCount
|
|
((Number) row[3]).intValue(), // criticalCount
|
|
((Number) row[4]).intValue(), // errorCount
|
|
((Number) row[5]).intValue(), // warnCount
|
|
(LocalDateTime) row[6] // lastOccurredAt
|
|
))
|
|
.toList();
|
|
}
|
|
|
|
/**
|
|
* 패턴별 에러 통계
|
|
*/
|
|
public List<PatternErrorStats> getErrorStatsByPattern(Long serverId) {
|
|
List<Object[]> results = errorLogRepository.getErrorStatsByPattern(serverId);
|
|
return results.stream()
|
|
.map(row -> new PatternErrorStats(
|
|
((Number) row[0]).longValue(), // patternId
|
|
(String) row[1], // patternName
|
|
(String) row[2], // severity
|
|
((Number) row[3]).intValue(), // count
|
|
(LocalDateTime) row[4] // lastOccurredAt
|
|
))
|
|
.toList();
|
|
}
|
|
|
|
/**
|
|
* 대시보드용: 서버별 최근 N일 일별 통계
|
|
*/
|
|
public List<ServerDailyStats> getDailyStatsByServer(int days) {
|
|
LocalDateTime endDate = LocalDate.now().plusDays(1).atStartOfDay(); // 내일 00:00
|
|
LocalDateTime startDate = LocalDate.now().minusDays(days - 1).atStartOfDay(); // N일 전 00:00
|
|
|
|
// epoch milliseconds로 변환
|
|
long startMs = startDate.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
|
|
long endMs = endDate.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
|
|
|
|
List<Object[]> results = errorLogRepository.getDailyStatsByServer(startMs, endMs);
|
|
|
|
// 서버별로 그룹화
|
|
Map<Long, ServerDailyStats> serverMap = new LinkedHashMap<>();
|
|
|
|
for (Object[] row : results) {
|
|
Long serverId = ((Number) row[0]).longValue();
|
|
String serverName = (String) row[1];
|
|
String dateStr = (String) row[2]; // SQLite date() 함수는 String 반환
|
|
int total = ((Number) row[3]).intValue();
|
|
int critical = ((Number) row[4]).intValue();
|
|
int error = ((Number) row[5]).intValue();
|
|
int warn = ((Number) row[6]).intValue();
|
|
|
|
serverMap.computeIfAbsent(serverId, k -> new ServerDailyStats(serverId, serverName, new ArrayList<>()))
|
|
.dailyStats().add(new DailyStat(dateStr, total, critical, error, warn));
|
|
}
|
|
|
|
// 모든 날짜 채우기 (데이터 없는 날짜는 0으로)
|
|
List<String> allDates = new ArrayList<>();
|
|
for (int i = days - 1; i >= 0; i--) {
|
|
allDates.add(LocalDate.now().minusDays(i).toString());
|
|
}
|
|
|
|
for (ServerDailyStats server : serverMap.values()) {
|
|
Map<String, DailyStat> dateMap = server.dailyStats().stream()
|
|
.collect(Collectors.toMap(DailyStat::date, s -> s));
|
|
|
|
List<DailyStat> filledStats = allDates.stream()
|
|
.map(date -> dateMap.getOrDefault(date, new DailyStat(date, 0, 0, 0, 0)))
|
|
.toList();
|
|
|
|
server.dailyStats().clear();
|
|
server.dailyStats().addAll(filledStats);
|
|
}
|
|
|
|
return new ArrayList<>(serverMap.values());
|
|
}
|
|
|
|
/**
|
|
* 월별현황용: 서버별 해당 월 일별 통계
|
|
*/
|
|
public List<ServerDailyStats> getMonthlyStatsByServer(int year, int month) {
|
|
LocalDate firstDay = LocalDate.of(year, month, 1);
|
|
LocalDate lastDay = firstDay.withDayOfMonth(firstDay.lengthOfMonth());
|
|
|
|
LocalDateTime startDate = firstDay.atStartOfDay();
|
|
LocalDateTime endDate = lastDay.plusDays(1).atStartOfDay();
|
|
|
|
// epoch milliseconds로 변환
|
|
long startMs = startDate.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
|
|
long endMs = endDate.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
|
|
|
|
log.info("Monthly stats query: year={}, month={}, startMs={}, endMs={}", year, month, startMs, endMs);
|
|
|
|
List<Object[]> results = errorLogRepository.getDailyStatsByServer(startMs, endMs);
|
|
|
|
log.info("Monthly stats results count: {}", results.size());
|
|
|
|
// 서버별로 그룹화
|
|
Map<Long, ServerDailyStats> serverMap = new LinkedHashMap<>();
|
|
|
|
for (Object[] row : results) {
|
|
Long serverId = ((Number) row[0]).longValue();
|
|
String serverName = (String) row[1];
|
|
String dateStr = (String) row[2]; // SQLite date() 함수는 String 반환
|
|
int total = ((Number) row[3]).intValue();
|
|
int critical = ((Number) row[4]).intValue();
|
|
int error = ((Number) row[5]).intValue();
|
|
int warn = ((Number) row[6]).intValue();
|
|
|
|
log.debug("Row: serverId={}, serverName={}, date={}, total={}", serverId, serverName, dateStr, total);
|
|
|
|
serverMap.computeIfAbsent(serverId, k -> new ServerDailyStats(serverId, serverName, new ArrayList<>()))
|
|
.dailyStats().add(new DailyStat(dateStr, total, critical, error, warn));
|
|
}
|
|
|
|
// 모든 날짜 채우기
|
|
List<String> allDates = new ArrayList<>();
|
|
for (int day = 1; day <= firstDay.lengthOfMonth(); day++) {
|
|
allDates.add(LocalDate.of(year, month, day).toString());
|
|
}
|
|
|
|
for (ServerDailyStats server : serverMap.values()) {
|
|
Map<String, DailyStat> dateMap = server.dailyStats().stream()
|
|
.collect(Collectors.toMap(DailyStat::date, s -> s));
|
|
|
|
List<DailyStat> filledStats = allDates.stream()
|
|
.map(date -> dateMap.getOrDefault(date, new DailyStat(date, 0, 0, 0, 0)))
|
|
.toList();
|
|
|
|
server.dailyStats().clear();
|
|
server.dailyStats().addAll(filledStats);
|
|
}
|
|
|
|
return new ArrayList<>(serverMap.values());
|
|
}
|
|
|
|
/**
|
|
* 일별현황용: 서버별 해당 날짜 5분 단위 통계
|
|
*/
|
|
public List<ServerTimeStats> getTimeStatsByServer(LocalDate date, int intervalMinutes) {
|
|
LocalDateTime startDate = date.atStartOfDay();
|
|
LocalDateTime endDate = date.plusDays(1).atStartOfDay();
|
|
|
|
List<Object[]> results = errorLogRepository.getErrorsByDateRange(startDate, endDate);
|
|
|
|
// 서버별로 그룹화
|
|
Map<Long, ServerTimeStats> serverMap = new LinkedHashMap<>();
|
|
|
|
// 시간 슬롯 초기화 (5분 단위 = 288개)
|
|
int slots = 24 * 60 / intervalMinutes;
|
|
|
|
for (Object[] row : results) {
|
|
Long serverId = ((Number) row[0]).longValue();
|
|
String serverName = (String) row[1];
|
|
LocalDateTime occurredAt = (LocalDateTime) row[2];
|
|
String severity = (String) row[3];
|
|
|
|
ServerTimeStats stats = serverMap.computeIfAbsent(serverId,
|
|
k -> new ServerTimeStats(serverId, serverName, initTimeSlots(slots, intervalMinutes)));
|
|
|
|
// 해당 시간 슬롯 찾기
|
|
int minuteOfDay = occurredAt.getHour() * 60 + occurredAt.getMinute();
|
|
int slotIndex = minuteOfDay / intervalMinutes;
|
|
|
|
if (slotIndex < stats.timeStats().size()) {
|
|
TimeStat slot = stats.timeStats().get(slotIndex);
|
|
slot.incrementTotal();
|
|
switch (severity) {
|
|
case "CRITICAL" -> slot.incrementCritical();
|
|
case "ERROR" -> slot.incrementError();
|
|
case "WARN" -> slot.incrementWarn();
|
|
}
|
|
}
|
|
}
|
|
|
|
return new ArrayList<>(serverMap.values());
|
|
}
|
|
|
|
private List<TimeStat> initTimeSlots(int slots, int intervalMinutes) {
|
|
List<TimeStat> timeStats = new ArrayList<>();
|
|
for (int i = 0; i < slots; i++) {
|
|
int minutes = i * intervalMinutes;
|
|
String time = String.format("%02d:%02d", minutes / 60, minutes % 60);
|
|
timeStats.add(new TimeStat(time));
|
|
}
|
|
return timeStats;
|
|
}
|
|
|
|
// DTOs for stats
|
|
public record ResetResult(int deletedErrors, int deletedFiles, int deletedHistories) {}
|
|
|
|
public record FileErrorStats(
|
|
String filePath,
|
|
String serverName,
|
|
int totalCount,
|
|
int criticalCount,
|
|
int errorCount,
|
|
int warnCount,
|
|
LocalDateTime lastOccurredAt
|
|
) {}
|
|
|
|
public record ServerErrorStats(
|
|
Long serverId,
|
|
String serverName,
|
|
int totalCount,
|
|
int criticalCount,
|
|
int errorCount,
|
|
int warnCount,
|
|
LocalDateTime lastOccurredAt
|
|
) {}
|
|
|
|
public record PatternErrorStats(
|
|
Long patternId,
|
|
String patternName,
|
|
String severity,
|
|
int count,
|
|
LocalDateTime lastOccurredAt
|
|
) {}
|
|
|
|
// 일별 통계 DTO
|
|
public record ServerDailyStats(
|
|
Long serverId,
|
|
String serverName,
|
|
List<DailyStat> dailyStats
|
|
) {}
|
|
|
|
public record DailyStat(
|
|
String date,
|
|
int total,
|
|
int critical,
|
|
int error,
|
|
int warn
|
|
) {}
|
|
|
|
// 시간별 통계 DTO
|
|
public record ServerTimeStats(
|
|
Long serverId,
|
|
String serverName,
|
|
List<TimeStat> timeStats
|
|
) {}
|
|
|
|
public static class TimeStat {
|
|
private final String time;
|
|
private int total;
|
|
private int critical;
|
|
private int error;
|
|
private int warn;
|
|
|
|
public TimeStat(String time) {
|
|
this.time = time;
|
|
}
|
|
|
|
public String getTime() { return time; }
|
|
public int getTotal() { return total; }
|
|
public int getCritical() { return critical; }
|
|
public int getError() { return error; }
|
|
public int getWarn() { return warn; }
|
|
|
|
public void incrementTotal() { total++; }
|
|
public void incrementCritical() { critical++; }
|
|
public void incrementError() { error++; }
|
|
public void incrementWarn() { warn++; }
|
|
}
|
|
}
|