Files
log-hunter/src/main/java/research/loghunter/service/ScanService.java
2026-01-07 01:41:17 +09:00

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++; }
}
}