update 22
This commit is contained in:
39
src/main/java/research/loghunter/config/WebConfig.java
Normal file
39
src/main/java/research/loghunter/config/WebConfig.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package research.loghunter.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
protected Resource getResource(String resourcePath, Resource location) throws IOException {
|
||||
Resource requestedResource = location.createRelative(resourcePath);
|
||||
|
||||
// 실제 파일이 존재하면 반환
|
||||
if (requestedResource.exists() && requestedResource.isReadable()) {
|
||||
return requestedResource;
|
||||
}
|
||||
|
||||
// API 요청이 아니면 index.html 반환 (SPA 라우팅)
|
||||
if (!resourcePath.startsWith("api/")) {
|
||||
return new ClassPathResource("/static/index.html");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,12 @@ 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.dto.FileTreeDto;
|
||||
import research.loghunter.service.ErrorLogService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/error-logs")
|
||||
@@ -22,6 +25,7 @@ public class ErrorLogController {
|
||||
@RequestParam(required = false) Long serverId,
|
||||
@RequestParam(required = false) Long patternId,
|
||||
@RequestParam(required = false) String severity,
|
||||
@RequestParam(required = false) String filePath,
|
||||
@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,
|
||||
@@ -29,7 +33,7 @@ public class ErrorLogController {
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
return ResponseEntity.ok(errorLogService.search(
|
||||
serverId, patternId, severity, startDate, endDate, keyword, page, size));
|
||||
serverId, patternId, severity, filePath, startDate, endDate, keyword, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@@ -45,4 +49,46 @@ public class ErrorLogController {
|
||||
) {
|
||||
return ResponseEntity.ok(errorLogService.findByServerId(serverId, page, size));
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 구조 데이터 조회 (서버 > 로그경로 > 파일)
|
||||
*/
|
||||
@GetMapping("/tree")
|
||||
public ResponseEntity<List<FileTreeDto.ServerNode>> getFileTree() {
|
||||
return ResponseEntity.ok(errorLogService.getFileTree());
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버별 파일 목록 조회
|
||||
*/
|
||||
@GetMapping("/files")
|
||||
public ResponseEntity<List<String>> getFilesByServer(
|
||||
@RequestParam(required = false) Long serverId
|
||||
) {
|
||||
return ResponseEntity.ok(errorLogService.getFilesByServer(serverId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 에러 삭제
|
||||
*/
|
||||
@DeleteMapping("/batch")
|
||||
public ResponseEntity<Map<String, Object>> deleteByIds(@RequestBody List<Long> ids) {
|
||||
int deleted = errorLogService.deleteByIds(ids);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"deleted", deleted
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일별 에러 및 스캔기록 삭제
|
||||
*/
|
||||
@DeleteMapping("/by-file")
|
||||
public ResponseEntity<Map<String, Object>> deleteByFile(
|
||||
@RequestParam Long serverId,
|
||||
@RequestParam String filePath
|
||||
) {
|
||||
Map<String, Object> result = errorLogService.deleteFileAndErrors(serverId, filePath);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,13 @@ public class ExportController {
|
||||
@RequestParam(required = false) Long serverId,
|
||||
@RequestParam(required = false) Long patternId,
|
||||
@RequestParam(required = false) String severity,
|
||||
@RequestParam(required = false) String filePath,
|
||||
@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);
|
||||
serverId, patternId, severity, filePath, startDate, endDate, keyword);
|
||||
|
||||
ExportService.ExportResult result = exportService.exportHtml(request);
|
||||
|
||||
@@ -45,12 +46,13 @@ public class ExportController {
|
||||
@RequestParam(required = false) Long serverId,
|
||||
@RequestParam(required = false) Long patternId,
|
||||
@RequestParam(required = false) String severity,
|
||||
@RequestParam(required = false) String filePath,
|
||||
@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);
|
||||
serverId, patternId, severity, filePath, startDate, endDate, keyword);
|
||||
|
||||
ExportService.ExportResult result = exportService.exportTxt(request);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import research.loghunter.service.SftpService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@@ -133,4 +134,88 @@ public class ScanController {
|
||||
public ResponseEntity<List<ScanHistory>> getHistory(@PathVariable Long serverId) {
|
||||
return ResponseEntity.ok(scanService.getHistory(serverId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 결과 초기화 (서버별)
|
||||
*/
|
||||
@DeleteMapping("/reset/{serverId}")
|
||||
public ResponseEntity<Map<String, Object>> resetScanData(@PathVariable Long serverId) {
|
||||
ScanService.ResetResult result = scanService.resetScanData(serverId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"deletedErrors", result.deletedErrors(),
|
||||
"deletedFiles", result.deletedFiles(),
|
||||
"deletedHistories", result.deletedHistories()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 분석 결과 초기화
|
||||
*/
|
||||
@DeleteMapping("/reset-all")
|
||||
public ResponseEntity<Map<String, Object>> resetAllScanData() {
|
||||
ScanService.ResetResult result = scanService.resetAllScanData();
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"deletedErrors", result.deletedErrors(),
|
||||
"deletedFiles", result.deletedFiles(),
|
||||
"deletedHistories", result.deletedHistories()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일별 에러 통계 조회
|
||||
*/
|
||||
@GetMapping("/stats/by-file")
|
||||
public ResponseEntity<List<ScanService.FileErrorStats>> getErrorStatsByFile(
|
||||
@RequestParam(required = false) Long serverId) {
|
||||
return ResponseEntity.ok(scanService.getErrorStatsByFile(serverId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버별 에러 통계 조회
|
||||
*/
|
||||
@GetMapping("/stats/by-server")
|
||||
public ResponseEntity<List<ScanService.ServerErrorStats>> getErrorStatsByServer() {
|
||||
return ResponseEntity.ok(scanService.getErrorStatsByServer());
|
||||
}
|
||||
|
||||
/**
|
||||
* 패턴별 에러 통계 조회
|
||||
*/
|
||||
@GetMapping("/stats/by-pattern")
|
||||
public ResponseEntity<List<ScanService.PatternErrorStats>> getErrorStatsByPattern(
|
||||
@RequestParam(required = false) Long serverId) {
|
||||
return ResponseEntity.ok(scanService.getErrorStatsByPattern(serverId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드용: 서버별 최근 N일 일별 통계
|
||||
*/
|
||||
@GetMapping("/stats/daily-by-server")
|
||||
public ResponseEntity<List<ScanService.ServerDailyStats>> getDailyStatsByServer(
|
||||
@RequestParam(defaultValue = "30") int days) {
|
||||
return ResponseEntity.ok(scanService.getDailyStatsByServer(days));
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별현황용: 서버별 해당 월 일별 통계
|
||||
*/
|
||||
@GetMapping("/stats/monthly-by-server")
|
||||
public ResponseEntity<List<ScanService.ServerDailyStats>> getMonthlyStatsByServer(
|
||||
@RequestParam int year,
|
||||
@RequestParam int month) {
|
||||
return ResponseEntity.ok(scanService.getMonthlyStatsByServer(year, month));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별현황용: 서버별 해당 날짜 5분 단위 통계
|
||||
*/
|
||||
@GetMapping("/stats/time-by-server")
|
||||
public ResponseEntity<List<ScanService.ServerTimeStats>> getTimeStatsByServer(
|
||||
@RequestParam String date,
|
||||
@RequestParam(defaultValue = "5") int intervalMinutes) {
|
||||
java.time.LocalDate localDate = java.time.LocalDate.parse(date);
|
||||
return ResponseEntity.ok(scanService.getTimeStatsByServer(localDate, intervalMinutes));
|
||||
}
|
||||
}
|
||||
|
||||
45
src/main/java/research/loghunter/dto/FileTreeDto.java
Normal file
45
src/main/java/research/loghunter/dto/FileTreeDto.java
Normal file
@@ -0,0 +1,45 @@
|
||||
package research.loghunter.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class FileTreeDto {
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ServerNode {
|
||||
private Long serverId;
|
||||
private String serverName;
|
||||
private int totalErrorCount;
|
||||
private List<PathNode> paths;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class PathNode {
|
||||
private String path;
|
||||
private int totalErrorCount;
|
||||
private List<FileNode> files;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class FileNode {
|
||||
private String filePath;
|
||||
private String fileName;
|
||||
private int errorCount;
|
||||
private int criticalCount;
|
||||
private int errorLevelCount;
|
||||
private int warnCount;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public class PatternDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String regex;
|
||||
private String excludeRegex; // 제외 정규식
|
||||
private String severity;
|
||||
private Integer contextLines;
|
||||
private String description;
|
||||
|
||||
@@ -23,6 +23,9 @@ public class Pattern {
|
||||
@Column(nullable = false, length = 1000)
|
||||
private String regex; // 정규식
|
||||
|
||||
@Column(length = 1000)
|
||||
private String excludeRegex; // 제외 정규식 (매칭되면 에러에서 제외)
|
||||
|
||||
@Column(nullable = false)
|
||||
private String severity; // CRITICAL, ERROR, WARN
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import research.loghunter.entity.ErrorLog;
|
||||
@@ -20,6 +21,7 @@ public interface ErrorLogRepository extends JpaRepository<ErrorLog, Long> {
|
||||
"(: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 " +
|
||||
"(:filePath IS NULL OR e.filePath = :filePath) 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, '%'))) " +
|
||||
@@ -28,8 +30,94 @@ public interface ErrorLogRepository extends JpaRepository<ErrorLog, Long> {
|
||||
@Param("serverId") Long serverId,
|
||||
@Param("patternId") Long patternId,
|
||||
@Param("severity") String severity,
|
||||
@Param("filePath") String filePath,
|
||||
@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate,
|
||||
@Param("keyword") String keyword,
|
||||
Pageable pageable);
|
||||
|
||||
// 서버별 삭제
|
||||
void deleteByServerId(Long serverId);
|
||||
|
||||
// 서버별 에러 수
|
||||
long countByServerId(Long serverId);
|
||||
|
||||
// 전체 파일 경로 목록 (중복 제거)
|
||||
@Query("SELECT DISTINCT e.filePath FROM ErrorLog e ORDER BY e.filePath")
|
||||
List<String> findDistinctFilePaths();
|
||||
|
||||
// 서버별 파일 경로 목록 (중복 제거)
|
||||
@Query("SELECT DISTINCT e.filePath FROM ErrorLog e WHERE e.server.id = :serverId ORDER BY e.filePath")
|
||||
List<String> findDistinctFilePathsByServerId(@Param("serverId") Long serverId);
|
||||
|
||||
// ID 목록으로 삭제
|
||||
@Modifying
|
||||
@Query("DELETE FROM ErrorLog e WHERE e.id IN :ids")
|
||||
int deleteByIdIn(@Param("ids") List<Long> ids);
|
||||
|
||||
// 서버+파일 기준 삭제
|
||||
@Modifying
|
||||
@Query("DELETE FROM ErrorLog e WHERE e.server.id = :serverId AND e.filePath = :filePath")
|
||||
int deleteByServerIdAndFilePath(@Param("serverId") Long serverId, @Param("filePath") String filePath);
|
||||
|
||||
// 파일별 에러 통계
|
||||
@Query("SELECT e.filePath, e.server.name, COUNT(e), " +
|
||||
"SUM(CASE WHEN e.severity = 'CRITICAL' THEN 1 ELSE 0 END), " +
|
||||
"SUM(CASE WHEN e.severity = 'ERROR' THEN 1 ELSE 0 END), " +
|
||||
"SUM(CASE WHEN e.severity = 'WARN' THEN 1 ELSE 0 END), " +
|
||||
"MAX(e.occurredAt) " +
|
||||
"FROM ErrorLog e " +
|
||||
"WHERE (:serverId IS NULL OR e.server.id = :serverId) " +
|
||||
"GROUP BY e.filePath, e.server.name " +
|
||||
"ORDER BY COUNT(e) DESC")
|
||||
List<Object[]> getErrorStatsByFile(@Param("serverId") Long serverId);
|
||||
|
||||
// 서버별 에러 통계
|
||||
@Query("SELECT e.server.id, e.server.name, COUNT(e), " +
|
||||
"SUM(CASE WHEN e.severity = 'CRITICAL' THEN 1 ELSE 0 END), " +
|
||||
"SUM(CASE WHEN e.severity = 'ERROR' THEN 1 ELSE 0 END), " +
|
||||
"SUM(CASE WHEN e.severity = 'WARN' THEN 1 ELSE 0 END), " +
|
||||
"MAX(e.occurredAt) " +
|
||||
"FROM ErrorLog e " +
|
||||
"GROUP BY e.server.id, e.server.name " +
|
||||
"ORDER BY COUNT(e) DESC")
|
||||
List<Object[]> getErrorStatsByServer();
|
||||
|
||||
// 패턴별 에러 통계
|
||||
@Query("SELECT e.pattern.id, e.pattern.name, e.pattern.severity, COUNT(e), MAX(e.occurredAt) " +
|
||||
"FROM ErrorLog e " +
|
||||
"WHERE (:serverId IS NULL OR e.server.id = :serverId) " +
|
||||
"GROUP BY e.pattern.id, e.pattern.name, e.pattern.severity " +
|
||||
"ORDER BY COUNT(e) DESC")
|
||||
List<Object[]> getErrorStatsByPattern(@Param("serverId") Long serverId);
|
||||
|
||||
// 트리용 파일별 에러 통계 (서버ID, 서버명, 파일경로, 에러수, CRITICAL, ERROR, WARN)
|
||||
@Query("SELECT e.server.id, e.server.name, e.filePath, COUNT(e), " +
|
||||
"SUM(CASE WHEN e.severity = 'CRITICAL' THEN 1 ELSE 0 END), " +
|
||||
"SUM(CASE WHEN e.severity = 'ERROR' THEN 1 ELSE 0 END), " +
|
||||
"SUM(CASE WHEN e.severity = 'WARN' THEN 1 ELSE 0 END) " +
|
||||
"FROM ErrorLog e " +
|
||||
"GROUP BY e.server.id, e.server.name, e.filePath " +
|
||||
"ORDER BY e.server.name, e.filePath")
|
||||
List<Object[]> getFileErrorStats();
|
||||
|
||||
// 서버별 일별 에러 통계 (대시보드, 월별현황용) - SQLite 네이티브 쿼리 (epoch ms 지원)
|
||||
@Query(value = "SELECT e.server_id, s.name, date(e.occurred_at/1000, 'unixepoch', 'localtime'), COUNT(*), " +
|
||||
"SUM(CASE WHEN e.severity = 'CRITICAL' THEN 1 ELSE 0 END), " +
|
||||
"SUM(CASE WHEN e.severity = 'ERROR' THEN 1 ELSE 0 END), " +
|
||||
"SUM(CASE WHEN e.severity = 'WARN' THEN 1 ELSE 0 END) " +
|
||||
"FROM error_logs e JOIN servers s ON e.server_id = s.id " +
|
||||
"WHERE e.occurred_at >= :startMs AND e.occurred_at < :endMs " +
|
||||
"GROUP BY e.server_id, s.name, date(e.occurred_at/1000, 'unixepoch', 'localtime') " +
|
||||
"ORDER BY e.server_id, date(e.occurred_at/1000, 'unixepoch', 'localtime')", nativeQuery = true)
|
||||
List<Object[]> getDailyStatsByServer(@Param("startMs") long startMs,
|
||||
@Param("endMs") long endMs);
|
||||
|
||||
// 서버별 시간대별 에러 통계 (일별현황용 - 원본 데이터, 5분 단위는 서비스에서 처리)
|
||||
@Query("SELECT e.server.id, e.server.name, e.occurredAt, e.severity " +
|
||||
"FROM ErrorLog e " +
|
||||
"WHERE e.occurredAt >= :startDate AND e.occurredAt < :endDate " +
|
||||
"ORDER BY e.server.id, e.occurredAt")
|
||||
List<Object[]> getErrorsByDateRange(@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package research.loghunter.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import research.loghunter.entity.ScannedFile;
|
||||
@@ -28,4 +29,14 @@ public interface ScannedFileRepository extends JpaRepository<ScannedFile, Long>
|
||||
|
||||
// 경로별 스캔 파일 수
|
||||
long countByLogPathId(Long logPathId);
|
||||
|
||||
// 서버+파일경로 기준 삭제
|
||||
@Modifying
|
||||
@Query("DELETE FROM ScannedFile sf WHERE sf.serverId = :serverId AND sf.filePath = :filePath")
|
||||
int deleteByServerIdAndFilePath(@Param("serverId") Long serverId, @Param("filePath") String filePath);
|
||||
|
||||
// 서버별 전체 삭제
|
||||
@Modifying
|
||||
@Query("DELETE FROM ScannedFile sf WHERE sf.serverId = :serverId")
|
||||
int deleteByServerId(@Param("serverId") Long serverId);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@ 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.dto.FileTreeDto;
|
||||
import research.loghunter.entity.ErrorLog;
|
||||
import research.loghunter.entity.Server;
|
||||
import research.loghunter.repository.ErrorLogRepository;
|
||||
import research.loghunter.repository.ScannedFileRepository;
|
||||
import research.loghunter.repository.ServerRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -18,11 +24,14 @@ import java.time.LocalDateTime;
|
||||
public class ErrorLogService {
|
||||
|
||||
private final ErrorLogRepository errorLogRepository;
|
||||
private final ScannedFileRepository scannedFileRepository;
|
||||
private final ServerRepository serverRepository;
|
||||
|
||||
public Page<ErrorLogDto> search(
|
||||
Long serverId,
|
||||
Long patternId,
|
||||
String severity,
|
||||
String filePath,
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate,
|
||||
String keyword,
|
||||
@@ -31,7 +40,7 @@ public class ErrorLogService {
|
||||
) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
Page<ErrorLog> errorLogs = errorLogRepository.searchErrors(
|
||||
serverId, patternId, severity, startDate, endDate, keyword, pageable);
|
||||
serverId, patternId, severity, filePath, startDate, endDate, keyword, pageable);
|
||||
|
||||
return errorLogs.map(this::toDto);
|
||||
}
|
||||
@@ -48,6 +57,125 @@ public class ErrorLogService {
|
||||
.orElseThrow(() -> new RuntimeException("ErrorLog not found: " + id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 구조 데이터 조회
|
||||
*/
|
||||
public List<FileTreeDto.ServerNode> getFileTree() {
|
||||
// 파일별 에러 통계 조회
|
||||
List<Object[]> stats = errorLogRepository.getFileErrorStats();
|
||||
|
||||
// 서버별로 그룹핑
|
||||
Map<Long, List<Object[]>> serverGroups = new LinkedHashMap<>();
|
||||
for (Object[] stat : stats) {
|
||||
Long serverId = ((Number) stat[0]).longValue();
|
||||
serverGroups.computeIfAbsent(serverId, k -> new ArrayList<>()).add(stat);
|
||||
}
|
||||
|
||||
List<FileTreeDto.ServerNode> result = new ArrayList<>();
|
||||
|
||||
for (Map.Entry<Long, List<Object[]>> entry : serverGroups.entrySet()) {
|
||||
Long serverId = entry.getKey();
|
||||
List<Object[]> serverStats = entry.getValue();
|
||||
|
||||
String serverName = (String) serverStats.get(0)[1];
|
||||
|
||||
// 경로별로 그룹핑
|
||||
Map<String, List<FileTreeDto.FileNode>> pathGroups = new LinkedHashMap<>();
|
||||
int serverTotalErrors = 0;
|
||||
|
||||
for (Object[] stat : serverStats) {
|
||||
String filePath = (String) stat[2];
|
||||
int errorCount = ((Number) stat[3]).intValue();
|
||||
int criticalCount = ((Number) stat[4]).intValue();
|
||||
int errorLevelCount = ((Number) stat[5]).intValue();
|
||||
int warnCount = ((Number) stat[6]).intValue();
|
||||
|
||||
// 경로와 파일명 분리
|
||||
int lastSlash = filePath.lastIndexOf('/');
|
||||
String path = lastSlash > 0 ? filePath.substring(0, lastSlash) : "/";
|
||||
String fileName = lastSlash > 0 ? filePath.substring(lastSlash + 1) : filePath;
|
||||
|
||||
FileTreeDto.FileNode fileNode = FileTreeDto.FileNode.builder()
|
||||
.filePath(filePath)
|
||||
.fileName(fileName)
|
||||
.errorCount(errorCount)
|
||||
.criticalCount(criticalCount)
|
||||
.errorLevelCount(errorLevelCount)
|
||||
.warnCount(warnCount)
|
||||
.build();
|
||||
|
||||
pathGroups.computeIfAbsent(path, k -> new ArrayList<>()).add(fileNode);
|
||||
serverTotalErrors += errorCount;
|
||||
}
|
||||
|
||||
// PathNode 생성
|
||||
List<FileTreeDto.PathNode> pathNodes = new ArrayList<>();
|
||||
for (Map.Entry<String, List<FileTreeDto.FileNode>> pathEntry : pathGroups.entrySet()) {
|
||||
List<FileTreeDto.FileNode> files = pathEntry.getValue();
|
||||
int pathTotalErrors = files.stream().mapToInt(FileTreeDto.FileNode::getErrorCount).sum();
|
||||
|
||||
pathNodes.add(FileTreeDto.PathNode.builder()
|
||||
.path(pathEntry.getKey())
|
||||
.totalErrorCount(pathTotalErrors)
|
||||
.files(files)
|
||||
.build());
|
||||
}
|
||||
|
||||
result.add(FileTreeDto.ServerNode.builder()
|
||||
.serverId(serverId)
|
||||
.serverName(serverName)
|
||||
.totalErrorCount(serverTotalErrors)
|
||||
.paths(pathNodes)
|
||||
.build());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버별 파일 목록 조회
|
||||
*/
|
||||
public List<String> getFilesByServer(Long serverId) {
|
||||
if (serverId == null) {
|
||||
return errorLogRepository.findDistinctFilePaths();
|
||||
}
|
||||
return errorLogRepository.findDistinctFilePathsByServerId(serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 ID들의 에러 삭제
|
||||
*/
|
||||
@Transactional
|
||||
public int deleteByIds(List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return errorLogRepository.deleteByIdIn(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제 (에러로그 + 스캔기록)
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> deleteFileAndErrors(Long serverId, String filePath) {
|
||||
int deletedErrors = errorLogRepository.deleteByServerIdAndFilePath(serverId, filePath);
|
||||
int deletedFiles = scannedFileRepository.deleteByServerIdAndFilePath(serverId, filePath);
|
||||
|
||||
return Map.of(
|
||||
"success", true,
|
||||
"deletedErrors", deletedErrors,
|
||||
"deletedScannedFiles", deletedFiles
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버+파일 기준 에러 삭제
|
||||
*/
|
||||
@Transactional
|
||||
public int deleteByServerAndFile(Long serverId, String filePath) {
|
||||
return errorLogRepository.deleteByServerIdAndFilePath(serverId, filePath);
|
||||
}
|
||||
|
||||
private ErrorLogDto toDto(ErrorLog errorLog) {
|
||||
return ErrorLogDto.builder()
|
||||
.id(errorLog.getId())
|
||||
|
||||
@@ -141,6 +141,7 @@ public class ExportService {
|
||||
request.serverId(),
|
||||
request.patternId(),
|
||||
request.severity(),
|
||||
request.filePath(),
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
request.keyword(),
|
||||
@@ -272,6 +273,7 @@ public class ExportService {
|
||||
Long serverId,
|
||||
Long patternId,
|
||||
String severity,
|
||||
String filePath,
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate,
|
||||
String keyword
|
||||
|
||||
@@ -14,6 +14,18 @@ import java.util.regex.Matcher;
|
||||
@Slf4j
|
||||
public class LogParserService {
|
||||
|
||||
// 로그 레벨 감지 패턴 (INFO, DEBUG, TRACE는 에러가 아님)
|
||||
private static final java.util.regex.Pattern LOG_LEVEL_PATTERN = java.util.regex.Pattern.compile(
|
||||
"\\b(INFO|DEBUG|TRACE|FINE|FINER|FINEST|CONFIG)\\b",
|
||||
java.util.regex.Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
|
||||
// 에러 레벨 패턴 (ERROR, WARN, FATAL 등)
|
||||
private static final java.util.regex.Pattern ERROR_LEVEL_PATTERN = java.util.regex.Pattern.compile(
|
||||
"\\b(ERROR|WARN|WARNING|FATAL|SEVERE|CRITICAL)\\b",
|
||||
java.util.regex.Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
|
||||
/**
|
||||
* 로그 내용에서 패턴 매칭
|
||||
*/
|
||||
@@ -27,13 +39,37 @@ public class LogParserService {
|
||||
try {
|
||||
java.util.regex.Pattern compiledPattern = java.util.regex.Pattern.compile(pattern.getRegex());
|
||||
|
||||
// 제외 패턴 컴파일 (있는 경우)
|
||||
java.util.regex.Pattern excludePattern = null;
|
||||
if (pattern.getExcludeRegex() != null && !pattern.getExcludeRegex().isBlank()) {
|
||||
try {
|
||||
excludePattern = java.util.regex.Pattern.compile(pattern.getExcludeRegex());
|
||||
} catch (Exception e) {
|
||||
log.warn("Invalid exclude regex for pattern {}: {}", pattern.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
Matcher matcher = compiledPattern.matcher(lines[i]);
|
||||
String line = lines[i];
|
||||
Matcher matcher = compiledPattern.matcher(line);
|
||||
|
||||
if (matcher.find()) {
|
||||
// 1. 제외 패턴 체크
|
||||
if (excludePattern != null && excludePattern.matcher(line).find()) {
|
||||
log.debug("Excluded by excludeRegex: {}", line.substring(0, Math.min(100, line.length())));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 로그 레벨 체크 - INFO/DEBUG/TRACE는 에러가 아님
|
||||
if (isNonErrorLogLevel(line)) {
|
||||
log.debug("Excluded by log level (INFO/DEBUG/TRACE): {}",
|
||||
line.substring(0, Math.min(100, line.length())));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 컨텍스트 추출
|
||||
String context = extractContext(lines, i, pattern.getContextLines());
|
||||
String summary = createSummary(lines[i]);
|
||||
String summary = createSummary(line);
|
||||
|
||||
results.add(new MatchResult(
|
||||
pattern,
|
||||
@@ -53,6 +89,26 @@ public class LogParserService {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 레벨이 INFO/DEBUG/TRACE인지 확인
|
||||
* - 에러 레벨(ERROR/WARN 등)이 있으면 false
|
||||
* - INFO/DEBUG/TRACE가 있으면 true (에러 아님)
|
||||
*/
|
||||
private boolean isNonErrorLogLevel(String line) {
|
||||
// 에러 레벨이 있으면 에러임 (false 반환)
|
||||
if (ERROR_LEVEL_PATTERN.matcher(line).find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// INFO/DEBUG/TRACE가 있으면 에러 아님 (true 반환)
|
||||
if (LOG_LEVEL_PATTERN.matcher(line).find()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 레벨 정보가 없으면 패턴 매칭 결과를 사용 (에러로 간주)
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨텍스트 추출 (에러 전후 라인)
|
||||
*/
|
||||
@@ -70,12 +126,32 @@ public class LogParserService {
|
||||
return context.toString();
|
||||
}
|
||||
|
||||
// 요약에서 제거할 날짜시간 패턴
|
||||
private static final java.util.regex.Pattern DATETIME_PREFIX_PATTERN = java.util.regex.Pattern.compile(
|
||||
"^\\s*" +
|
||||
"(" +
|
||||
"\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}[,.]?\\d{0,3}" + // 2026-01-06 10:35:23,456
|
||||
"|\\d{2}-[A-Za-z]{3}-\\d{4} \\d{2}:\\d{2}:\\d{2}" + // 06-Jan-2026 10:35:23
|
||||
"|[A-Za-z]{3} \\d{2},? \\d{4} \\d{2}:\\d{2}:\\d{2}" + // Jan 06, 2026 10:35:23
|
||||
"|[A-Za-z]{3} \\d{2} \\d{2}:\\d{2}:\\d{2}" + // Jan 06 10:35:23
|
||||
"|\\[\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}[,.]?\\d{0,3}\\]" + // [2026-01-06 10:35:23]
|
||||
")" +
|
||||
"\\s*"
|
||||
);
|
||||
|
||||
/**
|
||||
* 요약 생성 (첫 줄, 최대 200자)
|
||||
* 요약 생성 (첫 줄, 최대 200자, 앞부분 날짜시간 제거)
|
||||
*/
|
||||
private String createSummary(String line) {
|
||||
if (line == null) return "";
|
||||
String trimmed = line.trim();
|
||||
|
||||
// 앞부분 날짜시간 제거
|
||||
Matcher dtMatcher = DATETIME_PREFIX_PATTERN.matcher(trimmed);
|
||||
if (dtMatcher.find()) {
|
||||
trimmed = trimmed.substring(dtMatcher.end()).trim();
|
||||
}
|
||||
|
||||
if (trimmed.length() <= 200) return trimmed;
|
||||
return trimmed.substring(0, 200) + "...";
|
||||
}
|
||||
|
||||
@@ -39,10 +39,14 @@ public class PatternService {
|
||||
@Transactional
|
||||
public PatternDto create(PatternDto dto) {
|
||||
validateRegex(dto.getRegex());
|
||||
if (dto.getExcludeRegex() != null && !dto.getExcludeRegex().isBlank()) {
|
||||
validateRegex(dto.getExcludeRegex());
|
||||
}
|
||||
|
||||
Pattern pattern = Pattern.builder()
|
||||
.name(dto.getName())
|
||||
.regex(dto.getRegex())
|
||||
.excludeRegex(dto.getExcludeRegex())
|
||||
.severity(dto.getSeverity() != null ? dto.getSeverity() : "ERROR")
|
||||
.contextLines(dto.getContextLines() != null ? dto.getContextLines() : 5)
|
||||
.description(dto.getDescription())
|
||||
@@ -56,12 +60,16 @@ public class PatternService {
|
||||
@Transactional
|
||||
public PatternDto update(Long id, PatternDto dto) {
|
||||
validateRegex(dto.getRegex());
|
||||
if (dto.getExcludeRegex() != null && !dto.getExcludeRegex().isBlank()) {
|
||||
validateRegex(dto.getExcludeRegex());
|
||||
}
|
||||
|
||||
Pattern pattern = patternRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Pattern not found: " + id));
|
||||
|
||||
pattern.setName(dto.getName());
|
||||
pattern.setRegex(dto.getRegex());
|
||||
pattern.setExcludeRegex(dto.getExcludeRegex());
|
||||
pattern.setSeverity(dto.getSeverity());
|
||||
pattern.setContextLines(dto.getContextLines());
|
||||
pattern.setDescription(dto.getDescription());
|
||||
@@ -107,6 +115,7 @@ public class PatternService {
|
||||
.id(pattern.getId())
|
||||
.name(pattern.getName())
|
||||
.regex(pattern.getRegex())
|
||||
.excludeRegex(pattern.getExcludeRegex())
|
||||
.severity(pattern.getSeverity())
|
||||
.contextLines(pattern.getContextLines())
|
||||
.description(pattern.getDescription())
|
||||
|
||||
@@ -7,17 +7,17 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import research.loghunter.entity.*;
|
||||
import research.loghunter.repository.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
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.*;
|
||||
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
|
||||
@@ -450,4 +450,339 @@ public class ScanService {
|
||||
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++; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,14 +190,20 @@ public class SftpService {
|
||||
return detail.toString();
|
||||
}
|
||||
|
||||
// 분석 대상 파일 최대 기간 (1개월)
|
||||
private static final int MAX_FILE_AGE_DAYS = 31;
|
||||
|
||||
/**
|
||||
* 지정된 경로의 모든 파일 목록 조회 (패턴 매칭 포함)
|
||||
* 지정된 경로의 모든 파일 목록 조회 (패턴 매칭 포함, 1개월 이내 파일만)
|
||||
*/
|
||||
public List<RemoteFile> listAllFiles(Server server, ServerLogPath logPath) {
|
||||
List<RemoteFile> files = new ArrayList<>();
|
||||
Session session = null;
|
||||
ChannelSftp channel = null;
|
||||
|
||||
// 1개월 전 기준 시간
|
||||
LocalDateTime cutoffTime = LocalDateTime.now().minusDays(MAX_FILE_AGE_DAYS);
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
@@ -212,6 +218,7 @@ public class SftpService {
|
||||
@SuppressWarnings("unchecked")
|
||||
Vector<ChannelSftp.LsEntry> entries = channel.ls(path);
|
||||
|
||||
int skippedOldFiles = 0;
|
||||
for (ChannelSftp.LsEntry entry : entries) {
|
||||
if (entry.getAttrs().isDir()) continue;
|
||||
|
||||
@@ -222,6 +229,12 @@ public class SftpService {
|
||||
LocalDateTime fileTime = LocalDateTime.ofInstant(
|
||||
Instant.ofEpochMilli(mtime), ZoneId.systemDefault());
|
||||
|
||||
// 1개월 이전 파일은 제외
|
||||
if (fileTime.isBefore(cutoffTime)) {
|
||||
skippedOldFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
files.add(new RemoteFile(
|
||||
path + (path.endsWith("/") ? "" : "/") + fileName,
|
||||
fileName,
|
||||
@@ -233,8 +246,8 @@ public class SftpService {
|
||||
// 최신 파일 순 정렬
|
||||
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());
|
||||
log.info("Found {} files (skipped {} old files > {} days) matching pattern '{}' in path '{}' on server {}",
|
||||
files.size(), skippedOldFiles, MAX_FILE_AGE_DAYS, filePattern, path, server.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to list files from server {}: {}", server.getId(), e.getMessage());
|
||||
|
||||
@@ -18,6 +18,14 @@ spring:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
|
||||
# 정적 리소스 캐시 비활성화
|
||||
web:
|
||||
resources:
|
||||
cache:
|
||||
cachecontrol:
|
||||
no-cache: true
|
||||
no-store: true
|
||||
|
||||
# 앱 설정
|
||||
app:
|
||||
crypto:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as r,d as u,a as d,e as c,b as t,t as m,s as n,k as f,m as p,T as _,i as a,j as y,n as h}from"./index-CZ3IEKgR.js";import"./index-jV6SX453.js";const v={class:"modal-header"},b={class:"modal-body"},g={key:0,class:"modal-footer"},k={__name:"Modal",props:{modelValue:{type:Boolean,default:!1},title:{type:String,default:""},width:{type:String,default:"500px"}},emits:["update:modelValue","close"],setup(e,{emit:s}){const o=s,i=()=>{o("update:modelValue",!1),o("close")};return(l,S)=>(a(),u(_,{to:"body"},[e.modelValue?(a(),d("div",{key:0,class:"modal-overlay",onClick:p(i,["self"])},[t("div",{class:"modal",style:f({width:e.width})},[t("div",v,[t("h3",null,m(e.title),1),t("button",{class:"close-btn",onClick:i},"×")]),t("div",b,[n(l.$slots,"default",{},void 0)]),l.$slots.footer?(a(),d("div",g,[n(l.$slots,"footer",{},void 0)])):c("",!0)],4)])):c("",!0)]))}},w=r(k,[["__scopeId","data-v-90993dd3"]]),B={__name:"Badge",props:{text:String,variant:{type:String,default:"default"}},setup(e){return(s,o)=>(a(),d("span",{class:h(["badge",`badge-${e.variant}`])},[n(s.$slots,"default",{},()=>[y(m(e.text),1)])],2))}},x=r(B,[["__scopeId","data-v-b7bd2350"]]);export{x as B,w as M};
|
||||
1
src/main/resources/static/assets/Badge-DzuJKoKk.js
Normal file
1
src/main/resources/static/assets/Badge-DzuJKoKk.js
Normal file
@@ -0,0 +1 @@
|
||||
import"./index-09HB4Lmg.js";import{_ as e,a as s,h as r,G as n,i as o,t as c,n as d}from"./index-BB0X_WMV.js";const l={__name:"Badge",props:{text:String,variant:{type:String,default:"default"}},setup(a){return(t,i)=>(r(),s("span",{class:d(["badge",`badge-${a.variant}`])},[n(t.$slots,"default",{},()=>[o(c(a.text),1)])],2))}},g=e(l,[["__scopeId","data-v-b7bd2350"]]);export{g as B};
|
||||
1
src/main/resources/static/assets/Button-CoXLXkk7.js
Normal file
1
src/main/resources/static/assets/Button-CoXLXkk7.js
Normal file
@@ -0,0 +1 @@
|
||||
import"./index-09HB4Lmg.js";import{_ as d,r,a,e as c,G as u,n as f,h as o}from"./index-BB0X_WMV.js";const m=["type","disabled"],b={key:0,class:"spinner"},y={__name:"Button",props:{type:{type:String,default:"button"},variant:{type:String,default:"primary"},size:{type:String,default:"md"},disabled:Boolean,loading:Boolean},emits:["click"],setup(e,{expose:l}){const n=r(null);return l({focus:()=>{var t;(t=n.value)==null||t.focus()}}),(t,s)=>(o(),a("button",{ref_key:"buttonRef",ref:n,type:e.type,class:f(["btn",`btn-${e.variant}`,{"btn-sm":e.size==="sm","btn-lg":e.size==="lg"}]),disabled:e.disabled||e.loading,onClick:s[0]||(s[0]=i=>t.$emit("click",i))},[e.loading?(o(),a("span",b)):c("",!0),u(t.$slots,"default",{},void 0)],10,m))}},B=d(y,[["__scopeId","data-v-c92354e1"]]);export{B};
|
||||
1
src/main/resources/static/assets/DailyStats-C_oquM7K.js
Normal file
1
src/main/resources/static/assets/DailyStats-C_oquM7K.js
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as C,r as g,o as x,a as l,b as s,l as w,s as I,t as d,F as B,g as T,h as o,d as R,w as m,f as L,u as _}from"./index-BB0X_WMV.js";import{a as A,C as F}from"./index-09HB4Lmg.js";import{C as O,a as W,L as N,B as V,p as E,b as M,c as $,d as P,e as z}from"./chartjs-plugin-datalabels.esm-DiDzp_cw.js";const U={class:"daily-stats"},Y={class:"page-header"},Z={class:"filter-section"},j={key:0,class:"loading"},q={key:1,class:"no-data"},G={key:2,class:"server-charts"},H={class:"chart-header"},J={class:"chart-subtitle"},K={class:"chart-wrapper"},Q={class:"chart-container"},X={__name:"DailyStats",setup(tt){O.register(W,N,V,E,M,$,P);const c=g(!1),r=g([]),n=g(f());function f(){return new Date().toISOString().split("T")[0]}function b(){const t=new Date(n.value);t.setDate(t.getDate()-1),n.value=t.toISOString().split("T")[0],i()}function y(){const t=new Date(n.value);t.setDate(t.getDate()+1),n.value=t.toISOString().split("T")[0],i()}const S={responsive:!0,maintainAspectRatio:!1,plugins:{legend:{position:"top"},tooltip:{mode:"index",intersect:!1},datalabels:{display:t=>{if(t.datasetIndex!==2)return!1;const e=t.chart.data.datasets,a=t.dataIndex;return e.reduce((u,p)=>u+(p.data[a]||0),0)>0},anchor:"end",align:"end",offset:0,font:{size:9},color:"#666",formatter:(t,e)=>{const a=e.chart.data.datasets,v=e.dataIndex;return a.reduce((u,p)=>u+(p.data[v]||0),0)}}},scales:{x:{stacked:!0,ticks:{maxRotation:0,autoSkip:!0,maxTicksLimit:24,callback:function(t,e){const a=this.getLabelForValue(t);return a&&a.endsWith(":00")?a:""}},grid:{display:!1}},y:{stacked:!0,beginAtZero:!0}},barPercentage:.8,categoryPercentage:.9},D=t=>({labels:t.timeStats.map(a=>a.time),datasets:[{label:"CRITICAL",data:t.timeStats.map(a=>a.critical),backgroundColor:"#9b59b6",borderWidth:0},{label:"ERROR",data:t.timeStats.map(a=>a.error),backgroundColor:"#e74c3c",borderWidth:0},{label:"WARN",data:t.timeStats.map(a=>a.warn),backgroundColor:"#f39c12",borderWidth:0}]}),i=async()=>{c.value=!0;try{r.value=await A.getTimeStatsByServer(n.value,15)}catch(t){console.error("Failed to load stats:",t),r.value=[]}finally{c.value=!1}},h=t=>{const e=new Date(t);return`${e.getFullYear()}년 ${e.getMonth()+1}월 ${e.getDate()}일`},k=t=>t.timeStats.reduce((e,a)=>e+a.total,0);return x(()=>{i()}),(t,e)=>(o(),l("div",U,[s("div",Y,[e[1]||(e[1]=s("h2",null,"일별 에러현황",-1)),s("div",Z,[s("button",{class:"nav-btn",onClick:b},"◀ 이전"),w(s("input",{type:"date","onUpdate:modelValue":e[0]||(e[0]=a=>n.value=a),onChange:i},null,544),[[I,n.value]]),s("button",{class:"nav-btn",onClick:y},"다음 ▶")])]),c.value?(o(),l("div",j,[...e[2]||(e[2]=[s("p",null,"로딩중...",-1)])])):r.value.length===0?(o(),l("div",q,[s("p",null,d(h(n.value))+"에 분석된 에러 데이터가 없습니다.",1)])):(o(),l("div",G,[(o(!0),l(B,null,T(r.value,a=>(o(),R(_(F),{key:a.serverId,class:"server-chart-card"},{header:m(()=>[s("div",H,[s("h3",null,"🖥️ "+d(a.serverName),1),s("span",J,d(h(n.value))+" 15분 단위 에러 ("+d(k(a))+"건)",1)])]),default:m(()=>[s("div",K,[s("div",Q,[L(_(z),{data:D(a),options:S},null,8,["data"])])])]),_:2},1024))),128))]))]))}},nt=C(X,[["__scopeId","data-v-b7241be8"]]);export{nt as default};
|
||||
1
src/main/resources/static/assets/DailyStats-DaWyYZ7V.css
Normal file
1
src/main/resources/static/assets/DailyStats-DaWyYZ7V.css
Normal file
@@ -0,0 +1 @@
|
||||
.page-header[data-v-b7241be8]{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.page-header h2[data-v-b7241be8]{margin:0}.filter-section[data-v-b7241be8]{display:flex;align-items:center;gap:8px}.filter-section input[type=date][data-v-b7241be8]{padding:10px 16px;border:1px solid #ddd;border-radius:6px;font-size:14px;cursor:pointer}.filter-section input[type=date][data-v-b7241be8]:focus{outline:none;border-color:#3498db}.nav-btn[data-v-b7241be8]{padding:10px 16px;border:1px solid #ddd;border-radius:6px;background:#fff;cursor:pointer;font-size:14px;transition:all .2s}.nav-btn[data-v-b7241be8]:hover{background:#f0f0f0;border-color:#3498db}.loading[data-v-b7241be8],.no-data[data-v-b7241be8]{text-align:center;padding:60px;color:#666;background:#fff;border-radius:8px}.server-charts[data-v-b7241be8]{display:flex;flex-direction:column;gap:20px}.server-chart-card[data-v-b7241be8]{width:100%}.chart-header[data-v-b7241be8]{display:flex;align-items:center;gap:12px}.chart-header h3[data-v-b7241be8]{margin:0;font-size:16px}.chart-subtitle[data-v-b7241be8]{font-size:13px;color:#888}.chart-wrapper[data-v-b7241be8]{overflow-x:auto}.chart-container[data-v-b7241be8]{height:220px;min-width:100%;padding:8px 0}
|
||||
1
src/main/resources/static/assets/Dashboard-CILG6x2p.css
Normal file
1
src/main/resources/static/assets/Dashboard-CILG6x2p.css
Normal file
@@ -0,0 +1 @@
|
||||
.dashboard-header[data-v-abd43acf]{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.dashboard-header h2[data-v-abd43acf]{margin:0}.server-grid[data-v-abd43acf]{display:grid;grid-template-columns:repeat(auto-fill,minmax(350px,1fr));gap:20px;margin-bottom:24px}.server-card[data-v-abd43acf]{transition:box-shadow .2s}.server-card[data-v-abd43acf]:hover{box-shadow:0 4px 12px #00000026}.server-header[data-v-abd43acf]{display:flex;justify-content:space-between;align-items:center}.server-title[data-v-abd43acf]{display:flex;align-items:center;gap:10px}.server-title h4[data-v-abd43acf]{margin:0;font-size:16px}.server-info[data-v-abd43acf]{margin-bottom:12px}.info-row[data-v-abd43acf]{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #f0f0f0}.info-row[data-v-abd43acf]:last-child{border-bottom:none}.info-row .label[data-v-abd43acf]{color:#666;font-size:13px}.info-row .value[data-v-abd43acf]{font-weight:500}.info-row .value.has-error[data-v-abd43acf]{color:#e74c3c}.progress-section[data-v-abd43acf]{padding:12px;background:#f8f9fa;border-radius:8px;margin-top:12px}.progress-header[data-v-abd43acf]{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.status-text[data-v-abd43acf]{font-size:13px;color:#333}.progress-bar-container[data-v-abd43acf]{height:8px;background:#e0e0e0;border-radius:4px;overflow:hidden;margin-bottom:8px}.progress-bar[data-v-abd43acf]{height:100%;background:#3498db;transition:width .3s}.progress-details[data-v-abd43acf]{display:flex;justify-content:space-between;font-size:12px;color:#666}.empty-card[data-v-abd43acf]{text-align:center}.empty-content[data-v-abd43acf]{padding:40px 20px}.empty-content p[data-v-abd43acf]{margin-bottom:16px;color:#666}.daily-charts[data-v-abd43acf]{margin-top:32px}.daily-charts h3[data-v-abd43acf]{margin-bottom:16px;font-size:18px}.chart-list[data-v-abd43acf]{display:flex;flex-direction:column;gap:16px}.chart-card[data-v-abd43acf]{width:100%}.chart-header[data-v-abd43acf]{display:flex;justify-content:space-between;align-items:center}.chart-total[data-v-abd43acf]{font-size:13px;color:#888}.chart-container[data-v-abd43acf]{height:200px;padding:8px 0}
|
||||
1
src/main/resources/static/assets/Dashboard-CWQb9z6Z.js
Normal file
1
src/main/resources/static/assets/Dashboard-CWQb9z6Z.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.dashboard-header[data-v-586380a4]{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.dashboard-header h2[data-v-586380a4]{margin:0}.server-grid[data-v-586380a4]{display:grid;grid-template-columns:repeat(auto-fill,minmax(350px,1fr));gap:20px;margin-bottom:24px}.server-card[data-v-586380a4]{transition:box-shadow .2s}.server-card[data-v-586380a4]:hover{box-shadow:0 4px 12px #00000026}.server-header[data-v-586380a4]{display:flex;justify-content:space-between;align-items:center}.server-title[data-v-586380a4]{display:flex;align-items:center;gap:10px}.server-title h4[data-v-586380a4]{margin:0;font-size:16px}.server-info[data-v-586380a4]{margin-bottom:12px}.info-row[data-v-586380a4]{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #f0f0f0}.info-row[data-v-586380a4]:last-child{border-bottom:none}.info-row .label[data-v-586380a4]{color:#666;font-size:13px}.info-row .value[data-v-586380a4]{font-weight:500}.info-row .value.has-error[data-v-586380a4]{color:#e74c3c}.progress-section[data-v-586380a4]{padding:12px;background:#f8f9fa;border-radius:8px;margin-top:12px}.progress-header[data-v-586380a4]{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.status-text[data-v-586380a4]{font-size:13px;color:#333}.progress-bar-container[data-v-586380a4]{height:8px;background:#e0e0e0;border-radius:4px;overflow:hidden;margin-bottom:8px}.progress-bar[data-v-586380a4]{height:100%;background:#3498db;transition:width .3s}.progress-details[data-v-586380a4]{display:flex;justify-content:space-between;font-size:12px;color:#666}.empty-card[data-v-586380a4]{text-align:center}.empty-content[data-v-586380a4]{padding:40px 20px}.empty-content p[data-v-586380a4]{margin-bottom:16px;color:#666}.recent-errors[data-v-586380a4]{margin-top:24px}.section-header[data-v-586380a4]{display:flex;justify-content:space-between;align-items:center}.section-header h3[data-v-586380a4]{margin:0}.error-table[data-v-586380a4]{width:100%;border-collapse:collapse}.error-table th[data-v-586380a4],.error-table td[data-v-586380a4]{padding:12px;text-align:left;border-bottom:1px solid #eee}.error-table th[data-v-586380a4]{background:#f8f9fa;font-weight:600;font-size:13px}.error-table tbody tr[data-v-586380a4]{cursor:pointer;transition:background .2s}.error-table tbody tr[data-v-586380a4]:hover{background:#f8f9fa}.summary-cell[data-v-586380a4]{max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.error-detail[data-v-586380a4]{max-height:60vh;overflow-y:auto}.detail-grid[data-v-586380a4]{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px}.detail-item[data-v-586380a4]{display:flex;flex-direction:column;gap:4px}.detail-item label[data-v-586380a4]{font-size:12px;color:#666}.detail-section[data-v-586380a4]{margin-bottom:16px}.detail-section label[data-v-586380a4]{display:block;font-size:12px;color:#666;margin-bottom:8px}.summary-box[data-v-586380a4]{padding:12px;background:#f8f9fa;border-radius:4px;font-size:14px}.context-box[data-v-586380a4]{padding:12px;background:#2d2d2d;color:#f8f8f2;border-radius:4px;font-size:12px;line-height:1.5;overflow-x:auto;white-space:pre;margin:0}
|
||||
@@ -1 +1 @@
|
||||
import"./index-jV6SX453.js";import{_ as h,a as e,i as a,b as o,e as u,F as d,g as i,k as f,t as c,s as y,j as g}from"./index-CZ3IEKgR.js";const b={class:"data-table-wrapper"},p={class:"data-table"},$={key:0,class:"actions-col"},_={key:0},D=["colspan"],S={key:1},T=["colspan"],B=["onClick"],N={key:0,class:"actions-col"},V={__name:"DataTable",props:{columns:{type:Array,required:!0},data:{type:Array,default:()=>[]},loading:{type:Boolean,default:!1},emptyText:{type:String,default:"데이터가 없습니다."}},emits:["row-click"],setup(n){const k=(t,r)=>t==null?"-":r.type==="date"&&t?new Date(t).toLocaleString("ko-KR"):r.type==="boolean"?t?"Y":"N":t;return(t,r)=>(a(),e("div",b,[o("table",p,[o("thead",null,[o("tr",null,[(a(!0),e(d,null,i(n.columns,s=>(a(),e("th",{key:s.key,style:f({width:s.width})},c(s.label),5))),128)),t.$slots.actions?(a(),e("th",$,"작업")):u("",!0)])]),o("tbody",null,[n.loading?(a(),e("tr",_,[o("td",{colspan:n.columns.length+(t.$slots.actions?1:0),class:"loading-cell"}," 로딩 중... ",8,D)])):!n.data||n.data.length===0?(a(),e("tr",S,[o("td",{colspan:n.columns.length+(t.$slots.actions?1:0),class:"empty-cell"},c(n.emptyText),9,T)])):(a(!0),e(d,{key:2},i(n.data,(s,m)=>(a(),e("tr",{key:s.id||m,onClick:l=>t.$emit("row-click",s)},[(a(!0),e(d,null,i(n.columns,l=>(a(),e("td",{key:l.key},[y(t.$slots,l.key,{row:s,value:s[l.key]},()=>[g(c(k(s[l.key],l)),1)])]))),128)),t.$slots.actions?(a(),e("td",N,[y(t.$slots,"actions",{row:s},void 0)])):u("",!0)],8,B))),128))])])]))}},A=h(V,[["__scopeId","data-v-db5e24a9"]]);export{A as D};
|
||||
import"./index-09HB4Lmg.js";import{_ as h,a as e,h as a,b as o,e as u,F as d,g as i,j as f,t as c,G as y,i as g}from"./index-BB0X_WMV.js";const b={class:"data-table-wrapper"},p={class:"data-table"},$={key:0,class:"actions-col"},_={key:0},D=["colspan"],S={key:1},T=["colspan"],B=["onClick"],N={key:0,class:"actions-col"},V={__name:"DataTable",props:{columns:{type:Array,required:!0},data:{type:Array,default:()=>[]},loading:{type:Boolean,default:!1},emptyText:{type:String,default:"데이터가 없습니다."}},emits:["row-click"],setup(n){const m=(t,r)=>t==null?"-":r.type==="date"&&t?new Date(t).toLocaleString("ko-KR"):r.type==="boolean"?t?"Y":"N":t;return(t,r)=>(a(),e("div",b,[o("table",p,[o("thead",null,[o("tr",null,[(a(!0),e(d,null,i(n.columns,s=>(a(),e("th",{key:s.key,style:f({width:s.width})},c(s.label),5))),128)),t.$slots.actions?(a(),e("th",$,"작업")):u("",!0)])]),o("tbody",null,[n.loading?(a(),e("tr",_,[o("td",{colspan:n.columns.length+(t.$slots.actions?1:0),class:"loading-cell"}," 로딩 중... ",8,D)])):!n.data||n.data.length===0?(a(),e("tr",S,[o("td",{colspan:n.columns.length+(t.$slots.actions?1:0),class:"empty-cell"},c(n.emptyText),9,T)])):(a(!0),e(d,{key:2},i(n.data,(s,k)=>(a(),e("tr",{key:s.id||k,onClick:l=>t.$emit("row-click",s)},[(a(!0),e(d,null,i(n.columns,l=>(a(),e("td",{key:l.key},[y(t.$slots,l.key,{row:s,value:s[l.key]},()=>[g(c(m(s[l.key],l)),1)])]))),128)),t.$slots.actions?(a(),e("td",N,[y(t.$slots,"actions",{row:s},void 0)])):u("",!0)],8,B))),128))])])]))}},A=h(V,[["__scopeId","data-v-db5e24a9"]]);export{A as D};
|
||||
1
src/main/resources/static/assets/ErrorLogs-BZmKr5Li.css
Normal file
1
src/main/resources/static/assets/ErrorLogs-BZmKr5Li.css
Normal file
File diff suppressed because one or more lines are too long
1
src/main/resources/static/assets/ErrorLogs-C4tZmC_2.js
Normal file
1
src/main/resources/static/assets/ErrorLogs-C4tZmC_2.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.card-header-content[data-v-ccc22999]{display:flex;justify-content:space-between;align-items:center}.card-header-content h3[data-v-ccc22999]{margin:0}.header-actions[data-v-ccc22999]{display:flex;gap:8px}.header-actions[data-v-ccc22999] .btn{white-space:nowrap;min-width:60px}.filters[data-v-ccc22999]{padding:16px;background:#f8f9fa;border-radius:8px;margin-bottom:20px}.filter-row[data-v-ccc22999]{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:12px}.filter-row[data-v-ccc22999]:last-child{margin-bottom:0}.filter-actions[data-v-ccc22999]{display:flex;align-items:flex-end;gap:8px;padding-bottom:4px}.filter-actions[data-v-ccc22999] .btn{white-space:nowrap}.results-section[data-v-ccc22999]{margin-top:16px}.results-header[data-v-ccc22999]{margin-bottom:12px;color:#666;font-size:14px}.table-wrapper[data-v-ccc22999]{overflow-x:auto}.error-table[data-v-ccc22999]{width:100%;border-collapse:collapse;table-layout:fixed}.error-table th[data-v-ccc22999],.error-table td[data-v-ccc22999]{padding:10px 8px;text-align:left;border-bottom:1px solid #eee;overflow:hidden;text-overflow:ellipsis}.error-table th[data-v-ccc22999]{background:#f8f9fa;font-weight:600;font-size:13px;white-space:nowrap}.error-table tbody tr[data-v-ccc22999]:hover{background:#fafafa}.col-time[data-v-ccc22999]{width:140px;white-space:nowrap}.col-server[data-v-ccc22999]{width:130px;white-space:nowrap}.col-severity[data-v-ccc22999]{width:90px;white-space:nowrap}.col-pattern[data-v-ccc22999]{width:120px;white-space:nowrap}.col-summary[data-v-ccc22999]{min-width:200px}.col-action[data-v-ccc22999]{width:70px;text-align:center}.col-action[data-v-ccc22999] .btn{white-space:nowrap;padding:4px 12px}.empty-result[data-v-ccc22999],.loading-result[data-v-ccc22999]{text-align:center;padding:40px;color:#666}.pagination[data-v-ccc22999]{display:flex;justify-content:center;align-items:center;gap:16px;margin-top:20px;padding-top:16px;border-top:1px solid #eee}.pagination[data-v-ccc22999] .btn{white-space:nowrap;min-width:50px}.page-info[data-v-ccc22999]{font-size:14px;color:#666}.error-detail[data-v-ccc22999]{max-height:65vh;overflow-y:auto}.detail-grid[data-v-ccc22999]{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px}.detail-item[data-v-ccc22999]{display:flex;flex-direction:column;gap:4px}.detail-item label[data-v-ccc22999]{font-size:12px;color:#666}.file-path[data-v-ccc22999]{word-break:break-all;font-family:monospace;font-size:13px}.detail-section[data-v-ccc22999]{margin-bottom:16px}.detail-section label[data-v-ccc22999]{display:block;font-size:12px;color:#666;margin-bottom:8px}.summary-box[data-v-ccc22999]{padding:12px;background:#f8f9fa;border-radius:4px;font-size:14px;word-break:break-all}.context-box[data-v-ccc22999]{padding:12px;background:#1e1e1e;color:#d4d4d4;border-radius:4px;font-size:12px;line-height:1.6;overflow-x:auto;white-space:pre;margin:0;max-height:300px;overflow-y:auto}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import"./index-jV6SX453.js";import{_ as i,c as s,a as t,i as l,e as o,j as c,t as n,F as m,g as y}from"./index-CZ3IEKgR.js";const h={class:"form-group"},v=["for"],b={key:0,class:"required"},g=["id","type","value","placeholder","disabled","readonly"],f=["id","value","placeholder","disabled","readonly","rows"],k=["id","value","disabled"],V={key:0,value:""},S=["value"],x={key:4,class:"error-text"},I={key:5,class:"hint-text"},B={__name:"FormInput",props:{modelValue:{type:[String,Number],default:""},label:String,type:{type:String,default:"text"},placeholder:String,required:Boolean,disabled:Boolean,readonly:Boolean,error:String,hint:String,rows:{type:Number,default:3},options:{type:Array,default:()=>[]}},emits:["update:modelValue"],setup(e){const r=s(()=>`input-${Math.random().toString(36).slice(2,9)}`);return(u,d)=>(l(),t("div",h,[e.label?(l(),t("label",{key:0,for:r.value},[c(n(e.label)+" ",1),e.required?(l(),t("span",b,"*")):o("",!0)],8,v)):o("",!0),e.type!=="textarea"&&e.type!=="select"?(l(),t("input",{key:1,id:r.value,type:e.type,value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,readonly:e.readonly,class:"form-input",onInput:d[0]||(d[0]=a=>u.$emit("update:modelValue",a.target.value))},null,40,g)):e.type==="textarea"?(l(),t("textarea",{key:2,id:r.value,value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,readonly:e.readonly,rows:e.rows,class:"form-input",onInput:d[1]||(d[1]=a=>u.$emit("update:modelValue",a.target.value))},null,40,f)):e.type==="select"?(l(),t("select",{key:3,id:r.value,value:e.modelValue,disabled:e.disabled,class:"form-input",onChange:d[2]||(d[2]=a=>u.$emit("update:modelValue",a.target.value))},[e.placeholder?(l(),t("option",V,n(e.placeholder),1)):o("",!0),(l(!0),t(m,null,y(e.options,a=>(l(),t("option",{key:a.value,value:a.value},n(a.label),9,S))),128))],40,k)):o("",!0),e.error?(l(),t("span",x,n(e.error),1)):o("",!0),e.hint?(l(),t("span",I,n(e.hint),1)):o("",!0)]))}},N=i(B,[["__scopeId","data-v-45f49038"]]);export{N as F};
|
||||
import"./index-09HB4Lmg.js";import{_ as i,c as s,a as t,h as l,e as o,i as c,t as n,F as m,g as y}from"./index-BB0X_WMV.js";const h={class:"form-group"},v=["for"],b={key:0,class:"required"},g=["id","type","value","placeholder","disabled","readonly"],f=["id","value","placeholder","disabled","readonly","rows"],k=["id","value","disabled"],V={key:0,value:""},S=["value"],x={key:4,class:"error-text"},I={key:5,class:"hint-text"},B={__name:"FormInput",props:{modelValue:{type:[String,Number],default:""},label:String,type:{type:String,default:"text"},placeholder:String,required:Boolean,disabled:Boolean,readonly:Boolean,error:String,hint:String,rows:{type:Number,default:3},options:{type:Array,default:()=>[]}},emits:["update:modelValue"],setup(e){const r=s(()=>`input-${Math.random().toString(36).slice(2,9)}`);return(u,d)=>(l(),t("div",h,[e.label?(l(),t("label",{key:0,for:r.value},[c(n(e.label)+" ",1),e.required?(l(),t("span",b,"*")):o("",!0)],8,v)):o("",!0),e.type!=="textarea"&&e.type!=="select"?(l(),t("input",{key:1,id:r.value,type:e.type,value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,readonly:e.readonly,class:"form-input",onInput:d[0]||(d[0]=a=>u.$emit("update:modelValue",a.target.value))},null,40,g)):e.type==="textarea"?(l(),t("textarea",{key:2,id:r.value,value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,readonly:e.readonly,rows:e.rows,class:"form-input",onInput:d[1]||(d[1]=a=>u.$emit("update:modelValue",a.target.value))},null,40,f)):e.type==="select"?(l(),t("select",{key:3,id:r.value,value:e.modelValue,disabled:e.disabled,class:"form-input",onChange:d[2]||(d[2]=a=>u.$emit("update:modelValue",a.target.value))},[e.placeholder?(l(),t("option",V,n(e.placeholder),1)):o("",!0),(l(!0),t(m,null,y(e.options,a=>(l(),t("option",{key:a.value,value:a.value},n(a.label),9,S))),128))],40,k)):o("",!0),e.error?(l(),t("span",x,n(e.error),1)):o("",!0),e.hint?(l(),t("span",I,n(e.hint),1)):o("",!0)]))}},N=i(B,[["__scopeId","data-v-45f49038"]]);export{N as F};
|
||||
1
src/main/resources/static/assets/Modal-iKIn6dGt.js
Normal file
1
src/main/resources/static/assets/Modal-iKIn6dGt.js
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as r,d as m,a as d,e as i,b as e,t as u,G as c,j as f,m as p,T as h,h as s}from"./index-BB0X_WMV.js";import"./index-09HB4Lmg.js";const y={class:"modal-header"},_={class:"modal-body"},k={key:0,class:"modal-footer"},v={__name:"Modal",props:{modelValue:{type:Boolean,default:!1},title:{type:String,default:""},width:{type:String,default:"500px"}},emits:["update:modelValue","close"],setup(t,{emit:n}){const a=n,l=()=>{a("update:modelValue",!1),a("close")};return(o,V)=>(s(),m(h,{to:"body"},[t.modelValue?(s(),d("div",{key:0,class:"modal-overlay",onClick:p(l,["self"])},[e("div",{class:"modal",style:f({width:t.width})},[e("div",y,[e("h3",null,u(t.title),1),e("button",{class:"close-btn",onClick:l},"×")]),e("div",_,[c(o.$slots,"default",{},void 0)]),o.$slots.footer?(s(),d("div",k,[c(o.$slots,"footer",{},void 0)])):i("",!0)],4)])):i("",!0)]))}},S=r(v,[["__scopeId","data-v-90993dd3"]]);export{S as M};
|
||||
@@ -0,0 +1 @@
|
||||
.page-header[data-v-7c1c78fe]{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.page-header h2[data-v-7c1c78fe]{margin:0}.filter-section[data-v-7c1c78fe]{display:flex;align-items:center;gap:8px}.filter-section input[type=month][data-v-7c1c78fe]{padding:10px 16px;border:1px solid #ddd;border-radius:6px;font-size:14px;cursor:pointer}.filter-section input[type=month][data-v-7c1c78fe]:focus{outline:none;border-color:#3498db}.nav-btn[data-v-7c1c78fe]{padding:10px 16px;border:1px solid #ddd;border-radius:6px;background:#fff;cursor:pointer;font-size:14px;transition:all .2s}.nav-btn[data-v-7c1c78fe]:hover{background:#f0f0f0;border-color:#3498db}.loading[data-v-7c1c78fe],.no-data[data-v-7c1c78fe]{text-align:center;padding:60px;color:#666;background:#fff;border-radius:8px}.server-charts[data-v-7c1c78fe]{display:flex;flex-direction:column;gap:20px}.server-chart-card[data-v-7c1c78fe]{width:100%}.chart-header[data-v-7c1c78fe]{display:flex;align-items:center;gap:12px}.chart-header h3[data-v-7c1c78fe]{margin:0;font-size:16px}.chart-subtitle[data-v-7c1c78fe]{font-size:13px;color:#888}.chart-container[data-v-7c1c78fe]{height:220px;padding:8px 0}
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as M,r as g,o as w,a as l,b as s,l as x,s as R,t as c,F as $,g as B,h as o,d as D,w as v,f as I,u as _}from"./index-BB0X_WMV.js";import{a as N,C as F}from"./index-09HB4Lmg.js";import{C as A,a as L,L as E,B as T,p as V,b as Y,c as O,d as z,e as U}from"./chartjs-plugin-datalabels.esm-DiDzp_cw.js";const W={class:"monthly-stats"},Z={class:"page-header"},j={class:"filter-section"},q={key:0,class:"loading"},G={key:1,class:"no-data"},H={key:2,class:"server-charts"},J={class:"chart-header"},K={class:"chart-subtitle"},P={class:"chart-container"},Q={__name:"MonthlyStats",setup(X){A.register(L,E,T,V,Y,O,z);const i=g(!1),r=g([]),n=g(f());function f(){const a=new Date;return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}`}function b(){const[a,e]=n.value.split("-").map(Number),t=new Date(a,e-2,1);n.value=`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}`,d()}function y(){const[a,e]=n.value.split("-").map(Number),t=new Date(a,e,1);n.value=`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}`,d()}const S={responsive:!0,maintainAspectRatio:!1,plugins:{legend:{position:"top"},tooltip:{mode:"index",intersect:!1},datalabels:{display:a=>{if(a.datasetIndex!==2)return!1;const e=a.chart.data.datasets,t=a.dataIndex;return e.reduce((p,h)=>p+(h.data[t]||0),0)>0},anchor:"end",align:"end",offset:2,font:{size:10,weight:"bold"},color:"#666",formatter:(a,e)=>{const t=e.chart.data.datasets,u=e.dataIndex;return t.reduce((p,h)=>p+(h.data[u]||0),0)}}},scales:{x:{stacked:!0},y:{stacked:!0,beginAtZero:!0}}},C=a=>({labels:a.dailyStats.map(t=>`${new Date(t.date).getDate()}일`),datasets:[{label:"CRITICAL",data:a.dailyStats.map(t=>t.critical),backgroundColor:"#9b59b6",borderRadius:2},{label:"ERROR",data:a.dailyStats.map(t=>t.error),backgroundColor:"#e74c3c",borderRadius:2},{label:"WARN",data:a.dailyStats.map(t=>t.warn),backgroundColor:"#f39c12",borderRadius:2}]}),d=async()=>{i.value=!0;try{const[a,e]=n.value.split("-").map(Number);r.value=await N.getMonthlyStatsByServer(a,e)}catch(a){console.error("Failed to load stats:",a),r.value=[]}finally{i.value=!1}},m=a=>{const[e,t]=a.split("-");return`${e}년 ${parseInt(t)}월`},k=a=>a.dailyStats.reduce((e,t)=>e+t.total,0);return w(()=>{d()}),(a,e)=>(o(),l("div",W,[s("div",Z,[e[1]||(e[1]=s("h2",null,"월별 에러현황",-1)),s("div",j,[s("button",{class:"nav-btn",onClick:b},"◀ 이전"),x(s("input",{type:"month","onUpdate:modelValue":e[0]||(e[0]=t=>n.value=t),onChange:d},null,544),[[R,n.value]]),s("button",{class:"nav-btn",onClick:y},"다음 ▶")])]),i.value?(o(),l("div",q,[...e[2]||(e[2]=[s("p",null,"로딩중...",-1)])])):r.value.length===0?(o(),l("div",G,[s("p",null,c(m(n.value))+"에 분석된 에러 데이터가 없습니다.",1)])):(o(),l("div",H,[(o(!0),l($,null,B(r.value,t=>(o(),D(_(F),{key:t.serverId,class:"server-chart-card"},{header:v(()=>[s("div",J,[s("h3",null,"🖥️ "+c(t.serverName),1),s("span",K,c(m(n.value))+" 일별 에러 ("+c(k(t))+"건)",1)])]),default:v(()=>[s("div",P,[I(_(U),{data:C(t),options:S},null,8,["data"])])]),_:2},1024))),128))]))]))}},st=M(Q,[["__scopeId","data-v-7c1c78fe"]]);export{st as default};
|
||||
@@ -0,0 +1 @@
|
||||
.card-header-content[data-v-fc6fccb5]{display:flex;justify-content:space-between;align-items:center}.card-header-content h3[data-v-fc6fccb5]{margin:0}.action-buttons[data-v-fc6fccb5]{display:flex;gap:4px}.regex-code[data-v-fc6fccb5]{font-family:monospace;background:#f1f3f4;padding:2px 6px;border-radius:3px;font-size:12px}.form-group[data-v-fc6fccb5]{margin-bottom:16px}.form-group label[data-v-fc6fccb5]{display:flex;align-items:center;gap:8px;cursor:pointer}.test-section[data-v-fc6fccb5]{display:flex;flex-direction:column;gap:16px}.test-pattern label[data-v-fc6fccb5]{display:block;font-weight:500;margin-bottom:6px}.regex-display[data-v-fc6fccb5]{display:block;font-family:monospace;background:#f8f9fa;padding:12px;border-radius:4px;font-size:13px;word-break:break-all}.test-result[data-v-fc6fccb5]{padding:16px;border-radius:8px;margin-top:8px}.test-result.success[data-v-fc6fccb5]{background:#d4edda;border:1px solid #c3e6cb}.test-result.fail[data-v-fc6fccb5]{background:#f8d7da;border:1px solid #f5c6cb}.test-result h4[data-v-fc6fccb5]{margin:0 0 12px}.test-result p[data-v-fc6fccb5]{margin:0}.match-info[data-v-fc6fccb5]{margin-top:8px}.match-info label[data-v-fc6fccb5]{font-weight:500;margin-right:8px}.match-info code[data-v-fc6fccb5]{background:#0000001a;padding:2px 6px;border-radius:3px}.error-msg[data-v-fc6fccb5]{color:#721c24}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.card-header-content[data-v-b6e855fd]{display:flex;justify-content:space-between;align-items:center}.card-header-content h3[data-v-b6e855fd]{margin:0}.action-buttons[data-v-b6e855fd]{display:flex;gap:4px}.regex-code[data-v-b6e855fd]{font-family:monospace;background:#f1f3f4;padding:2px 6px;border-radius:3px;font-size:12px}.form-group[data-v-b6e855fd]{margin-bottom:16px}.form-group label[data-v-b6e855fd]{display:flex;align-items:center;gap:8px;cursor:pointer}.test-section[data-v-b6e855fd]{display:flex;flex-direction:column;gap:16px}.test-pattern label[data-v-b6e855fd]{display:block;font-weight:500;margin-bottom:6px}.regex-display[data-v-b6e855fd]{display:block;font-family:monospace;background:#f8f9fa;padding:12px;border-radius:4px;font-size:13px;word-break:break-all}.test-result[data-v-b6e855fd]{padding:16px;border-radius:8px;margin-top:8px}.test-result.success[data-v-b6e855fd]{background:#d4edda;border:1px solid #c3e6cb}.test-result.fail[data-v-b6e855fd]{background:#f8d7da;border:1px solid #f5c6cb}.test-result h4[data-v-b6e855fd]{margin:0 0 12px}.test-result p[data-v-b6e855fd]{margin:0}.match-info[data-v-b6e855fd]{margin-top:8px}.match-info label[data-v-b6e855fd]{font-weight:500;margin-right:8px}.match-info code[data-v-b6e855fd]{background:#0000001a;padding:2px 6px;border-radius:3px}.error-msg[data-v-b6e855fd]{color:#721c24}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
src/main/resources/static/assets/Settings-BmmyOJaT.js
Normal file
1
src/main/resources/static/assets/Settings-BmmyOJaT.js
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as S,r as m,o as F,a as p,f as a,w as i,u as n,h as v,m as B,b as t,i as c}from"./index-BB0X_WMV.js";import{C as V,b}from"./index-09HB4Lmg.js";import{F as u}from"./FormInput-BwPgkOAn.js";import{B as y}from"./Button-CoXLXkk7.js";const k={class:"settings"},w={key:0,class:"loading"},C={class:"setting-section"},U={class:"setting-section"},h={class:"setting-section"},M={class:"setting-section"},z={class:"form-actions"},A={__name:"Settings",setup(N){const r=m(!1),d=m(!1),l=m({}),f={"server.port":"8080","export.path":"./exports","retention.days":"90","scan.timeout":"30","scan.maxFileSize":"100"},g=async()=>{r.value=!0;try{const o=await b.getAllAsMap();l.value={...f,...o}}catch(o){console.error("Failed to load settings:",o),l.value={...f}}finally{r.value=!1}},x=async()=>{d.value=!0;try{for(const[o,e]of Object.entries(l.value))await b.save({key:o,value:String(e)});alert("설정이 저장되었습니다.")}catch(o){console.error("Failed to save settings:",o),alert("설정 저장에 실패했습니다.")}finally{d.value=!1}};return F(()=>{g()}),(o,e)=>(v(),p("div",k,[a(n(V),null,{header:i(()=>[...e[5]||(e[5]=[t("div",{class:"card-header-content"},[t("h3",null,"설정")],-1)])]),default:i(()=>[r.value?(v(),p("div",w,"로딩중...")):(v(),p("form",{key:1,onSubmit:B(x,["prevent"]),class:"settings-form"},[t("div",C,[e[6]||(e[6]=t("h4",null,"일반 설정",-1)),a(n(u),{modelValue:l.value["server.port"],"onUpdate:modelValue":e[0]||(e[0]=s=>l.value["server.port"]=s),label:"서버 포트",type:"number",hint:"애플리케이션이 실행될 포트 번호 (기본: 8080)"},null,8,["modelValue"])]),t("div",U,[e[7]||(e[7]=t("h4",null,"내보내기 설정",-1)),a(n(u),{modelValue:l.value["export.path"],"onUpdate:modelValue":e[1]||(e[1]=s=>l.value["export.path"]=s),label:"내보내기 경로",placeholder:"예: C:\\LogHunter\\exports",hint:"리포트 파일이 저장될 기본 경로"},null,8,["modelValue"])]),t("div",h,[e[8]||(e[8]=t("h4",null,"데이터 관리",-1)),a(n(u),{modelValue:l.value["retention.days"],"onUpdate:modelValue":e[2]||(e[2]=s=>l.value["retention.days"]=s),label:"로그 보관 기간 (일)",type:"number",hint:"에러 로그 데이터 보관 기간 (0 = 무제한)"},null,8,["modelValue"])]),t("div",M,[e[9]||(e[9]=t("h4",null,"스캔 설정",-1)),a(n(u),{modelValue:l.value["scan.timeout"],"onUpdate:modelValue":e[3]||(e[3]=s=>l.value["scan.timeout"]=s),label:"스캔 타임아웃 (초)",type:"number",hint:"SFTP 연결 및 파일 다운로드 타임아웃"},null,8,["modelValue"]),a(n(u),{modelValue:l.value["scan.maxFileSize"],"onUpdate:modelValue":e[4]||(e[4]=s=>l.value["scan.maxFileSize"]=s),label:"최대 파일 크기 (MB)",type:"number",hint:"분석할 로그 파일의 최대 크기"},null,8,["modelValue"])]),t("div",z,[a(n(y),{onClick:g,variant:"secondary"},{default:i(()=>[...e[10]||(e[10]=[c("초기화",-1)])]),_:1}),a(n(y),{type:"submit",loading:d.value},{default:i(()=>[...e[11]||(e[11]=[c("저장",-1)])]),_:1},8,["loading"])])],32))]),_:1}),a(n(V),{class:"app-info"},{header:i(()=>[...e[12]||(e[12]=[t("h3",null,"애플리케이션 정보",-1)])]),default:i(()=>[e[13]||(e[13]=t("div",{class:"info-list"},[t("div",{class:"info-item"},[t("span",{class:"label"},"버전"),t("span",{class:"value"},"1.0.0")]),t("div",{class:"info-item"},[t("span",{class:"label"},"프레임워크"),t("span",{class:"value"},"Spring Boot 3.2 + Vue 3")]),t("div",{class:"info-item"},[t("span",{class:"label"},"데이터베이스"),t("span",{class:"value"},"SQLite (./data/loghunter.db)")])],-1))]),_:1})]))}},j=S(A,[["__scopeId","data-v-fdca948e"]]);export{j as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as x,r as v,o as F,a as m,f as l,w as d,u as o,i as _,m as B,b as e,j as g,p as k,h as w}from"./index-CZ3IEKgR.js";import{C as V,b,B as y}from"./index-jV6SX453.js";import{F as u}from"./FormInput-C3OYA9sE.js";const i=r=>(k("data-v-fdca948e"),r=r(),w(),r),C={class:"settings"},I=i(()=>e("div",{class:"card-header-content"},[e("h3",null,"설정")],-1)),U={key:0,class:"loading"},M={class:"setting-section"},z=i(()=>e("h4",null,"일반 설정",-1)),A={class:"setting-section"},N=i(()=>e("h4",null,"내보내기 설정",-1)),j={class:"setting-section"},L=i(()=>e("h4",null,"데이터 관리",-1)),T={class:"setting-section"},E=i(()=>e("h4",null,"스캔 설정",-1)),H={class:"form-actions"},O=i(()=>e("h3",null,"애플리케이션 정보",-1)),P=i(()=>e("div",{class:"info-list"},[e("div",{class:"info-item"},[e("span",{class:"label"},"버전"),e("span",{class:"value"},"1.0.0")]),e("div",{class:"info-item"},[e("span",{class:"label"},"프레임워크"),e("span",{class:"value"},"Spring Boot 3.2 + Vue 3")]),e("div",{class:"info-item"},[e("span",{class:"label"},"데이터베이스"),e("span",{class:"value"},"SQLite (./data/loghunter.db)")])],-1)),Q={__name:"Settings",setup(r){const c=v(!1),p=v(!1),t=v({}),f={"server.port":"8080","export.path":"./exports","retention.days":"90","scan.timeout":"30","scan.maxFileSize":"100"},h=async()=>{c.value=!0;try{const n=await b.getAllAsMap();t.value={...f,...n}}catch(n){console.error("Failed to load settings:",n),t.value={...f}}finally{c.value=!1}},S=async()=>{p.value=!0;try{for(const[n,s]of Object.entries(t.value))await b.save({key:n,value:String(s)});alert("설정이 저장되었습니다.")}catch(n){console.error("Failed to save settings:",n),alert("설정 저장에 실패했습니다.")}finally{p.value=!1}};return F(()=>{h()}),(n,s)=>(_(),m("div",C,[l(o(V),null,{header:d(()=>[I]),default:d(()=>[c.value?(_(),m("div",U,"로딩중...")):(_(),m("form",{key:1,onSubmit:B(S,["prevent"]),class:"settings-form"},[e("div",M,[z,l(o(u),{modelValue:t.value["server.port"],"onUpdate:modelValue":s[0]||(s[0]=a=>t.value["server.port"]=a),label:"서버 포트",type:"number",hint:"애플리케이션이 실행될 포트 번호 (기본: 8080)"},null,8,["modelValue"])]),e("div",A,[N,l(o(u),{modelValue:t.value["export.path"],"onUpdate:modelValue":s[1]||(s[1]=a=>t.value["export.path"]=a),label:"내보내기 경로",placeholder:"예: C:\\LogHunter\\exports",hint:"리포트 파일이 저장될 기본 경로"},null,8,["modelValue"])]),e("div",j,[L,l(o(u),{modelValue:t.value["retention.days"],"onUpdate:modelValue":s[2]||(s[2]=a=>t.value["retention.days"]=a),label:"로그 보관 기간 (일)",type:"number",hint:"에러 로그 데이터 보관 기간 (0 = 무제한)"},null,8,["modelValue"])]),e("div",T,[E,l(o(u),{modelValue:t.value["scan.timeout"],"onUpdate:modelValue":s[3]||(s[3]=a=>t.value["scan.timeout"]=a),label:"스캔 타임아웃 (초)",type:"number",hint:"SFTP 연결 및 파일 다운로드 타임아웃"},null,8,["modelValue"]),l(o(u),{modelValue:t.value["scan.maxFileSize"],"onUpdate:modelValue":s[4]||(s[4]=a=>t.value["scan.maxFileSize"]=a),label:"최대 파일 크기 (MB)",type:"number",hint:"분석할 로그 파일의 최대 크기"},null,8,["modelValue"])]),e("div",H,[l(o(y),{onClick:h,variant:"secondary"},{default:d(()=>[g("초기화")]),_:1}),l(o(y),{type:"submit",loading:p.value},{default:d(()=>[g("저장")]),_:1},8,["loading"])])],32))]),_:1}),l(o(V),{class:"app-info"},{header:d(()=>[O]),default:d(()=>[P]),_:1})]))}},J=x(Q,[["__scopeId","data-v-fdca948e"]]);export{J as default};
|
||||
File diff suppressed because one or more lines are too long
6
src/main/resources/static/assets/index-09HB4Lmg.js
Normal file
6
src/main/resources/static/assets/index-09HB4Lmg.js
Normal file
File diff suppressed because one or more lines are too long
30
src/main/resources/static/assets/index-BB0X_WMV.js
Normal file
30
src/main/resources/static/assets/index-BB0X_WMV.js
Normal file
File diff suppressed because one or more lines are too long
1
src/main/resources/static/assets/index-BJ4-9mFZ.css
Normal file
1
src/main/resources/static/assets/index-BJ4-9mFZ.css
Normal file
@@ -0,0 +1 @@
|
||||
*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}#app{min-height:100vh;background:#f5f5f5}.header{background:#2c3e50;color:#fff;padding:1rem 2rem;display:flex;align-items:center;gap:2rem}.header h1{font-size:1.5rem;margin:0}.header .logo{text-decoration:none;color:#fff}.header nav{display:flex;gap:1rem}.header nav a{color:#ecf0f1;text-decoration:none;padding:.5rem 1rem;border-radius:4px;transition:background .2s}.header nav a:hover{background:#34495e}.header nav a.router-link-active{background:#3498db}.main{padding:1.5rem}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}#app{min-height:100vh;background:#f5f5f5}.header{background:#2c3e50;color:#fff;padding:1rem 2rem;display:flex;align-items:center;gap:2rem}.header h1{font-size:1.5rem;margin:0}.header nav{display:flex;gap:1rem}.header nav a{color:#ecf0f1;text-decoration:none;padding:.5rem 1rem;border-radius:4px;transition:background .2s}.header nav a:hover{background:#34495e}.header nav a.router-link-active{background:#3498db}.main{padding:2rem;max-width:1400px;margin:0 auto}
|
||||
@@ -1 +1 @@
|
||||
.data-table-wrapper[data-v-db5e24a9]{overflow-x:auto}.data-table[data-v-db5e24a9]{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px #0000001a}.data-table th[data-v-db5e24a9],.data-table td[data-v-db5e24a9]{padding:12px 16px;text-align:left;border-bottom:1px solid #eee}.data-table th[data-v-db5e24a9]{background:#f8f9fa;font-weight:600;color:#495057}.data-table tbody tr[data-v-db5e24a9]:hover{background:#f8f9fa}.data-table tbody tr:last-child td[data-v-db5e24a9]{border-bottom:none}.actions-col[data-v-db5e24a9]{width:120px;text-align:center}.loading-cell[data-v-db5e24a9],.empty-cell[data-v-db5e24a9]{text-align:center;color:#6c757d;padding:40px!important}.modal-overlay[data-v-90993dd3]{position:fixed;top:0;left:0;right:0;bottom:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal[data-v-90993dd3]{background:#fff;border-radius:8px;max-height:90vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 4px 20px #00000026}.modal-header[data-v-90993dd3]{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #eee}.modal-header h3[data-v-90993dd3]{margin:0;font-size:1.1rem}.close-btn[data-v-90993dd3]{background:none;border:none;font-size:1.5rem;cursor:pointer;color:#666;padding:0;line-height:1}.close-btn[data-v-90993dd3]:hover{color:#333}.modal-body[data-v-90993dd3]{padding:20px;overflow-y:auto;flex:1}.modal-footer[data-v-90993dd3]{padding:16px 20px;border-top:1px solid #eee;display:flex;justify-content:flex-end;gap:8px}.form-group[data-v-45f49038]{margin-bottom:16px}label[data-v-45f49038]{display:block;margin-bottom:6px;font-weight:500;color:#333}.required[data-v-45f49038]{color:#e74c3c}.form-input[data-v-45f49038]{width:100%;padding:10px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px;transition:border-color .2s}.form-input[data-v-45f49038]:focus{outline:none;border-color:#3498db}.form-input[data-v-45f49038]:disabled{background:#f5f5f5;cursor:not-allowed}textarea.form-input[data-v-45f49038]{resize:vertical}select.form-input[data-v-45f49038]{cursor:pointer}.error-text[data-v-45f49038]{display:block;margin-top:4px;font-size:12px;color:#e74c3c}.hint-text[data-v-45f49038]{display:block;margin-top:4px;font-size:12px;color:#6c757d}.btn[data-v-e5da414f]{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:10px 16px;border:none;border-radius:4px;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s}.btn[data-v-e5da414f]:disabled{opacity:.6;cursor:not-allowed}.btn-sm[data-v-e5da414f]{padding:6px 12px;font-size:12px}.btn-lg[data-v-e5da414f]{padding:12px 24px;font-size:16px}.btn-primary[data-v-e5da414f]{background:#3498db;color:#fff}.btn-primary[data-v-e5da414f]:hover:not(:disabled){background:#2980b9}.btn-secondary[data-v-e5da414f]{background:#6c757d;color:#fff}.btn-secondary[data-v-e5da414f]:hover:not(:disabled){background:#5a6268}.btn-danger[data-v-e5da414f]{background:#e74c3c;color:#fff}.btn-danger[data-v-e5da414f]:hover:not(:disabled){background:#c0392b}.btn-success[data-v-e5da414f]{background:#27ae60;color:#fff}.btn-success[data-v-e5da414f]:hover:not(:disabled){background:#1e8449}.btn-warning[data-v-e5da414f]{background:#f39c12;color:#fff}.btn-warning[data-v-e5da414f]:hover:not(:disabled){background:#d68910}.spinner[data-v-e5da414f]{width:14px;height:14px;border:2px solid transparent;border-top-color:currentColor;border-radius:50%;animation:spin-e5da414f .8s linear infinite}@keyframes spin-e5da414f{to{transform:rotate(360deg)}}.badge[data-v-b7bd2350]{display:inline-block;padding:4px 8px;font-size:12px;font-weight:500;border-radius:4px}.badge-default[data-v-b7bd2350]{background:#e9ecef;color:#495057}.badge-critical[data-v-b7bd2350],.badge-error[data-v-b7bd2350]{background:#e74c3c;color:#fff}.badge-warn[data-v-b7bd2350]{background:#f39c12;color:#fff}.badge-success[data-v-b7bd2350]{background:#27ae60;color:#fff}.badge-info[data-v-b7bd2350]{background:#3498db;color:#fff}.card[data-v-2f260fa2]{background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a;overflow:hidden}.card-header[data-v-2f260fa2]{padding:16px 20px;border-bottom:1px solid #eee}.card-header h3[data-v-2f260fa2]{margin:0;font-size:1.1rem;color:#333}.card-body[data-v-2f260fa2]{padding:20px}.card-footer[data-v-2f260fa2]{padding:16px 20px;border-top:1px solid #eee;background:#f8f9fa}
|
||||
.data-table-wrapper[data-v-db5e24a9]{overflow-x:auto}.data-table[data-v-db5e24a9]{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px #0000001a}.data-table th[data-v-db5e24a9],.data-table td[data-v-db5e24a9]{padding:12px 16px;text-align:left;border-bottom:1px solid #eee}.data-table th[data-v-db5e24a9]{background:#f8f9fa;font-weight:600;color:#495057}.data-table tbody tr[data-v-db5e24a9]:hover{background:#f8f9fa}.data-table tbody tr:last-child td[data-v-db5e24a9]{border-bottom:none}.actions-col[data-v-db5e24a9]{width:120px;text-align:center}.loading-cell[data-v-db5e24a9],.empty-cell[data-v-db5e24a9]{text-align:center;color:#6c757d;padding:40px!important}.modal-overlay[data-v-90993dd3]{position:fixed;top:0;left:0;right:0;bottom:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal[data-v-90993dd3]{background:#fff;border-radius:8px;max-height:90vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 4px 20px #00000026}.modal-header[data-v-90993dd3]{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #eee}.modal-header h3[data-v-90993dd3]{margin:0;font-size:1.1rem}.close-btn[data-v-90993dd3]{background:none;border:none;font-size:1.5rem;cursor:pointer;color:#666;padding:0;line-height:1}.close-btn[data-v-90993dd3]:hover{color:#333}.modal-body[data-v-90993dd3]{padding:20px;overflow-y:auto;flex:1}.modal-footer[data-v-90993dd3]{padding:16px 20px;border-top:1px solid #eee;display:flex;justify-content:flex-end;gap:8px}.form-group[data-v-45f49038]{margin-bottom:16px}label[data-v-45f49038]{display:block;margin-bottom:6px;font-weight:500;color:#333}.required[data-v-45f49038]{color:#e74c3c}.form-input[data-v-45f49038]{width:100%;padding:10px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px;transition:border-color .2s}.form-input[data-v-45f49038]:focus{outline:none;border-color:#3498db}.form-input[data-v-45f49038]:disabled{background:#f5f5f5;cursor:not-allowed}textarea.form-input[data-v-45f49038]{resize:vertical}select.form-input[data-v-45f49038]{cursor:pointer}.error-text[data-v-45f49038]{display:block;margin-top:4px;font-size:12px;color:#e74c3c}.hint-text[data-v-45f49038]{display:block;margin-top:4px;font-size:12px;color:#6c757d}.btn[data-v-c92354e1]{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:10px 16px;border:none;border-radius:4px;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s}.btn[data-v-c92354e1]:disabled{opacity:.6;cursor:not-allowed}.btn-sm[data-v-c92354e1]{padding:6px 12px;font-size:12px}.btn-lg[data-v-c92354e1]{padding:12px 24px;font-size:16px}.btn-primary[data-v-c92354e1]{background:#3498db;color:#fff}.btn-primary[data-v-c92354e1]:hover:not(:disabled){background:#2980b9}.btn-secondary[data-v-c92354e1]{background:#6c757d;color:#fff}.btn-secondary[data-v-c92354e1]:hover:not(:disabled){background:#5a6268}.btn-danger[data-v-c92354e1]{background:#e74c3c;color:#fff}.btn-danger[data-v-c92354e1]:hover:not(:disabled){background:#c0392b}.btn-success[data-v-c92354e1]{background:#27ae60;color:#fff}.btn-success[data-v-c92354e1]:hover:not(:disabled){background:#1e8449}.btn-warning[data-v-c92354e1]{background:#f39c12;color:#fff}.btn-warning[data-v-c92354e1]:hover:not(:disabled){background:#d68910}.spinner[data-v-c92354e1]{width:14px;height:14px;border:2px solid transparent;border-top-color:currentColor;border-radius:50%;animation:spin-c92354e1 .8s linear infinite}@keyframes spin-c92354e1{to{transform:rotate(360deg)}}.badge[data-v-b7bd2350]{display:inline-block;padding:4px 8px;font-size:12px;font-weight:500;border-radius:4px}.badge-default[data-v-b7bd2350]{background:#e9ecef;color:#495057}.badge-critical[data-v-b7bd2350],.badge-error[data-v-b7bd2350]{background:#e74c3c;color:#fff}.badge-warn[data-v-b7bd2350]{background:#f39c12;color:#fff}.badge-success[data-v-b7bd2350]{background:#27ae60;color:#fff}.badge-info[data-v-b7bd2350]{background:#3498db;color:#fff}.card[data-v-2f260fa2]{background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a;overflow:hidden}.card-header[data-v-2f260fa2]{padding:16px 20px;border-bottom:1px solid #eee}.card-header h3[data-v-2f260fa2]{margin:0;font-size:1.1rem;color:#333}.card-body[data-v-2f260fa2]{padding:20px}.card-footer[data-v-2f260fa2]{padding:16px 20px;border-top:1px solid #eee;background:#f8f9fa}
|
||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>LogHunter</title>
|
||||
<style>
|
||||
* {
|
||||
@@ -15,8 +18,8 @@
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-CZ3IEKgR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dy1O9dj-.css">
|
||||
<script type="module" crossorigin src="/assets/index-BB0X_WMV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BJ4-9mFZ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user