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 progressMap = new ConcurrentHashMap<>(); // 로그 시간 파싱용 패턴들 private static final List 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 progressCallback) { Server server = serverRepository.findById(serverId) .orElseThrow(() -> new RuntimeException("Server not found: " + serverId)); return executeScan(server, progressCallback); } /** * 모든 활성 서버 스캔 */ @Transactional public List scanAllServers(Consumer progressCallback) { List servers = serverRepository.findByActiveTrue(); return servers.stream() .map(server -> executeScan(server, progressCallback)) .toList(); } /** * 스캔 실행 */ private ScanResult executeScan(Server server, Consumer 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 logPaths = logPathRepository.findByServerIdAndActiveTrue(server.getId()); if (logPaths.isEmpty()) { throw new RuntimeException("등록된 로그 경로가 없습니다."); } // 3. 활성 패턴 조회 List 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 allFiles = sftpService.listAllFiles(server, logPath); // 5. 스캔 대상 파일 필터링 (중복 제외) List 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 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 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 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 getHistory(Long serverId) { return scanHistoryRepository.findByServerIdOrderByStartedAtDesc(serverId); } /** * 스캔된 파일 목록 조회 (서버별) */ public List getScannedFiles(Long serverId) { return scannedFileRepository.findByServerId(serverId); } /** * 스캔된 파일 기록 초기화 (재스캔 허용) */ @Transactional public void resetScannedFiles(Long serverId) { List files = scannedFileRepository.findByServerId(serverId); scannedFileRepository.deleteAll(files); log.info("Reset {} scanned file records for server {}", files.size(), serverId); } private void notifyProgress(Consumer 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 scannedFiles = scannedFileRepository.findByServerId(serverId); int deletedFiles = scannedFiles.size(); scannedFileRepository.deleteAll(scannedFiles); List 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 getErrorStatsByFile(Long serverId) { List 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 getErrorStatsByServer() { List 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 getErrorStatsByPattern(Long serverId) { List 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 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 results = errorLogRepository.getDailyStatsByServer(startMs, endMs); // 서버별로 그룹화 Map 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 allDates = new ArrayList<>(); for (int i = days - 1; i >= 0; i--) { allDates.add(LocalDate.now().minusDays(i).toString()); } for (ServerDailyStats server : serverMap.values()) { Map dateMap = server.dailyStats().stream() .collect(Collectors.toMap(DailyStat::date, s -> s)); List 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 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 results = errorLogRepository.getDailyStatsByServer(startMs, endMs); log.info("Monthly stats results count: {}", results.size()); // 서버별로 그룹화 Map 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 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 dateMap = server.dailyStats().stream() .collect(Collectors.toMap(DailyStat::date, s -> s)); List 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 getTimeStatsByServer(LocalDate date, int intervalMinutes) { LocalDateTime startDate = date.atStartOfDay(); LocalDateTime endDate = date.plusDays(1).atStartOfDay(); List results = errorLogRepository.getErrorsByDateRange(startDate, endDate); // 서버별로 그룹화 Map 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 initTimeSlots(int slots, int intervalMinutes) { List 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 dailyStats ) {} public record DailyStat( String date, int total, int critical, int error, int warn ) {} // 시간별 통계 DTO public record ServerTimeStats( Long serverId, String serverName, List 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++; } } }