This commit is contained in:
2026-01-06 21:44:36 +09:00
parent ceec1ad7a9
commit 716cf63f73
98 changed files with 6997 additions and 538 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

View File

@@ -0,0 +1,2 @@
#Tue Jan 06 21:43:32 KST 2026
gradle.version=8.5

View File

25
HELP.md Normal file
View File

@@ -0,0 +1,25 @@
# Getting Started
### Reference Documentation
For further reference, please consider the following sections:
* [Official Gradle documentation](https://docs.gradle.org)
* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/4.0.1/gradle-plugin)
* [Create an OCI image](https://docs.spring.io/spring-boot/4.0.1/gradle-plugin/packaging-oci-image.html)
* [Spring Web](https://docs.spring.io/spring-boot/4.0.1/reference/web/servlet.html)
### Guides
The following guides illustrate how to use some features concretely:
* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/)
* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/)
* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/)
### Additional Links
These additional references should also help you:
* [Gradle Build Scans insights for your project's build](https://scans.gradle.com#gradle)

View File

@@ -21,7 +21,7 @@ SFTP를 통해 원격 서버의 로그 파일을 수집하고, 등록된 패턴
| 구분 | 기술 |
|------|------|
| Backend | Spring Boot |
| Backend | Spring Boot 3.2 |
| Frontend | Vue3 (SPA, 빌드 후 static 폴더에 포함) |
| Database | SQLite (파일 DB) |
| SFTP | JSch |
@@ -37,19 +37,22 @@ SFTP를 통해 원격 서버의 로그 파일을 수집하고, 등록된 패턴
| 4 | 패턴 관리 | 에러 패턴 등록/수정/삭제, 패턴 테스트 |
| 5 | 설정 | 내보내기 경로, 로그 보관 기간, 포트 설정 |
## 폴더 구조 (예정)
## 폴더 구조
```
log-hunter/
├── backend/ # Spring Boot
│ └── src/main/resources/
├── src/ # Spring Boot 소스
│ └── main/
│ ├── java/ # Java 소스
│ └── resources/
│ └── static/ # Vue3 빌드 결과물
├── frontend/ # Vue3 (개발용)
│ └── src/
│ └── views/
├── data/ # SQLite DB
├── exports/ # HTML/TXT 내보내기
── config/ # 설정 파일
── build.gradle
└── settings.gradle
```
## 실행 시 동작
@@ -72,57 +75,57 @@ log-hunter.jar 실행
- [x] 1-1. Spring Boot 프로젝트 생성
- [x] 1-2. Vue3 프로젝트 생성 (frontend 폴더)
- [ ] 1-3. 빌드 연동 (Vue → static 폴더로)
- [ ] 1-4. SQLite 연동 확인
- [ ] 1-5. 앱 실행 시 브라우저 자동 오픈
- [x] 1-3. 빌드 연동 (Vue → static 폴더로)
- [x] 1-4. SQLite 연동 확인
- [x] 1-5. 앱 실행 시 브라우저 자동 오픈
### Step 2. DB 스키마 설계
- [ ] 2-1. servers 테이블 (SFTP 서버 정보)
- [ ] 2-2. server_log_paths 테이블 (서버별 로그 경로, 1:N)
- [ ] 2-3. patterns 테이블 (에러 패턴 정의)
- [ ] 2-4. scan_history 테이블 (분석 실행 이력)
- [ ] 2-5. error_logs 테이블 (검출된 에러)
- [ ] 2-6. settings 테이블 (전역 설정, key-value)
- [x] 2-1. servers 테이블 (SFTP 서버 정보)
- [x] 2-2. server_log_paths 테이블 (서버별 로그 경로, 1:N)
- [x] 2-3. patterns 테이블 (에러 패턴 정의)
- [x] 2-4. scan_history 테이블 (분석 실행 이력)
- [x] 2-5. error_logs 테이블 (검출된 에러)
- [x] 2-6. settings 테이블 (전역 설정, key-value)
### Step 3. Backend - 기본 CRUD API
- [ ] 3-1. 서버 관리 API (CRUD)
- [ ] 3-2. 로그 경로 관리 API
- [ ] 3-3. 패턴 관리 API (CRUD)
- [ ] 3-4. 설정 API
- [ ] 3-5. 비밀번호/passphrase 암호화 유틸
- [x] 3-1. 서버 관리 API (CRUD)
- [x] 3-2. 로그 경로 관리 API
- [x] 3-3. 패턴 관리 API (CRUD)
- [x] 3-4. 설정 API
- [x] 3-5. 비밀번호/passphrase 암호화 유틸
### Step 4. Backend - 핵심 기능
- [ ] 4-1. SFTP 연결 (비밀번호 / 키 파일)
- [ ] 4-2. 연결 테스트 API
- [ ] 4-3. 파일 목록 조회 (마지막 분석 이후 파일)
- [ ] 4-4. 파일 다운로드
- [ ] 4-5. 로그 파싱 + 패턴 매칭 엔진
- [ ] 4-6. 에러 저장 (컨텍스트 포함)
- [ ] 4-7. 분석 실행 API (진행상황 SSE 또는 WebSocket)
- [x] 4-1. SFTP 연결 (비밀번호 / 키 파일)
- [x] 4-2. 연결 테스트 API
- [x] 4-3. 파일 목록 조회 (마지막 분석 이후 파일)
- [x] 4-4. 파일 다운로드
- [x] 4-5. 로그 파싱 + 패턴 매칭 엔진
- [x] 4-6. 에러 저장 (컨텍스트 포함)
- [x] 4-7. 분석 실행 API (진행상황 SSE 또는 WebSocket)
### Step 5. Backend - 내보내기
- [ ] 5-1. HTML 리포트 생성
- [ ] 5-2. 에러 상세 TXT 파일 생성
- [ ] 5-3. 내보내기 API
- [x] 5-1. HTML 리포트 생성
- [x] 5-2. TXT 리포트 생성
- [x] 5-3. 내보내기 API
### Step 6. Frontend - 공통
- [ ] 6-1. 레이아웃 (헤더, 사이드메뉴)
- [ ] 6-2. 라우터 설정
- [ ] 6-3. API 클라이언트 (axios)
- [ ] 6-4. 공통 컴포넌트 (테이블, 모달, 폼)
- [x] 6-1. 레이아웃 (헤더, 사이드메뉴)
- [x] 6-2. 라우터 설정
- [x] 6-3. API 클라이언트 (axios)
- [x] 6-4. 공통 컴포넌트 (테이블, 모달, 폼)
### Step 7. Frontend - 화면 개발
- [ ] 7-1. 대시보드 (서버 목록 + 실행 + 진행상황)
- [ ] 7-2. 에러 이력 (필터 + 목록 + 상세보기)
- [ ] 7-3. 서버 관리 (목록 + 추가/수정 폼)
- [ ] 7-4. 패턴 관리 (목록 + 추가/수정 + 테스트)
- [ ] 7-5. 설정
- [x] 7-1. 대시보드 (서버 목록 + 실행 + 진행상황)
- [x] 7-2. 에러 이력 (필터 + 목록 + 상세보기)
- [x] 7-3. 서버 관리 (목록 + 추가/수정 폼)
- [x] 7-4. 패턴 관리 (목록 + 추가/수정 + 테스트)
- [x] 7-5. 설정
### Step 8. 테스트 및 마무리
@@ -155,7 +158,16 @@ Step 1 → Step 2 → Step 3 → Step 6 (공통)
## 변경 이력
| 날짜 | 내용 |
| 일시 | 내용 |
|------|------|
| 2025-01-06 | 최초 작성, 요구사항 정리 |
| 2025-01-06 | Step 1-1, 1-2 완료 (Spring Boot, Vue3 프로젝트 생성) |
| 2025-01-06 | Step 1 완료 (Spring Boot, Vue3, SQLite, 빌드 연동, 브라우저 자동 오픈) |
| 2025-01-06 | Step 2 완료 (DB 스키마 설계 - 6개 Entity 생성) |
| 2025-01-06 | Step 3 완료 (Backend CRUD API - Repository, Service, Controller) |
| 2025-01-06 | Step 6 완료 (Frontend 공통 - 레이아웃, 라우터, API, 공통컴포넌트) |
| 2025-01-06 | Step 7-3, 7-4 완료 (서버 관리, 패턴 관리 화면) |
| 2025-01-06 | Step 4 완료 (Backend 핵심기능 - SFTP, 스캔엔진, 에러로그) |
| 2025-01-06 | Step 7-1, 7-2 완료 (대시보드, 에러 이력 화면) |
| 2025-01-06 | Step 5, 7-5 완료 (내보내기 HTML/TXT, 설정 화면) |
| 2025-01-06 19:05:00 | TEST_GUIDE.md 작성 (테스트 가이드) |
| 2025-01-06 19:23:00 | Frontend 빌드 + Backend 실행 테스트 성공 |

256
TEST_GUIDE.md Normal file
View File

@@ -0,0 +1,256 @@
# LogHunter 테스트 가이드
## 1. 사전 요구사항
| 항목 | 버전 | 확인 명령어 |
|------|------|-------------|
| Java | 17 이상 | `java -version` |
| Node.js | 18 이상 | `node -v` |
| npm | 9 이상 | `npm -v` |
---
## 2. 프로젝트 실행
### 2-1. Frontend 빌드
```bash
cd /Users/coziny/devs/osolit-research/workspace/log-hunter/frontend
# 의존성 설치 (최초 1회)
npm install
# 빌드 (결과물이 ../src/main/resources/static 으로 복사됨)
npm run build
```
### 2-2. Backend 실행
```bash
cd /Users/coziny/devs/osolit-research/workspace/log-hunter
# Gradle Wrapper로 실행
./gradlew bootRun
```
### 2-3. 접속
- 실행 시 브라우저가 자동으로 열림
- 수동 접속: http://localhost:8080
---
## 3. 테스트 시나리오
### 3-1. 패턴 등록 (먼저!)
1. **패턴 관리** 메뉴 클릭
2. **+ 패턴 추가** 클릭
3. 샘플 패턴 입력:
| 패턴명 | 정규식 | 심각도 | 컨텍스트 |
|--------|--------|--------|----------|
| Exception | `Exception\|Error` | ERROR | 5 |
| NullPointer | `NullPointerException` | CRITICAL | 5 |
| WARN 로그 | `\[WARN\]` | WARN | 3 |
| Tomcat 에러 | `SEVERE\|FATAL` | CRITICAL | 5 |
4. **테스트** 버튼으로 정규식 검증 가능
### 3-2. 서버 등록
1. **서버 관리** 메뉴 클릭
2. **+ 서버 추가** 클릭
3. 서버 정보 입력:
**비밀번호 인증 예시:**
```
서버명: 운영서버1
호스트: 192.168.1.100
포트: 22
사용자명: username
인증방식: 비밀번호
비밀번호: ********
```
**키파일 인증 예시:**
```
서버명: 개발서버
호스트: 10.0.0.50
포트: 22
사용자명: deploy
인증방식: 키 파일
키 파일 경로: C:\Users\사용자\.ssh\id_rsa
Passphrase: (없으면 비워둠)
```
4. **테스트** 버튼으로 연결 확인
### 3-3. 로그 경로 등록
1. 서버 목록에서 **경로** 버튼 클릭
2. 로그 경로 추가:
| 경로 | 파일 패턴 | 설명 |
|------|-----------|------|
| `/var/log/tomcat/` | `catalina.*.log` | Tomcat 로그 |
| `/var/log/` | `*.log` | 시스템 로그 |
| `/app/logs/` | `application-*.log` | 애플리케이션 로그 |
### 3-4. 분석 실행
1. **대시보드** 메뉴 클릭
2. 개별 서버 **분석 실행** 또는 **전체 분석 실행**
3. 진행상황 확인 (프로그레스 바)
4. 완료 후 **에러 이력**에서 결과 확인
### 3-5. 결과 확인 및 내보내기
1. **에러 이력** 메뉴 클릭
2. 필터로 검색 (서버, 심각도, 기간 등)
3. **상세** 버튼으로 컨텍스트 확인
4. **HTML 내보내기** 또는 **TXT 내보내기**
---
## 4. 단일 JAR 빌드
### 4-1. 전체 빌드 (Frontend + Backend)
```bash
cd /Users/coziny/devs/osolit-research/workspace/log-hunter
# Frontend 빌드
cd frontend && npm run build && cd ..
# JAR 빌드
./gradlew clean bootJar
```
### 4-2. 빌드 결과
```
build/libs/log-hunter-0.0.1-SNAPSHOT.jar
```
### 4-3. JAR 실행
```bash
java -jar build/libs/log-hunter-0.0.1-SNAPSHOT.jar
```
---
## 5. Windows 실행 스크립트
### run.bat 생성
```batch
@echo off
title LogHunter
echo LogHunter 시작 중...
java -jar log-hunter.jar
pause
```
### 배포 폴더 구조
```
LogHunter/
├── log-hunter.jar
├── run.bat
└── data/ <- 자동 생성됨 (SQLite DB)
```
---
## 6. 트러블슈팅
### 포트 충돌 (8080 사용 중)
```bash
# application.yml에서 포트 변경
server:
port: 9090
```
또는 실행 시 지정:
```bash
java -jar log-hunter.jar --server.port=9090
```
### SFTP 연결 실패
- 호스트/포트 확인
- 방화벽 확인 (22번 포트)
- 사용자명/비밀번호 확인
- 키파일 경로 확인 (Windows: `C:\Users\...`, 절대경로)
### 한글 깨짐
- 로그 파일 인코딩이 UTF-8인지 확인
- 필요시 서버에서 `file -i 파일명`으로 인코딩 확인
### DB 초기화 (데이터 삭제)
```bash
# data 폴더의 DB 파일 삭제
rm -rf data/loghunter.db
```
---
## 7. 개발 모드 (Hot Reload)
### Frontend 개발 서버
```bash
cd frontend
npm run dev
```
- http://localhost:5173 에서 실행
- API 프록시 설정으로 백엔드(8080)와 연동됨
### Backend만 재시작
```bash
./gradlew bootRun
```
---
## 8. API 테스트 (curl 예시)
```bash
# 서버 목록 조회
curl http://localhost:8080/api/servers
# 패턴 목록 조회
curl http://localhost:8080/api/patterns
# 연결 테스트
curl -X POST http://localhost:8080/api/servers/1/test-connection
# 스캔 실행 (동기)
curl -X POST http://localhost:8080/api/scan/execute/1
# 에러 로그 검색
curl "http://localhost:8080/api/error-logs?page=0&size=10"
```
---
## 체크리스트
- [x] Java 17+ 설치 확인
- [x] Node.js 18+ 설치 확인
- [x] Frontend 빌드 완료 (2025-01-06 19:23 확인)
- [x] Backend 실행 성공 (2025-01-06 19:23 확인)
- [x] 브라우저 접속 확인 (localhost:8080)
- [ ] 패턴 등록
- [ ] 서버 등록
- [ ] 연결 테스트 성공
- [ ] 로그 경로 등록
- [ ] 분석 실행
- [ ] 에러 검출 확인
- [ ] 내보내기 테스트

25
backend/.gitignore vendored
View File

@@ -1,25 +0,0 @@
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
# IDE
.idea/
*.iml
*.ipr
*.iws
.vscode/
# OS
.DS_Store
Thumbs.db
# Application
data/*.db
exports/
logs/
# Compiled
*.class
*.jar
!gradle/wrapper/gradle-wrapper.jar

View File

@@ -4,11 +4,14 @@ plugins {
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.osolit'
group = 'research'
version = '0.0.1-SNAPSHOT'
description = 'log-hunter'
java {
sourceCompatibility = '17'
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
@@ -33,19 +36,9 @@ dependencies {
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
// Vue3 static
task copyFrontend(type: Copy) {
from '../frontend/dist'
into 'src/main/resources/static'
}
// frontend
bootJar {
dependsOn copyFrontend
}

BIN
data/loghunter.db Normal file

Binary file not shown.

View File

@@ -65,9 +65,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
"integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
"cpu": [
"ppc64"
],
@@ -82,9 +82,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
"integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
"cpu": [
"arm"
],
@@ -99,9 +99,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
"integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
"cpu": [
"arm64"
],
@@ -116,9 +116,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
"integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
"cpu": [
"x64"
],
@@ -133,9 +133,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
"integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
"cpu": [
"arm64"
],
@@ -150,9 +150,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
"integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
"cpu": [
"x64"
],
@@ -167,9 +167,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
"integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
"cpu": [
"arm64"
],
@@ -184,9 +184,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
"integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
"cpu": [
"x64"
],
@@ -201,9 +201,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
"integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
"cpu": [
"arm"
],
@@ -218,9 +218,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
"integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
"cpu": [
"arm64"
],
@@ -235,9 +235,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
"integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
"cpu": [
"ia32"
],
@@ -252,9 +252,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
"integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
"cpu": [
"loong64"
],
@@ -269,9 +269,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
"integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
"cpu": [
"mips64el"
],
@@ -286,9 +286,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
"integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
"cpu": [
"ppc64"
],
@@ -303,9 +303,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
"integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
"cpu": [
"riscv64"
],
@@ -320,9 +320,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
"integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
"cpu": [
"s390x"
],
@@ -337,9 +337,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
"integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
"cpu": [
"x64"
],
@@ -354,9 +354,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
"integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
"cpu": [
"x64"
],
@@ -371,9 +371,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
"integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
"cpu": [
"x64"
],
@@ -388,9 +388,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
"integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
"cpu": [
"x64"
],
@@ -405,9 +405,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
"integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
"cpu": [
"arm64"
],
@@ -422,9 +422,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
"integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
"cpu": [
"ia32"
],
@@ -439,9 +439,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
"integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
"cpu": [
"x64"
],
@@ -819,67 +819,67 @@
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.0.3.tgz",
"integrity": "sha512-b8S5dVS40rgHdDrw+DQi/xOM9ed+kSRZzfm1T74bMmBDCd8XO87NKlFYInzCtwvtWwXZvo1QxE2OSspTATWrbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0",
"vite": "^5.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
"integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.15.tgz",
"integrity": "sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.26",
"entities": "^7.0.0",
"@babel/parser": "^7.23.6",
"@vue/shared": "3.4.15",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
"source-map-js": "^1.0.2"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
"integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.15.tgz",
"integrity": "sha512-wox0aasVV74zoXyblarOM3AZQz/Z+OunYcIHe1OsGclCHt8RsRm04DObjefaI82u6XDzv+qGWZ24tIsRAIi5MQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.26",
"@vue/shared": "3.5.26"
"@vue/compiler-core": "3.4.15",
"@vue/shared": "3.4.15"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.15.tgz",
"integrity": "sha512-LCn5M6QpkpFsh3GQvs2mJUOAlBQcCco8D60Bcqmf3O3w5a+KWS5GvYbrrJBkgvL1BDnTp+e8q0lXCLgHhKguBA==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.26",
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-ssr": "3.5.26",
"@vue/shared": "3.5.26",
"@babel/parser": "^7.23.6",
"@vue/compiler-core": "3.4.15",
"@vue/compiler-dom": "3.4.15",
"@vue/compiler-ssr": "3.4.15",
"@vue/shared": "3.4.15",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
"magic-string": "^0.30.5",
"postcss": "^8.4.33",
"source-map-js": "^1.0.2"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
"integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.15.tgz",
"integrity": "sha512-1jdeQyiGznr8gjFDadVmOJqZiLNSsMa5ZgqavkPZ8O2wjHv0tVuAEsw5hTdUoUW4232vpBbL/wJhzVW/JwY1Uw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/shared": "3.5.26"
"@vue/compiler-dom": "3.4.15",
"@vue/shared": "3.4.15"
}
},
"node_modules/@vue/devtools-api": {
@@ -889,53 +889,52 @@
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
"integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.15.tgz",
"integrity": "sha512-55yJh2bsff20K5O84MxSvXKPHHt17I2EomHznvFiJCAZpJTNW8IuLj1xZWMLELRhBK3kkFV/1ErZGHJfah7i7w==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.26"
"@vue/shared": "3.4.15"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
"integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.15.tgz",
"integrity": "sha512-6E3by5m6v1AkW0McCeAyhHTw+3y17YCOKG0U0HDKDscV4Hs0kgNT5G+GCHak16jKgcCDHpI9xe5NKb8sdLCLdw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.26",
"@vue/shared": "3.5.26"
"@vue/reactivity": "3.4.15",
"@vue/shared": "3.4.15"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
"integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.15.tgz",
"integrity": "sha512-EVW8D6vfFVq3V/yDKNPBFkZKGMFSvZrUQmx196o/v2tHKdwWdiZjYUBS+0Ez3+ohRyF8Njwy/6FH5gYJ75liUw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.26",
"@vue/runtime-core": "3.5.26",
"@vue/shared": "3.5.26",
"csstype": "^3.2.3"
"@vue/runtime-core": "3.4.15",
"@vue/shared": "3.4.15",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
"integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.15.tgz",
"integrity": "sha512-3HYzaidu9cHjrT+qGUuDhFYvF/j643bHC6uUN9BgM11DVy+pM6ATsG6uPBLnkwOgs7BpJABReLmpL3ZPAsUaqw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.26",
"@vue/shared": "3.5.26"
"@vue/compiler-ssr": "3.4.15",
"@vue/shared": "3.4.15"
},
"peerDependencies": {
"vue": "3.5.26"
"vue": "3.4.15"
}
},
"node_modules/@vue/shared": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.15.tgz",
"integrity": "sha512-KzfPTxVaWfB+eGcGdbSf4CWdaXcGDqckoeXUh7SB3fZdEtzPCK2Vq9B/lRRL3yutax/LWITz+SwvgyOxz5V75g==",
"license": "MIT"
},
"node_modules/asynckit": {
@@ -945,13 +944,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz",
"integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.4",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
@@ -1010,9 +1009,9 @@
}
},
"node_modules/entities": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
"integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -1067,9 +1066,9 @@
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
"integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1080,29 +1079,29 @@
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
"@esbuild/aix-ppc64": "0.19.12",
"@esbuild/android-arm": "0.19.12",
"@esbuild/android-arm64": "0.19.12",
"@esbuild/android-x64": "0.19.12",
"@esbuild/darwin-arm64": "0.19.12",
"@esbuild/darwin-x64": "0.19.12",
"@esbuild/freebsd-arm64": "0.19.12",
"@esbuild/freebsd-x64": "0.19.12",
"@esbuild/linux-arm": "0.19.12",
"@esbuild/linux-arm64": "0.19.12",
"@esbuild/linux-ia32": "0.19.12",
"@esbuild/linux-loong64": "0.19.12",
"@esbuild/linux-mips64el": "0.19.12",
"@esbuild/linux-ppc64": "0.19.12",
"@esbuild/linux-riscv64": "0.19.12",
"@esbuild/linux-s390x": "0.19.12",
"@esbuild/linux-x64": "0.19.12",
"@esbuild/netbsd-x64": "0.19.12",
"@esbuild/openbsd-x64": "0.19.12",
"@esbuild/sunos-x64": "0.19.12",
"@esbuild/win32-arm64": "0.19.12",
"@esbuild/win32-ia32": "0.19.12",
"@esbuild/win32-x64": "0.19.12"
}
},
"node_modules/estree-walker": {
@@ -1323,27 +1322,57 @@
"license": "ISC"
},
"node_modules/pinia": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
"integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz",
"integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10"
"@vue/devtools-api": "^6.5.0",
"vue-demi": ">=0.14.5"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"@vue/composition-api": "^1.4.0",
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
"vue": "^2.6.14 || ^3.3.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -1433,15 +1462,15 @@
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.11.tgz",
"integrity": "sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
"esbuild": "^0.19.3",
"postcss": "^8.4.32",
"rollup": "^4.2.0"
},
"bin": {
"vite": "bin/vite.js"
@@ -1460,7 +1489,6 @@
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
@@ -1478,9 +1506,6 @@
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
@@ -1493,16 +1518,16 @@
}
},
"node_modules/vue": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.15.tgz",
"integrity": "sha512-jC0GH4KkWLWJOEQjOpkqU1bQsBwf4R1rsFtw5GQJbjHVKWDzO6P0nWWBTmjp1xSemAioDFj1jdaK1qa3DnMQoQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",
"@vue/runtime-dom": "3.5.26",
"@vue/server-renderer": "3.5.26",
"@vue/shared": "3.5.26"
"@vue/compiler-dom": "3.4.15",
"@vue/compiler-sfc": "3.4.15",
"@vue/runtime-dom": "3.4.15",
"@vue/server-renderer": "3.4.15",
"@vue/shared": "3.4.15"
},
"peerDependencies": {
"typescript": "*"
@@ -1513,45 +1538,19 @@
}
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.5.tgz",
"integrity": "sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
"@vue/devtools-api": "^6.5.0"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
"vue": "^3.2.0"
}
}
}

View File

@@ -9,10 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"axios": "^1.6.5",
"pinia": "^2.1.7"
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",

View File

@@ -20,11 +20,19 @@
</script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
#app {
min-height: 100vh;
background: #f5f5f5;
}
</style>
.header {
background: #2c3e50;

View File

@@ -23,4 +23,129 @@ api.interceptors.response.use(
}
)
// Server API
export const serverApi = {
getAll: () => api.get('/servers'),
getAllActive: () => api.get('/servers/active'),
getById: (id) => api.get(`/servers/${id}`),
create: (data) => api.post('/servers', data),
update: (id, data) => api.put(`/servers/${id}`, data),
delete: (id) => api.delete(`/servers/${id}`),
testConnection: (id) => api.post(`/servers/${id}/test-connection`)
}
// LogPath API
export const logPathApi = {
getByServerId: (serverId) => api.get(`/log-paths/server/${serverId}`),
getActiveByServerId: (serverId) => api.get(`/log-paths/server/${serverId}/active`),
getById: (id) => api.get(`/log-paths/${id}`),
create: (data) => api.post('/log-paths', data),
update: (id, data) => api.put(`/log-paths/${id}`, data),
delete: (id) => api.delete(`/log-paths/${id}`)
}
// Pattern API
export const patternApi = {
getAll: () => api.get('/patterns'),
getAllActive: () => api.get('/patterns/active'),
getById: (id) => api.get(`/patterns/${id}`),
create: (data) => api.post('/patterns', data),
update: (id, data) => api.put(`/patterns/${id}`, data),
delete: (id) => api.delete(`/patterns/${id}`),
test: (regex, sampleText) => api.post('/patterns/test', null, { params: { regex, sampleText } })
}
// Setting API
export const settingApi = {
getAll: () => api.get('/settings'),
getAllAsMap: () => api.get('/settings/map'),
getValue: (key) => api.get(`/settings/${key}`),
save: (data) => api.post('/settings', data),
saveAll: (settings) => api.put('/settings', settings),
delete: (key) => api.delete(`/settings/${key}`)
}
// Scan API
export const scanApi = {
// SSE 기반 스캔 시작 (진행상황 실시간 수신)
startWithProgress: (serverId, onProgress, onComplete, onError) => {
const eventSource = new EventSource(`/api/scan/start/${serverId}`)
eventSource.addEventListener('progress', (event) => {
const progress = JSON.parse(event.data)
onProgress && onProgress(progress)
})
eventSource.addEventListener('complete', (event) => {
const result = JSON.parse(event.data)
onComplete && onComplete(result)
eventSource.close()
})
eventSource.addEventListener('error', (event) => {
if (event.data) {
onError && onError(event.data)
}
eventSource.close()
})
eventSource.onerror = () => {
eventSource.close()
}
return eventSource
},
// SSE 기반 전체 서버 스캔
startAllWithProgress: (onProgress, onComplete, onError) => {
const eventSource = new EventSource('/api/scan/start-all')
eventSource.addEventListener('progress', (event) => {
const progress = JSON.parse(event.data)
onProgress && onProgress(progress)
})
eventSource.addEventListener('complete', (event) => {
const results = JSON.parse(event.data)
onComplete && onComplete(results)
eventSource.close()
})
eventSource.addEventListener('error', (event) => {
if (event.data) {
onError && onError(event.data)
}
eventSource.close()
})
eventSource.onerror = () => {
eventSource.close()
}
return eventSource
},
// 동기 방식 스캔 (간단 실행)
execute: (serverId) => api.post(`/scan/execute/${serverId}`),
// 진행 상황 조회
getProgress: (serverId) => api.get(`/scan/progress/${serverId}`),
// 스캔 이력 조회
getHistory: (serverId) => api.get(`/scan/history/${serverId}`)
}
// ErrorLog API
export const errorLogApi = {
search: (params) => api.get('/error-logs', { params }),
getById: (id) => api.get(`/error-logs/${id}`),
getByServer: (serverId, params) => api.get(`/error-logs/server/${serverId}`, { params })
}
// Export API (Step 5에서 구현 예정)
export const exportApi = {
exportHtml: (params) => api.post('/export/html', params, { responseType: 'blob' }),
exportTxt: (params) => api.post('/export/txt', params, { responseType: 'blob' })
}
export default api

View File

@@ -0,0 +1,56 @@
<template>
<span :class="['badge', `badge-${variant}`]">
<slot>{{ text }}</slot>
</span>
</template>
<script setup>
defineProps({
text: String,
variant: {
type: String,
default: 'default'
// default, critical, error, warn, success, info
}
})
</script>
<style scoped>
.badge {
display: inline-block;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
}
.badge-default {
background: #e9ecef;
color: #495057;
}
.badge-critical {
background: #e74c3c;
color: white;
}
.badge-error {
background: #e74c3c;
color: white;
}
.badge-warn {
background: #f39c12;
color: white;
}
.badge-success {
background: #27ae60;
color: white;
}
.badge-info {
background: #3498db;
color: white;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<button
:type="type"
:class="['btn', `btn-${variant}`, { 'btn-sm': size === 'sm', 'btn-lg': size === 'lg' }]"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<span v-if="loading" class="spinner"></span>
<slot></slot>
</button>
</template>
<script setup>
defineProps({
type: {
type: String,
default: 'button'
},
variant: {
type: String,
default: 'primary'
// primary, secondary, danger, success, warning
},
size: {
type: String,
default: 'md'
// sm, md, lg
},
disabled: Boolean,
loading: Boolean
})
defineEmits(['click'])
</script>
<style scoped>
.btn {
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 0.2s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-lg {
padding: 12px 24px;
font-size: 16px;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2980b9;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #5a6268;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c0392b;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover:not(:disabled) {
background: #1e8449;
}
.btn-warning {
background: #f39c12;
color: white;
}
.btn-warning:hover:not(:disabled) {
background: #d68910;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div class="card">
<div v-if="title || $slots.header" class="card-header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
</div>
<div class="card-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup>
defineProps({
title: String
})
</script>
<style scoped>
.card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow: hidden;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.card-header h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
}
.card-body {
padding: 20px;
}
.card-footer {
padding: 16px 20px;
border-top: 1px solid #eee;
background: #f8f9fa;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th v-for="col in columns" :key="col.key" :style="{ width: col.width }">
{{ col.label }}
</th>
<th v-if="$slots.actions" class="actions-col">작업</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td :colspan="columns.length + ($slots.actions ? 1 : 0)" class="loading-cell">
로딩 ...
</td>
</tr>
<tr v-else-if="!data || data.length === 0">
<td :colspan="columns.length + ($slots.actions ? 1 : 0)" class="empty-cell">
{{ emptyText }}
</td>
</tr>
<tr v-else v-for="(row, idx) in data" :key="row.id || idx" @click="$emit('row-click', row)">
<td v-for="col in columns" :key="col.key">
<slot :name="col.key" :row="row" :value="row[col.key]">
{{ formatValue(row[col.key], col) }}
</slot>
</td>
<td v-if="$slots.actions" class="actions-col">
<slot name="actions" :row="row"></slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
const props = defineProps({
columns: {
type: Array,
required: true
// { key: 'name', label: '이름', width: '100px', type: 'date' }
},
data: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
emptyText: {
type: String,
default: '데이터가 없습니다.'
}
})
defineEmits(['row-click'])
const formatValue = (value, col) => {
if (value === null || value === undefined) return '-'
if (col.type === 'date' && value) {
return new Date(value).toLocaleString('ko-KR')
}
if (col.type === 'boolean') {
return value ? 'Y' : 'N'
}
return value
}
</script>
<style scoped>
.data-table-wrapper {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.data-table th,
.data-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #eee;
}
.data-table th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
.data-table tbody tr:hover {
background: #f8f9fa;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.actions-col {
width: 120px;
text-align: center;
}
.loading-cell,
.empty-cell {
text-align: center;
color: #6c757d;
padding: 40px !important;
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<div class="form-group">
<label v-if="label" :for="inputId">
{{ label }}
<span v-if="required" class="required">*</span>
</label>
<input
v-if="type !== 'textarea' && type !== 'select'"
:id="inputId"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
class="form-input"
@input="$emit('update:modelValue', $event.target.value)"
/>
<textarea
v-else-if="type === 'textarea'"
:id="inputId"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:rows="rows"
class="form-input"
@input="$emit('update:modelValue', $event.target.value)"
/>
<select
v-else-if="type === 'select'"
:id="inputId"
:value="modelValue"
:disabled="disabled"
class="form-input"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-if="placeholder" value="">{{ placeholder }}</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<span v-if="error" class="error-text">{{ error }}</span>
<span v-if="hint" class="hint-text">{{ hint }}</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
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: () => []
// [{ value: 'a', label: 'A' }]
}
})
defineEmits(['update:modelValue'])
const inputId = computed(() => `input-${Math.random().toString(36).slice(2, 9)}`)
</script>
<style scoped>
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #333;
}
.required {
color: #e74c3c;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #3498db;
}
.form-input:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
textarea.form-input {
resize: vertical;
}
select.form-input {
cursor: pointer;
}
.error-text {
display: block;
margin-top: 4px;
font-size: 12px;
color: #e74c3c;
}
.hint-text {
display: block;
margin-top: 4px;
font-size: 12px;
color: #6c757d;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<Teleport to="body">
<div v-if="modelValue" class="modal-overlay" @click.self="close">
<div class="modal" :style="{ width: width }">
<div class="modal-header">
<h3>{{ title }}</h3>
<button class="close-btn" @click="close">&times;</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
width: {
type: String,
default: '500px'
}
})
const emit = defineEmits(['update:modelValue', 'close'])
const close = () => {
emit('update:modelValue', false)
emit('close')
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
margin: 0;
font-size: 1.1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: #333;
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,6 @@
export { default as DataTable } from './DataTable.vue'
export { default as Modal } from './Modal.vue'
export { default as FormInput } from './FormInput.vue'
export { default as Button } from './Button.vue'
export { default as Badge } from './Badge.vue'
export { default as Card } from './Card.vue'

View File

@@ -1,61 +1,545 @@
<template>
<div class="dashboard">
<div class="dashboard-header">
<h2>대시보드</h2>
<p>서버 목록과 분석 실행 화면입니다.</p>
<div class="header-actions">
<Button
@click="scanAllServers"
:loading="scanningAll"
:disabled="activeServers.length === 0"
>
전체 분석 실행
</Button>
</div>
</div>
<div class="server-list">
<table>
<thead>
<tr>
<th>서버명</th>
<th>마지막 분석</th>
<th>마지막 에러</th>
<th>액션</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="4" class="empty">등록된 서버가 없습니다.</td>
</tr>
</tbody>
</table>
<!-- 서버 목록 -->
<div class="server-grid">
<Card v-for="server in servers" :key="server.id" class="server-card">
<template #header>
<div class="server-header">
<div class="server-title">
<Badge :variant="server.active ? 'success' : 'default'" size="sm">
{{ server.active ? '활성' : '비활성' }}
</Badge>
<h4>{{ server.name }}</h4>
</div>
<div class="server-actions">
<Button
size="sm"
@click="scanServer(server)"
:loading="scanningServerId === server.id"
:disabled="!server.active || scanningAll"
>
분석 실행
</Button>
</div>
</div>
</template>
<div class="server-info">
<div class="info-row">
<span class="label">호스트</span>
<span class="value">{{ server.host }}:{{ server.port }}</span>
</div>
<div class="info-row">
<span class="label">마지막 분석</span>
<span class="value">{{ formatDate(server.lastScanAt) }}</span>
</div>
<div class="info-row">
<span class="label">마지막 에러</span>
<span class="value" :class="{ 'has-error': server.lastErrorAt }">
{{ formatDate(server.lastErrorAt) }}
</span>
</div>
</div>
<!-- 진행 상황 -->
<div v-if="progressMap[server.id]" class="progress-section">
<div class="progress-header">
<span class="status-text">{{ getStatusText(progressMap[server.id]) }}</span>
<Badge :variant="progressMap[server.id].status === 'RUNNING' ? 'warn' : 'success'">
{{ progressMap[server.id].status }}
</Badge>
</div>
<div class="progress-bar-container">
<div
class="progress-bar"
:style="{ width: getProgressPercent(progressMap[server.id]) + '%' }"
></div>
</div>
<div class="progress-details">
<span>파일: {{ progressMap[server.id].scannedFiles }} / {{ progressMap[server.id].totalFiles }}</span>
<span>에러: {{ progressMap[server.id].errorsFound }}</span>
</div>
</div>
</Card>
</div>
<!-- 서버 없음 -->
<Card v-if="servers.length === 0 && !loading" class="empty-card">
<div class="empty-content">
<p>등록된 서버가 없습니다.</p>
<Button @click="$router.push('/servers')">서버 등록하기</Button>
</div>
</Card>
<!-- 최근 에러 -->
<Card v-if="recentErrors.length > 0" class="recent-errors">
<template #header>
<div class="section-header">
<h3>최근 에러</h3>
<Button size="sm" variant="secondary" @click="$router.push('/errors')">전체보기</Button>
</div>
</template>
<table class="error-table">
<thead>
<tr>
<th>시간</th>
<th>서버</th>
<th>심각도</th>
<th>요약</th>
</tr>
</thead>
<tbody>
<tr v-for="error in recentErrors" :key="error.id" @click="showErrorDetail(error)">
<td>{{ formatDate(error.occurredAt) }}</td>
<td>{{ error.serverName }}</td>
<td>
<Badge :variant="getSeverityVariant(error.severity)">{{ error.severity }}</Badge>
</td>
<td class="summary-cell">{{ truncate(error.summary, 80) }}</td>
</tr>
</tbody>
</table>
</Card>
<!-- 에러 상세 모달 -->
<Modal v-model="showErrorModal" title="에러 상세" width="800px">
<div v-if="selectedError" class="error-detail">
<div class="detail-grid">
<div class="detail-item">
<label>서버</label>
<span>{{ selectedError.serverName }}</span>
</div>
<div class="detail-item">
<label>심각도</label>
<Badge :variant="getSeverityVariant(selectedError.severity)">{{ selectedError.severity }}</Badge>
</div>
<div class="detail-item">
<label>파일</label>
<span>{{ selectedError.filePath }}</span>
</div>
<div class="detail-item">
<label>라인</label>
<span>{{ selectedError.lineNumber }}</span>
</div>
<div class="detail-item">
<label>발생시간</label>
<span>{{ formatDate(selectedError.occurredAt) }}</span>
</div>
<div class="detail-item">
<label>패턴</label>
<span>{{ selectedError.patternName }}</span>
</div>
</div>
<div class="detail-section">
<label>요약</label>
<div class="summary-box">{{ selectedError.summary }}</div>
</div>
<div class="detail-section">
<label>컨텍스트</label>
<pre class="context-box">{{ selectedError.context }}</pre>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showErrorModal = false">닫기</Button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Card, Button, Badge, Modal } from '@/components'
import { serverApi, scanApi, errorLogApi } from '@/api'
// State
const servers = ref([])
const loading = ref(false)
const scanningServerId = ref(null)
const scanningAll = ref(false)
const progressMap = ref({})
const recentErrors = ref([])
// Error detail
const showErrorModal = ref(false)
const selectedError = ref(null)
// Computed
const activeServers = computed(() => servers.value.filter(s => s.active))
// Load data
const loadServers = async () => {
loading.value = true
try {
servers.value = await serverApi.getAll()
} catch (e) {
console.error('Failed to load servers:', e)
} finally {
loading.value = false
}
}
const loadRecentErrors = async () => {
try {
const result = await errorLogApi.search({ page: 0, size: 10 })
recentErrors.value = result.content || []
} catch (e) {
console.error('Failed to load recent errors:', e)
}
}
// Scan single server
const scanServer = (server) => {
scanningServerId.value = server.id
progressMap.value[server.id] = {
status: 'RUNNING',
currentPath: '',
currentFile: '',
totalFiles: 0,
scannedFiles: 0,
errorsFound: 0
}
scanApi.startWithProgress(
server.id,
(progress) => {
progressMap.value[server.id] = progress
},
(result) => {
scanningServerId.value = null
if (result.success) {
progressMap.value[server.id] = {
...progressMap.value[server.id],
status: 'SUCCESS',
message: `완료: ${result.filesScanned}개 파일, ${result.errorsFound}개 에러`
}
} else {
progressMap.value[server.id] = {
...progressMap.value[server.id],
status: 'FAILED',
message: result.error
}
}
loadServers()
loadRecentErrors()
// 3초 후 진행상황 제거
setTimeout(() => {
delete progressMap.value[server.id]
}, 5000)
},
(error) => {
scanningServerId.value = null
progressMap.value[server.id] = {
...progressMap.value[server.id],
status: 'FAILED',
message: error
}
}
)
}
// Scan all servers
const scanAllServers = () => {
scanningAll.value = true
scanApi.startAllWithProgress(
(progress) => {
progressMap.value[progress.serverId] = progress
},
(results) => {
scanningAll.value = false
loadServers()
loadRecentErrors()
// 3초 후 진행상황 제거
setTimeout(() => {
progressMap.value = {}
}, 5000)
},
(error) => {
scanningAll.value = false
alert('분석 실패: ' + error)
}
)
}
// Error detail
const showErrorDetail = (error) => {
selectedError.value = error
showErrorModal.value = true
}
// Utils
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ko-KR')
}
const truncate = (str, len) => {
if (!str) return ''
return str.length > len ? str.substring(0, len) + '...' : str
}
const getStatusText = (progress) => {
if (progress.status === 'SUCCESS') return progress.message || '완료'
if (progress.status === 'FAILED') return progress.message || '실패'
if (progress.currentFile) return `분석중: ${progress.currentFile}`
if (progress.currentPath) return `경로: ${progress.currentPath}`
return '준비중...'
}
const getProgressPercent = (progress) => {
if (progress.totalFiles === 0) return 0
return Math.round((progress.scannedFiles / progress.totalFiles) * 100)
}
const getSeverityVariant = (severity) => {
const map = { 'CRITICAL': 'critical', 'ERROR': 'error', 'WARN': 'warn' }
return map[severity] || 'default'
}
onMounted(() => {
loadServers()
loadRecentErrors()
})
</script>
<style scoped>
.dashboard h2 {
margin-bottom: 1rem;
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.server-list {
background: white;
.dashboard-header h2 {
margin: 0;
}
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.server-card {
transition: box-shadow 0.2s;
}
.server-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.server-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.server-title {
display: flex;
align-items: center;
gap: 10px;
}
.server-title h4 {
margin: 0;
font-size: 16px;
}
.server-info {
margin-bottom: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-row:last-child {
border-bottom: none;
}
.info-row .label {
color: #666;
font-size: 13px;
}
.info-row .value {
font-weight: 500;
}
.info-row .value.has-error {
color: #e74c3c;
}
.progress-section {
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-top: 12px;
}
table {
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.status-text {
font-size: 13px;
color: #333;
}
.progress-bar-container {
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-bar {
height: 100%;
background: #3498db;
transition: width 0.3s;
}
.progress-details {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #666;
}
.empty-card {
text-align: center;
}
.empty-content {
padding: 40px 20px;
}
.empty-content p {
margin-bottom: 16px;
color: #666;
}
.recent-errors {
margin-top: 24px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-header h3 {
margin: 0;
}
.error-table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
.error-table th,
.error-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
.error-table th {
background: #f8f9fa;
font-weight: 600;
font-size: 13px;
}
.empty {
text-align: center;
color: #999;
.error-table tbody tr {
cursor: pointer;
transition: background 0.2s;
}
.error-table tbody tr:hover {
background: #f8f9fa;
}
.summary-cell {
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Error Detail Modal */
.error-detail {
max-height: 60vh;
overflow-y: auto;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-item label {
font-size: 12px;
color: #666;
}
.detail-section {
margin-bottom: 16px;
}
.detail-section label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.summary-box {
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
font-size: 14px;
}
.context-box {
padding: 12px;
background: #2d2d2d;
color: #f8f8f2;
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
margin: 0;
}
</style>

View File

@@ -0,0 +1,554 @@
<template>
<div class="error-history">
<Card>
<template #header>
<div class="card-header-content">
<h3>에러 이력</h3>
<div class="header-actions">
<Button size="sm" variant="secondary" @click="exportHtml" :loading="exporting === 'html'">
HTML
</Button>
<Button size="sm" variant="secondary" @click="exportTxt" :loading="exporting === 'txt'">
TXT
</Button>
</div>
</div>
</template>
<!-- 필터 -->
<div class="filters">
<div class="filter-row">
<FormInput
v-model="filters.serverId"
label="서버"
type="select"
:options="serverOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.patternId"
label="패턴"
type="select"
:options="patternOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.severity"
label="심각도"
type="select"
:options="severityOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.keyword"
label="키워드"
placeholder="검색어 입력..."
/>
</div>
<div class="filter-row">
<FormInput
v-model="filters.startDate"
label="시작일"
type="date"
/>
<FormInput
v-model="filters.endDate"
label="종료일"
type="date"
/>
<div class="filter-actions">
<Button @click="search">검색</Button>
<Button variant="secondary" @click="resetFilters">초기화</Button>
</div>
</div>
</div>
<!-- 결과 테이블 -->
<div class="results-section">
<div class="results-header">
<span v-if="totalElements > 0"> {{ totalElements }}</span>
</div>
<div class="table-wrapper">
<table class="error-table" v-if="errors.length > 0">
<thead>
<tr>
<th class="col-time">발생시간</th>
<th class="col-server">서버</th>
<th class="col-severity">심각도</th>
<th class="col-pattern">패턴</th>
<th class="col-summary">요약</th>
<th class="col-action">작업</th>
</tr>
</thead>
<tbody>
<tr v-for="error in errors" :key="error.id">
<td class="col-time">{{ formatDate(error.occurredAt) }}</td>
<td class="col-server">{{ error.serverName }}</td>
<td class="col-severity">
<Badge :variant="getSeverityVariant(error.severity)">{{ error.severity }}</Badge>
</td>
<td class="col-pattern">{{ error.patternName }}</td>
<td class="col-summary">{{ truncate(error.summary, 50) }}</td>
<td class="col-action">
<Button size="sm" variant="secondary" @click="showDetail(error)">상세</Button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="errors.length === 0 && !loading" class="empty-result">
<p>검색 결과가 없습니다.</p>
</div>
<div v-if="loading" class="loading-result">
<p>로딩중...</p>
</div>
<!-- 페이지네이션 -->
<div v-if="totalPages > 1" class="pagination">
<Button
size="sm"
variant="secondary"
:disabled="currentPage === 0"
@click="goToPage(currentPage - 1)"
>
이전
</Button>
<span class="page-info">{{ currentPage + 1 }} / {{ totalPages }}</span>
<Button
size="sm"
variant="secondary"
:disabled="currentPage >= totalPages - 1"
@click="goToPage(currentPage + 1)"
>
다음
</Button>
</div>
</div>
</Card>
<!-- 상세 모달 -->
<Modal v-model="showDetailModal" title="에러 상세" width="900px">
<div v-if="selectedError" class="error-detail">
<div class="detail-grid">
<div class="detail-item">
<label>서버</label>
<span>{{ selectedError.serverName }}</span>
</div>
<div class="detail-item">
<label>심각도</label>
<Badge :variant="getSeverityVariant(selectedError.severity)">{{ selectedError.severity }}</Badge>
</div>
<div class="detail-item">
<label>패턴</label>
<span>{{ selectedError.patternName }}</span>
</div>
<div class="detail-item">
<label>파일</label>
<span class="file-path">{{ selectedError.filePath }}</span>
</div>
<div class="detail-item">
<label>라인</label>
<span>{{ selectedError.lineNumber }}</span>
</div>
<div class="detail-item">
<label>발생시간</label>
<span>{{ formatDate(selectedError.occurredAt) }}</span>
</div>
</div>
<div class="detail-section">
<label>요약</label>
<div class="summary-box">{{ selectedError.summary }}</div>
</div>
<div class="detail-section">
<label>컨텍스트</label>
<pre class="context-box">{{ selectedError.context }}</pre>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showDetailModal = false">닫기</Button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Card, Button, Badge, Modal, FormInput } from '@/components'
import { serverApi, patternApi, errorLogApi } from '@/api'
// State
const loading = ref(false)
const exporting = ref(null)
const errors = ref([])
const totalElements = ref(0)
const totalPages = ref(0)
const currentPage = ref(0)
const pageSize = 20
// Filters
const filters = reactive({
serverId: '',
patternId: '',
severity: '',
keyword: '',
startDate: '',
endDate: ''
})
// Options
const serverOptions = ref([])
const patternOptions = ref([])
const severityOptions = [
{ value: '', label: '전체' },
{ value: 'CRITICAL', label: 'CRITICAL' },
{ value: 'ERROR', label: 'ERROR' },
{ value: 'WARN', label: 'WARN' }
]
// Detail Modal
const showDetailModal = ref(false)
const selectedError = ref(null)
// Load options
const loadOptions = async () => {
try {
const servers = await serverApi.getAll()
serverOptions.value = [
{ value: '', label: '전체' },
...servers.map(s => ({ value: s.id, label: s.name }))
]
const patterns = await patternApi.getAll()
patternOptions.value = [
{ value: '', label: '전체' },
...patterns.map(p => ({ value: p.id, label: p.name }))
]
} catch (e) {
console.error('Failed to load options:', e)
}
}
// Search
const search = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
size: pageSize
}
if (filters.serverId) params.serverId = filters.serverId
if (filters.patternId) params.patternId = filters.patternId
if (filters.severity) params.severity = filters.severity
if (filters.keyword) params.keyword = filters.keyword
if (filters.startDate) params.startDate = filters.startDate + 'T00:00:00'
if (filters.endDate) params.endDate = filters.endDate + 'T23:59:59'
const result = await errorLogApi.search(params)
errors.value = result.content || []
totalElements.value = result.totalElements || 0
totalPages.value = result.totalPages || 0
} catch (e) {
console.error('Failed to search errors:', e)
errors.value = []
} finally {
loading.value = false
}
}
const resetFilters = () => {
filters.serverId = ''
filters.patternId = ''
filters.severity = ''
filters.keyword = ''
filters.startDate = ''
filters.endDate = ''
currentPage.value = 0
search()
}
const goToPage = (page) => {
currentPage.value = page
search()
}
// Detail
const showDetail = async (error) => {
try {
selectedError.value = await errorLogApi.getById(error.id)
showDetailModal.value = true
} catch (e) {
console.error('Failed to load error detail:', e)
selectedError.value = error
showDetailModal.value = true
}
}
// Export
const buildExportParams = () => {
const params = new URLSearchParams()
if (filters.serverId) params.append('serverId', filters.serverId)
if (filters.patternId) params.append('patternId', filters.patternId)
if (filters.severity) params.append('severity', filters.severity)
if (filters.keyword) params.append('keyword', filters.keyword)
if (filters.startDate) params.append('startDate', filters.startDate + 'T00:00:00')
if (filters.endDate) params.append('endDate', filters.endDate + 'T23:59:59')
return params.toString()
}
const exportHtml = () => {
exporting.value = 'html'
const params = buildExportParams()
window.open(`/api/export/html?${params}`, '_blank')
setTimeout(() => { exporting.value = null }, 1000)
}
const exportTxt = () => {
exporting.value = 'txt'
const params = buildExportParams()
window.open(`/api/export/txt?${params}`, '_blank')
setTimeout(() => { exporting.value = null }, 1000)
}
// Utils
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ko-KR')
}
const truncate = (str, len) => {
if (!str) return ''
return str.length > len ? str.substring(0, len) + '...' : str
}
const getSeverityVariant = (severity) => {
const map = { 'CRITICAL': 'critical', 'ERROR': 'error', 'WARN': 'warn' }
return map[severity] || 'default'
}
onMounted(() => {
loadOptions()
search()
})
</script>
<style scoped>
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header-content h3 {
margin: 0;
}
.header-actions {
display: flex;
gap: 8px;
}
.header-actions :deep(.btn) {
white-space: nowrap;
min-width: 60px;
}
.filters {
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 20px;
}
.filter-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 12px;
}
.filter-row:last-child {
margin-bottom: 0;
}
.filter-actions {
display: flex;
align-items: flex-end;
gap: 8px;
padding-bottom: 4px;
}
.filter-actions :deep(.btn) {
white-space: nowrap;
}
.results-section {
margin-top: 16px;
}
.results-header {
margin-bottom: 12px;
color: #666;
font-size: 14px;
}
.table-wrapper {
overflow-x: auto;
}
.error-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.error-table th,
.error-table td {
padding: 10px 8px;
text-align: left;
border-bottom: 1px solid #eee;
overflow: hidden;
text-overflow: ellipsis;
}
.error-table th {
background: #f8f9fa;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.error-table tbody tr:hover {
background: #fafafa;
}
/* Column widths */
.col-time {
width: 140px;
white-space: nowrap;
}
.col-server {
width: 130px;
white-space: nowrap;
}
.col-severity {
width: 90px;
white-space: nowrap;
}
.col-pattern {
width: 120px;
white-space: nowrap;
}
.col-summary {
min-width: 200px;
}
.col-action {
width: 70px;
text-align: center;
}
.col-action :deep(.btn) {
white-space: nowrap;
padding: 4px 12px;
}
.empty-result,
.loading-result {
text-align: center;
padding: 40px;
color: #666;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #eee;
}
.pagination :deep(.btn) {
white-space: nowrap;
min-width: 50px;
}
.page-info {
font-size: 14px;
color: #666;
}
/* Error Detail Modal */
.error-detail {
max-height: 65vh;
overflow-y: auto;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-item label {
font-size: 12px;
color: #666;
}
.file-path {
word-break: break-all;
font-family: monospace;
font-size: 13px;
}
.detail-section {
margin-bottom: 16px;
}
.detail-section label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.summary-box {
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
font-size: 14px;
word-break: break-all;
}
.context-box {
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;
}
</style>

View File

@@ -1,92 +1,554 @@
<template>
<div class="error-logs">
<h2>에러 이력</h2>
<p>수집된 에러 목록을 조회합니다.</p>
<div class="filters">
<select><option>전체 서버</option></select>
<input type="date" placeholder="시작일">
<input type="date" placeholder="종료일">
<input type="text" placeholder="키워드 검색">
<button>검색</button>
</div>
<div class="error-list">
<table>
<thead>
<tr>
<th>발생일시</th>
<th>서버</th>
<th>패턴</th>
<th>에러 요약</th>
<th>상세</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="empty">에러 이력이 없습니다.</td>
</tr>
</tbody>
</table>
<div class="error-history">
<Card>
<template #header>
<div class="card-header-content">
<h3>에러 이력</h3>
<div class="header-actions">
<Button size="sm" variant="secondary" @click="exportHtml" :loading="exporting === 'html'">
HTML
</Button>
<Button size="sm" variant="secondary" @click="exportTxt" :loading="exporting === 'txt'">
TXT
</Button>
</div>
</div>
</template>
<!-- 필터 -->
<div class="filters">
<div class="filter-row">
<FormInput
v-model="filters.serverId"
label="서버"
type="select"
:options="serverOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.patternId"
label="패턴"
type="select"
:options="patternOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.severity"
label="심각도"
type="select"
:options="severityOptions"
placeholder="전체"
/>
<FormInput
v-model="filters.keyword"
label="키워드"
placeholder="검색어 입력..."
/>
</div>
<div class="filter-row">
<FormInput
v-model="filters.startDate"
label="시작일"
type="date"
/>
<FormInput
v-model="filters.endDate"
label="종료일"
type="date"
/>
<div class="filter-actions">
<Button @click="search">검색</Button>
<Button variant="secondary" @click="resetFilters">초기화</Button>
</div>
</div>
</div>
<!-- 결과 테이블 -->
<div class="results-section">
<div class="results-header">
<span v-if="totalElements > 0"> {{ totalElements }}</span>
</div>
<div class="table-wrapper">
<table class="error-table" v-if="errors.length > 0">
<thead>
<tr>
<th class="col-time">발생시간</th>
<th class="col-server">서버</th>
<th class="col-severity">심각도</th>
<th class="col-pattern">패턴</th>
<th class="col-summary">요약</th>
<th class="col-action">작업</th>
</tr>
</thead>
<tbody>
<tr v-for="error in errors" :key="error.id">
<td class="col-time">{{ formatDate(error.occurredAt) }}</td>
<td class="col-server">{{ error.serverName }}</td>
<td class="col-severity">
<Badge :variant="getSeverityVariant(error.severity)">{{ error.severity }}</Badge>
</td>
<td class="col-pattern">{{ error.patternName }}</td>
<td class="col-summary">{{ truncate(error.summary, 50) }}</td>
<td class="col-action">
<Button size="sm" variant="secondary" @click="showDetail(error)">상세</Button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="errors.length === 0 && !loading" class="empty-result">
<p>검색 결과가 없습니다.</p>
</div>
<div v-if="loading" class="loading-result">
<p>로딩중...</p>
</div>
<!-- 페이지네이션 -->
<div v-if="totalPages > 1" class="pagination">
<Button
size="sm"
variant="secondary"
:disabled="currentPage === 0"
@click="goToPage(currentPage - 1)"
>
이전
</Button>
<span class="page-info">{{ currentPage + 1 }} / {{ totalPages }}</span>
<Button
size="sm"
variant="secondary"
:disabled="currentPage >= totalPages - 1"
@click="goToPage(currentPage + 1)"
>
다음
</Button>
</div>
</div>
</Card>
<!-- 상세 모달 -->
<Modal v-model="showDetailModal" title="에러 상세" width="900px">
<div v-if="selectedError" class="error-detail">
<div class="detail-grid">
<div class="detail-item">
<label>서버</label>
<span>{{ selectedError.serverName }}</span>
</div>
<div class="detail-item">
<label>심각도</label>
<Badge :variant="getSeverityVariant(selectedError.severity)">{{ selectedError.severity }}</Badge>
</div>
<div class="detail-item">
<label>패턴</label>
<span>{{ selectedError.patternName }}</span>
</div>
<div class="detail-item">
<label>파일</label>
<span class="file-path">{{ selectedError.filePath }}</span>
</div>
<div class="detail-item">
<label>라인</label>
<span>{{ selectedError.lineNumber }}</span>
</div>
<div class="detail-item">
<label>발생시간</label>
<span>{{ formatDate(selectedError.occurredAt) }}</span>
</div>
</div>
<div class="detail-section">
<label>요약</label>
<div class="summary-box">{{ selectedError.summary }}</div>
</div>
<div class="detail-section">
<label>컨텍스트</label>
<pre class="context-box">{{ selectedError.context }}</pre>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showDetailModal = false">닫기</Button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Card, Button, Badge, Modal, FormInput } from '@/components'
import { serverApi, patternApi, errorLogApi } from '@/api'
// State
const loading = ref(false)
const exporting = ref(null)
const errors = ref([])
const totalElements = ref(0)
const totalPages = ref(0)
const currentPage = ref(0)
const pageSize = 20
// Filters
const filters = reactive({
serverId: '',
patternId: '',
severity: '',
keyword: '',
startDate: '',
endDate: ''
})
// Options
const serverOptions = ref([])
const patternOptions = ref([])
const severityOptions = [
{ value: '', label: '전체' },
{ value: 'CRITICAL', label: 'CRITICAL' },
{ value: 'ERROR', label: 'ERROR' },
{ value: 'WARN', label: 'WARN' }
]
// Detail Modal
const showDetailModal = ref(false)
const selectedError = ref(null)
// Load options
const loadOptions = async () => {
try {
const servers = await serverApi.getAll()
serverOptions.value = [
{ value: '', label: '전체' },
...servers.map(s => ({ value: s.id, label: s.name }))
]
const patterns = await patternApi.getAll()
patternOptions.value = [
{ value: '', label: '전체' },
...patterns.map(p => ({ value: p.id, label: p.name }))
]
} catch (e) {
console.error('Failed to load options:', e)
}
}
// Search
const search = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
size: pageSize
}
if (filters.serverId) params.serverId = filters.serverId
if (filters.patternId) params.patternId = filters.patternId
if (filters.severity) params.severity = filters.severity
if (filters.keyword) params.keyword = filters.keyword
if (filters.startDate) params.startDate = filters.startDate + 'T00:00:00'
if (filters.endDate) params.endDate = filters.endDate + 'T23:59:59'
const result = await errorLogApi.search(params)
errors.value = result.content || []
totalElements.value = result.totalElements || 0
totalPages.value = result.totalPages || 0
} catch (e) {
console.error('Failed to search errors:', e)
errors.value = []
} finally {
loading.value = false
}
}
const resetFilters = () => {
filters.serverId = ''
filters.patternId = ''
filters.severity = ''
filters.keyword = ''
filters.startDate = ''
filters.endDate = ''
currentPage.value = 0
search()
}
const goToPage = (page) => {
currentPage.value = page
search()
}
// Detail
const showDetail = async (error) => {
try {
selectedError.value = await errorLogApi.getById(error.id)
showDetailModal.value = true
} catch (e) {
console.error('Failed to load error detail:', e)
selectedError.value = error
showDetailModal.value = true
}
}
// Export
const buildExportParams = () => {
const params = new URLSearchParams()
if (filters.serverId) params.append('serverId', filters.serverId)
if (filters.patternId) params.append('patternId', filters.patternId)
if (filters.severity) params.append('severity', filters.severity)
if (filters.keyword) params.append('keyword', filters.keyword)
if (filters.startDate) params.append('startDate', filters.startDate + 'T00:00:00')
if (filters.endDate) params.append('endDate', filters.endDate + 'T23:59:59')
return params.toString()
}
const exportHtml = () => {
exporting.value = 'html'
const params = buildExportParams()
window.open(`/api/export/html?${params}`, '_blank')
setTimeout(() => { exporting.value = null }, 1000)
}
const exportTxt = () => {
exporting.value = 'txt'
const params = buildExportParams()
window.open(`/api/export/txt?${params}`, '_blank')
setTimeout(() => { exporting.value = null }, 1000)
}
// Utils
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ko-KR')
}
const truncate = (str, len) => {
if (!str) return ''
return str.length > len ? str.substring(0, len) + '...' : str
}
const getSeverityVariant = (severity) => {
const map = { 'CRITICAL': 'critical', 'ERROR': 'error', 'WARN': 'warn' }
return map[severity] || 'default'
}
onMounted(() => {
loadOptions()
search()
})
</script>
<style scoped>
.error-logs h2 {
margin-bottom: 1rem;
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header-content h3 {
margin: 0;
}
.header-actions {
display: flex;
gap: 8px;
}
.header-actions :deep(.btn) {
white-space: nowrap;
min-width: 60px;
}
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters select,
.filters input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.filters button {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.error-list {
background: white;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
table {
.filter-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 12px;
}
.filter-row:last-child {
margin-bottom: 0;
}
.filter-actions {
display: flex;
align-items: flex-end;
gap: 8px;
padding-bottom: 4px;
}
.filter-actions :deep(.btn) {
white-space: nowrap;
}
.results-section {
margin-top: 16px;
}
.results-header {
margin-bottom: 12px;
color: #666;
font-size: 14px;
}
.table-wrapper {
overflow-x: auto;
}
.error-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th, td {
padding: 0.75rem;
.error-table th,
.error-table td {
padding: 10px 8px;
text-align: left;
border-bottom: 1px solid #eee;
overflow: hidden;
text-overflow: ellipsis;
}
th {
.error-table th {
background: #f8f9fa;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.empty {
.error-table tbody tr:hover {
background: #fafafa;
}
/* Column widths */
.col-time {
width: 140px;
white-space: nowrap;
}
.col-server {
width: 130px;
white-space: nowrap;
}
.col-severity {
width: 90px;
white-space: nowrap;
}
.col-pattern {
width: 120px;
white-space: nowrap;
}
.col-summary {
min-width: 200px;
}
.col-action {
width: 70px;
text-align: center;
color: #999;
}
.col-action :deep(.btn) {
white-space: nowrap;
padding: 4px 12px;
}
.empty-result,
.loading-result {
text-align: center;
padding: 40px;
color: #666;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #eee;
}
.pagination :deep(.btn) {
white-space: nowrap;
min-width: 50px;
}
.page-info {
font-size: 14px;
color: #666;
}
/* Error Detail Modal */
.error-detail {
max-height: 65vh;
overflow-y: auto;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-item label {
font-size: 12px;
color: #666;
}
.file-path {
word-break: break-all;
font-family: monospace;
font-size: 13px;
}
.detail-section {
margin-bottom: 16px;
}
.detail-section label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.summary-box {
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
font-size: 14px;
word-break: break-all;
}
.context-box {
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;
}
</style>

View File

@@ -1,56 +1,424 @@
<template>
<div class="pattern-manage">
<h2>패턴 관리</h2>
<p>에러 검출 패턴을 관리합니다.</p>
<Card>
<template #header>
<div class="card-header-content">
<h3>패턴 관리</h3>
<Button @click="openAddModal">+ 패턴 추가</Button>
</div>
</template>
<div class="actions">
<button class="btn-primary">+ 패턴 추가</button>
<DataTable
:columns="columns"
:data="patterns"
:loading="loading"
empty-text="등록된 패턴이 없습니다."
>
<template #severity="{ value }">
<Badge :variant="getSeverityVariant(value)">{{ value }}</Badge>
</template>
<template #regex="{ value }">
<code class="regex-code">{{ truncate(value, 50) }}</code>
</template>
<template #active="{ value }">
<Badge :variant="value ? 'success' : 'default'">
{{ value ? '활성' : '비활성' }}
</Badge>
</template>
<template #actions="{ row }">
<div class="action-buttons">
<Button size="sm" variant="secondary" @click="openTestModal(row)">테스트</Button>
<Button size="sm" @click="openEditModal(row)">수정</Button>
<Button size="sm" variant="danger" @click="confirmDelete(row)">삭제</Button>
</div>
</template>
</DataTable>
</Card>
<!-- 패턴 추가/수정 모달 -->
<Modal v-model="showPatternModal" :title="isEdit ? '패턴 수정' : '패턴 추가'" width="600px">
<form @submit.prevent="savePattern">
<FormInput
v-model="form.name"
label="패턴명"
placeholder="예: NullPointerException"
required
/>
<FormInput
v-model="form.regex"
label="정규식"
type="textarea"
:rows="3"
placeholder="예: (Exception|Error|SEVERE|FATAL)"
required
hint="Java 정규식 문법을 사용합니다."
/>
<FormInput
v-model="form.severity"
label="심각도"
type="select"
:options="severityOptions"
required
/>
<FormInput
v-model="form.contextLines"
label="컨텍스트 라인 수"
type="number"
placeholder="5"
hint="에러 전후로 캡처할 라인 수"
/>
<FormInput
v-model="form.description"
label="설명"
type="textarea"
:rows="2"
placeholder="이 패턴에 대한 설명"
/>
<div class="form-group">
<label>
<input type="checkbox" v-model="form.active" />
활성화
</label>
</div>
</form>
<template #footer>
<Button variant="secondary" @click="showPatternModal = false">취소</Button>
<Button @click="savePattern" :loading="saving">저장</Button>
</template>
</Modal>
<!-- 패턴 테스트 모달 -->
<Modal v-model="showTestModal" :title="`패턴 테스트 - ${testTarget?.name || ''}`" width="700px">
<div class="test-section">
<div class="test-pattern">
<label>정규식</label>
<code class="regex-display">{{ testTarget?.regex }}</code>
</div>
<div class="pattern-list">
<table>
<thead>
<tr>
<th>패턴명</th>
<th>정규식</th>
<th>심각도</th>
<th>컨텍스트</th>
<th>활성화</th>
<th>액션</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="empty">등록된 패턴이 없습니다.</td>
</tr>
</tbody>
</table>
<FormInput
v-model="testSampleText"
label="테스트할 텍스트"
type="textarea"
:rows="6"
placeholder="로그 텍스트를 붙여넣으세요..."
/>
<Button @click="runPatternTest" :loading="testing" :disabled="!testSampleText">
테스트 실행
</Button>
<div v-if="testResult" class="test-result" :class="{ success: testResult.matched, fail: !testResult.matched }">
<h4>테스트 결과</h4>
<div v-if="!testResult.validRegex" class="error-msg">
정규식 오류: {{ testResult.errorMessage }}
</div>
<div v-else-if="testResult.matched">
<p> 매칭 성공!</p>
<div class="match-info">
<label>매칭된 텍스트:</label>
<code>{{ testResult.matchedText }}</code>
</div>
<div class="match-info">
<label>위치:</label>
<span>{{ testResult.matchStart }} ~ {{ testResult.matchEnd }}</span>
</div>
</div>
<div v-else>
<p> 매칭 없음</p>
</div>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showTestModal = false">닫기</Button>
</template>
</Modal>
<!-- 삭제 확인 모달 -->
<Modal v-model="showDeleteModal" title="패턴 삭제" width="400px">
<p>정말로 <strong>{{ deleteTarget?.name }}</strong> 패턴을 삭제하시겠습니까?</p>
<template #footer>
<Button variant="secondary" @click="showDeleteModal = false">취소</Button>
<Button variant="danger" @click="deletePattern" :loading="deleting">삭제</Button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { DataTable, Modal, FormInput, Button, Badge, Card } from '@/components'
import { patternApi } from '@/api'
const columns = [
{ key: 'name', label: '패턴명', width: '150px' },
{ key: 'regex', label: '정규식' },
{ key: 'severity', label: '심각도', width: '100px' },
{ key: 'contextLines', label: '컨텍스트', width: '90px' },
{ key: 'active', label: '상태', width: '80px' }
]
const severityOptions = [
{ value: 'CRITICAL', label: 'CRITICAL' },
{ value: 'ERROR', label: 'ERROR' },
{ value: 'WARN', label: 'WARN' }
]
// State
const patterns = ref([])
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const testing = ref(false)
// Pattern Modal
const showPatternModal = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const form = ref({
name: '',
regex: '',
severity: 'ERROR',
contextLines: 5,
description: '',
active: true
})
// Test Modal
const showTestModal = ref(false)
const testTarget = ref(null)
const testSampleText = ref('')
const testResult = ref(null)
// Delete Modal
const showDeleteModal = ref(false)
const deleteTarget = ref(null)
// Load patterns
const loadPatterns = async () => {
loading.value = true
try {
patterns.value = await patternApi.getAll()
} catch (e) {
console.error('Failed to load patterns:', e)
alert('패턴 목록을 불러오는데 실패했습니다.')
} finally {
loading.value = false
}
}
// Open Add Modal
const openAddModal = () => {
isEdit.value = false
editId.value = null
form.value = {
name: '',
regex: '',
severity: 'ERROR',
contextLines: 5,
description: '',
active: true
}
showPatternModal.value = true
}
// Open Edit Modal
const openEditModal = (pattern) => {
isEdit.value = true
editId.value = pattern.id
form.value = {
name: pattern.name,
regex: pattern.regex,
severity: pattern.severity,
contextLines: pattern.contextLines,
description: pattern.description || '',
active: pattern.active
}
showPatternModal.value = true
}
// Save Pattern
const savePattern = async () => {
if (!form.value.name || !form.value.regex) {
alert('필수 항목을 입력해주세요.')
return
}
saving.value = true
try {
if (isEdit.value) {
await patternApi.update(editId.value, form.value)
} else {
await patternApi.create(form.value)
}
showPatternModal.value = false
await loadPatterns()
} catch (e) {
console.error('Failed to save pattern:', e)
alert('저장에 실패했습니다. 정규식 문법을 확인해주세요.')
} finally {
saving.value = false
}
}
// Delete
const confirmDelete = (pattern) => {
deleteTarget.value = pattern
showDeleteModal.value = true
}
const deletePattern = async () => {
deleting.value = true
try {
await patternApi.delete(deleteTarget.value.id)
showDeleteModal.value = false
await loadPatterns()
} catch (e) {
console.error('Failed to delete pattern:', e)
alert('삭제에 실패했습니다.')
} finally {
deleting.value = false
}
}
// Test Modal
const openTestModal = (pattern) => {
testTarget.value = pattern
testSampleText.value = ''
testResult.value = null
showTestModal.value = true
}
const runPatternTest = async () => {
if (!testSampleText.value) return
testing.value = true
try {
testResult.value = await patternApi.test(testTarget.value.regex, testSampleText.value)
} catch (e) {
console.error('Failed to test pattern:', e)
alert('테스트 실행에 실패했습니다.')
} finally {
testing.value = false
}
}
// Utils
const getSeverityVariant = (severity) => {
const map = {
'CRITICAL': 'critical',
'ERROR': 'error',
'WARN': 'warn'
}
return map[severity] || 'default'
}
const truncate = (str, len) => {
if (!str) return ''
return str.length > len ? str.substring(0, len) + '...' : str
}
onMounted(() => {
loadPatterns()
})
</script>
<style scoped>
.pattern-manage h2 { margin-bottom: 1rem; }
.actions { margin-bottom: 1rem; }
.btn-primary {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header-content h3 {
margin: 0;
}
.action-buttons {
display: flex;
gap: 4px;
}
.regex-code {
font-family: monospace;
background: #f1f3f4;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.pattern-list {
background: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
.test-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.test-pattern label {
display: block;
font-weight: 500;
margin-bottom: 6px;
}
.regex-display {
display: block;
font-family: monospace;
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
font-size: 13px;
word-break: break-all;
}
.test-result {
padding: 16px;
border-radius: 8px;
margin-top: 8px;
}
.test-result.success {
background: #d4edda;
border: 1px solid #c3e6cb;
}
.test-result.fail {
background: #f8d7da;
border: 1px solid #f5c6cb;
}
.test-result h4 {
margin: 0 0 12px 0;
}
.test-result p {
margin: 0;
}
.match-info {
margin-top: 8px;
}
.match-info label {
font-weight: 500;
margin-right: 8px;
}
.match-info code {
background: rgba(0,0,0,0.1);
padding: 2px 6px;
border-radius: 3px;
}
.error-msg {
color: #721c24;
}
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; }
.empty { text-align: center; color: #999; }
</style>

View File

@@ -1,79 +1,511 @@
<template>
<div class="server-manage">
<h2>서버 관리</h2>
<p>SFTP 서버 접속 정보를 관리합니다.</p>
<Card>
<template #header>
<div class="card-header-content">
<h3>서버 관리</h3>
<Button @click="openAddModal">+ 서버 추가</Button>
</div>
</template>
<div class="actions">
<button class="btn-primary">+ 서버 추가</button>
<DataTable
:columns="columns"
:data="servers"
:loading="loading"
empty-text="등록된 서버가 없습니다."
>
<template #active="{ value }">
<Badge :variant="value ? 'success' : 'default'">
{{ value ? '활성' : '비활성' }}
</Badge>
</template>
<template #authType="{ value }">
{{ value === 'PASSWORD' ? '비밀번호' : '키 파일' }}
</template>
<template #lastScanAt="{ value }">
{{ value ? formatDate(value) : '-' }}
</template>
<template #actions="{ row }">
<div class="action-buttons">
<Button size="sm" variant="success" @click="testConnection(row)" :loading="testingId === row.id">테스트</Button>
<Button size="sm" variant="secondary" @click="openLogPathModal(row)">경로</Button>
<Button size="sm" @click="openEditModal(row)">수정</Button>
<Button size="sm" variant="danger" @click="confirmDelete(row)">삭제</Button>
</div>
</template>
</DataTable>
</Card>
<!-- 서버 추가/수정 모달 -->
<Modal v-model="showServerModal" :title="isEdit ? '서버 수정' : '서버 추가'" width="500px">
<form @submit.prevent="saveServer">
<FormInput
v-model="form.name"
label="서버명"
placeholder="예: 운영서버1"
required
/>
<FormInput
v-model="form.host"
label="호스트"
placeholder="예: 192.168.1.100"
required
/>
<FormInput
v-model="form.port"
label="포트"
type="number"
placeholder="22"
/>
<FormInput
v-model="form.username"
label="사용자명"
placeholder="예: root"
required
/>
<FormInput
v-model="form.authType"
label="인증 방식"
type="select"
:options="authTypeOptions"
required
/>
<FormInput
v-if="form.authType === 'PASSWORD'"
v-model="form.password"
label="비밀번호"
type="password"
:placeholder="isEdit ? '변경 시에만 입력' : '비밀번호 입력'"
:required="!isEdit"
/>
<FormInput
v-if="form.authType === 'KEY_FILE'"
v-model="form.keyFilePath"
label="키 파일 경로"
placeholder="예: C:\Users\user\.ssh\id_rsa"
required
/>
<FormInput
v-if="form.authType === 'KEY_FILE'"
v-model="form.passphrase"
label="Passphrase"
type="password"
:placeholder="isEdit ? '변경 시에만 입력' : 'Passphrase (없으면 비워두세요)'"
/>
<div class="form-group">
<label>
<input type="checkbox" v-model="form.active" />
활성화
</label>
</div>
</form>
<template #footer>
<Button variant="secondary" @click="showServerModal = false">취소</Button>
<Button @click="saveServer" :loading="saving">저장</Button>
</template>
</Modal>
<!-- 로그 경로 관리 모달 -->
<Modal v-model="showLogPathModal" :title="`로그 경로 관리 - ${selectedServer?.name || ''}`" width="700px">
<div class="log-path-section">
<div class="log-path-form">
<h4>경로 추가</h4>
<div class="log-path-inputs">
<FormInput
v-model="logPathForm.path"
label="경로"
placeholder="예: /var/log/tomcat/"
/>
<FormInput
v-model="logPathForm.filePattern"
label="파일 패턴"
placeholder="예: *.log, catalina.*.log"
/>
<FormInput
v-model="logPathForm.description"
label="설명"
placeholder="예: Tomcat 로그"
/>
</div>
<Button size="sm" @click="addLogPath" :disabled="!logPathForm.path || !logPathForm.filePattern">
추가
</Button>
</div>
<div class="server-list">
<table>
<div class="log-path-list">
<h4>등록된 경로</h4>
<table v-if="logPaths.length > 0">
<thead>
<tr>
<th>서버명</th>
<th>호스트</th>
<th>포트</th>
<th>인증방식</th>
<th>활성화</th>
<th>액션</th>
<th>경로</th>
<th>파일 패턴</th>
<th>설명</th>
<th>활성</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="empty">등록된 서버가 없습니다.</td>
<tr v-for="lp in logPaths" :key="lp.id">
<td>{{ lp.path }}</td>
<td>{{ lp.filePattern }}</td>
<td>{{ lp.description || '-' }}</td>
<td>
<Badge :variant="lp.active ? 'success' : 'default'">
{{ lp.active ? 'Y' : 'N' }}
</Badge>
</td>
<td>
<Button size="sm" variant="danger" @click="deleteLogPath(lp.id)">삭제</Button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-text">등록된 경로가 없습니다.</p>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showLogPathModal = false">닫기</Button>
</template>
</Modal>
<!-- 삭제 확인 모달 -->
<Modal v-model="showDeleteModal" title="서버 삭제" width="400px">
<p>정말로 <strong>{{ deleteTarget?.name }}</strong> 서버를 삭제하시겠습니까?</p>
<p class="warning-text">관련된 모든 로그 경로와 에러 이력도 함께 삭제됩니다.</p>
<template #footer>
<Button variant="secondary" @click="showDeleteModal = false">취소</Button>
<Button variant="danger" @click="deleteServer" :loading="deleting">삭제</Button>
</template>
</Modal>
<!-- 연결 테스트 결과 모달 -->
<Modal v-model="showTestResultModal" title="연결 테스트 결과" width="450px">
<div class="test-result" :class="{ success: testResult?.success, fail: !testResult?.success }">
<div v-if="testResult?.success">
<p> {{ testResult.message }}</p>
</div>
<div v-else>
<p> {{ testResult?.error }}</p>
</div>
</div>
<template #footer>
<Button variant="secondary" @click="showTestResultModal = false">닫기</Button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { DataTable, Modal, FormInput, Button, Badge, Card } from '@/components'
import { serverApi, logPathApi } from '@/api'
const columns = [
{ key: 'name', label: '서버명', width: '150px' },
{ key: 'host', label: '호스트' },
{ key: 'port', label: '포트', width: '80px' },
{ key: 'authType', label: '인증방식', width: '100px' },
{ key: 'active', label: '상태', width: '80px' },
{ key: 'lastScanAt', label: '마지막 분석', width: '150px' }
]
const authTypeOptions = [
{ value: 'PASSWORD', label: '비밀번호' },
{ value: 'KEY_FILE', label: '키 파일' }
]
// State
const servers = ref([])
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
// Server Modal
const showServerModal = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const form = ref({
name: '',
host: '',
port: 22,
username: '',
authType: 'PASSWORD',
password: '',
keyFilePath: '',
passphrase: '',
active: true
})
// LogPath Modal
const showLogPathModal = ref(false)
const selectedServer = ref(null)
const logPaths = ref([])
const logPathForm = ref({
path: '',
filePattern: '',
description: ''
})
// Delete Modal
const showDeleteModal = ref(false)
const deleteTarget = ref(null)
// Test Connection
const testingId = ref(null)
const showTestResultModal = ref(false)
const testResult = ref(null)
// Load servers
const loadServers = async () => {
loading.value = true
try {
servers.value = await serverApi.getAll()
} catch (e) {
console.error('Failed to load servers:', e)
alert('서버 목록을 불러오는데 실패했습니다.')
} finally {
loading.value = false
}
}
// Open Add Modal
const openAddModal = () => {
isEdit.value = false
editId.value = null
form.value = {
name: '',
host: '',
port: 22,
username: '',
authType: 'PASSWORD',
password: '',
keyFilePath: '',
passphrase: '',
active: true
}
showServerModal.value = true
}
// Open Edit Modal
const openEditModal = (server) => {
isEdit.value = true
editId.value = server.id
form.value = {
name: server.name,
host: server.host,
port: server.port,
username: server.username,
authType: server.authType,
password: '',
keyFilePath: server.keyFilePath || '',
passphrase: '',
active: server.active
}
showServerModal.value = true
}
// Save Server
const saveServer = async () => {
if (!form.value.name || !form.value.host || !form.value.username) {
alert('필수 항목을 입력해주세요.')
return
}
saving.value = true
try {
if (isEdit.value) {
await serverApi.update(editId.value, form.value)
} else {
await serverApi.create(form.value)
}
showServerModal.value = false
await loadServers()
} catch (e) {
console.error('Failed to save server:', e)
alert('저장에 실패했습니다.')
} finally {
saving.value = false
}
}
// Delete
const confirmDelete = (server) => {
deleteTarget.value = server
showDeleteModal.value = true
}
const deleteServer = async () => {
deleting.value = true
try {
await serverApi.delete(deleteTarget.value.id)
showDeleteModal.value = false
await loadServers()
} catch (e) {
console.error('Failed to delete server:', e)
alert('삭제에 실패했습니다.')
} finally {
deleting.value = false
}
}
// Test Connection
const testConnection = async (server) => {
testingId.value = server.id
try {
testResult.value = await serverApi.testConnection(server.id)
showTestResultModal.value = true
} catch (e) {
console.error('Failed to test connection:', e)
testResult.value = { success: false, error: '연결 테스트 실패: ' + e.message }
showTestResultModal.value = true
} finally {
testingId.value = null
}
}
// Log Path Modal
const openLogPathModal = async (server) => {
selectedServer.value = server
logPathForm.value = { path: '', filePattern: '', description: '' }
try {
logPaths.value = await logPathApi.getByServerId(server.id)
} catch (e) {
console.error('Failed to load log paths:', e)
logPaths.value = []
}
showLogPathModal.value = true
}
const addLogPath = async () => {
try {
await logPathApi.create({
serverId: selectedServer.value.id,
path: logPathForm.value.path,
filePattern: logPathForm.value.filePattern,
description: logPathForm.value.description,
active: true
})
logPaths.value = await logPathApi.getByServerId(selectedServer.value.id)
logPathForm.value = { path: '', filePattern: '', description: '' }
} catch (e) {
console.error('Failed to add log path:', e)
alert('경로 추가에 실패했습니다.')
}
}
const deleteLogPath = async (id) => {
if (!confirm('이 경로를 삭제하시겠습니까?')) return
try {
await logPathApi.delete(id)
logPaths.value = await logPathApi.getByServerId(selectedServer.value.id)
} catch (e) {
console.error('Failed to delete log path:', e)
alert('경로 삭제에 실패했습니다.')
}
}
// Utils
const formatDate = (dateStr) => {
return new Date(dateStr).toLocaleString('ko-KR')
}
onMounted(() => {
loadServers()
})
</script>
<style scoped>
.server-manage h2 {
margin-bottom: 1rem;
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.actions {
margin-bottom: 1rem;
.card-header-content h3 {
margin: 0;
}
.btn-primary {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
.action-buttons {
display: flex;
gap: 4px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.server-list {
background: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
.log-path-section h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: #333;
}
table {
.log-path-form {
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 20px;
}
.log-path-inputs {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.log-path-list table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
.log-path-list th,
.log-path-list td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
.log-path-list th {
background: #f8f9fa;
font-weight: 600;
font-size: 13px;
}
.empty {
.empty-text {
color: #6c757d;
text-align: center;
color: #999;
padding: 20px;
}
.warning-text {
color: #e74c3c;
font-size: 14px;
}
.test-result {
padding: 16px;
border-radius: 8px;
}
.test-result.success {
background: #d4edda;
border: 1px solid #c3e6cb;
}
.test-result.fail {
background: #f8d7da;
border: 1px solid #f5c6cb;
}
.test-result p {
margin: 0;
}
</style>

View File

@@ -1,79 +1,223 @@
<template>
<div class="settings">
<h2>설정</h2>
<p>애플리케이션 설정을 관리합니다.</p>
<Card>
<template #header>
<div class="card-header-content">
<h3>설정</h3>
</div>
</template>
<div class="settings-form">
<div class="form-group">
<label>내보내기 경로</label>
<input type="text" v-model="form.exportPath" placeholder="./exports">
<div v-if="loading" class="loading">로딩중...</div>
<form v-else @submit.prevent="saveSettings" class="settings-form">
<div class="setting-section">
<h4>일반 설정</h4>
<FormInput
v-model="settings['server.port']"
label="서버 포트"
type="number"
hint="애플리케이션이 실행될 포트 번호 (기본: 8080)"
/>
</div>
<div class="form-group">
<label>로그 보관 기간 ()</label>
<input type="number" v-model="form.retentionDays" min="1">
<div class="setting-section">
<h4>내보내기 설정</h4>
<FormInput
v-model="settings['export.path']"
label="내보내기 경로"
placeholder="예: C:\LogHunter\exports"
hint="리포트 파일이 저장될 기본 경로"
/>
</div>
<div class="form-group">
<label> 서버 포트</label>
<input type="number" v-model="form.port" min="1" max="65535">
<div class="setting-section">
<h4>데이터 관리</h4>
<FormInput
v-model="settings['retention.days']"
label="로그 보관 기간 (일)"
type="number"
hint="에러 로그 데이터 보관 기간 (0 = 무제한)"
/>
</div>
<div class="actions">
<button class="btn-primary">저장</button>
<div class="setting-section">
<h4>스캔 설정</h4>
<FormInput
v-model="settings['scan.timeout']"
label="스캔 타임아웃 (초)"
type="number"
hint="SFTP 연결 및 파일 다운로드 타임아웃"
/>
<FormInput
v-model="settings['scan.maxFileSize']"
label="최대 파일 크기 (MB)"
type="number"
hint="분석할 로그 파일의 최대 크기"
/>
</div>
<div class="form-actions">
<Button @click="loadSettings" variant="secondary">초기화</Button>
<Button type="submit" :loading="saving">저장</Button>
</div>
</form>
</Card>
<!-- 정보 -->
<Card class="app-info">
<template #header>
<h3>애플리케이션 정보</h3>
</template>
<div class="info-list">
<div class="info-item">
<span class="label">버전</span>
<span class="value">1.0.0</span>
</div>
<div class="info-item">
<span class="label">프레임워크</span>
<span class="value">Spring Boot 3.2 + Vue 3</span>
</div>
<div class="info-item">
<span class="label">데이터베이스</span>
<span class="value">SQLite (./data/loghunter.db)</span>
</div>
</div>
</Card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { Card, Button, FormInput } from '@/components'
import { settingApi } from '@/api'
const form = ref({
exportPath: './exports',
retentionDays: 30,
port: 8080
const loading = ref(false)
const saving = ref(false)
const settings = ref({})
// 기본값
const defaultSettings = {
'server.port': '8080',
'export.path': './exports',
'retention.days': '90',
'scan.timeout': '30',
'scan.maxFileSize': '100'
}
const loadSettings = async () => {
loading.value = true
try {
const data = await settingApi.getAllAsMap()
settings.value = { ...defaultSettings, ...data }
} catch (e) {
console.error('Failed to load settings:', e)
settings.value = { ...defaultSettings }
} finally {
loading.value = false
}
}
const saveSettings = async () => {
saving.value = true
try {
// 각 설정 저장
for (const [key, value] of Object.entries(settings.value)) {
await settingApi.save({ key, value: String(value) })
}
alert('설정이 저장되었습니다.')
} catch (e) {
console.error('Failed to save settings:', e)
alert('설정 저장에 실패했습니다.')
} finally {
saving.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>
<style scoped>
.settings h2 { margin-bottom: 1rem; }
.settings {
max-width: 800px;
}
.card-header-content h3 {
margin: 0;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.settings-form {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
max-width: 500px;
padding: 10px 0;
}
.form-group {
margin-bottom: 1rem;
.setting-section {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
.setting-section:last-of-type {
border-bottom: none;
}
.setting-section h4 {
margin: 0 0 20px 0;
font-size: 16px;
color: #2c3e50;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.app-info {
margin-top: 20px;
}
.app-info h3 {
margin: 0;
}
.info-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-item:last-child {
border-bottom: none;
}
.info-item .label {
color: #666;
}
.info-item .value {
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.actions {
margin-top: 1.5rem;
}
.btn-primary {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>

View File

@@ -19,7 +19,7 @@ export default defineConfig({
}
},
build: {
outDir: 'dist',
outDir: '../src/main/resources/static',
emptyOutDir: true
}
})

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

View File

View File

@@ -1,4 +1,4 @@
package com.osolit.loghunter;
package research.loghunter;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

View File

@@ -0,0 +1,48 @@
package research.loghunter.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import research.loghunter.dto.ErrorLogDto;
import research.loghunter.service.ErrorLogService;
import java.time.LocalDateTime;
@RestController
@RequestMapping("/api/error-logs")
@RequiredArgsConstructor
public class ErrorLogController {
private final ErrorLogService errorLogService;
@GetMapping
public ResponseEntity<Page<ErrorLogDto>> search(
@RequestParam(required = false) Long serverId,
@RequestParam(required = false) Long patternId,
@RequestParam(required = false) String severity,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return ResponseEntity.ok(errorLogService.search(
serverId, patternId, severity, startDate, endDate, keyword, page, size));
}
@GetMapping("/{id}")
public ResponseEntity<ErrorLogDto> findById(@PathVariable Long id) {
return ResponseEntity.ok(errorLogService.findById(id));
}
@GetMapping("/server/{serverId}")
public ResponseEntity<Page<ErrorLogDto>> findByServerId(
@PathVariable Long serverId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return ResponseEntity.ok(errorLogService.findByServerId(serverId, page, size));
}
}

View File

@@ -0,0 +1,68 @@
package research.loghunter.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import research.loghunter.service.ExportService;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
@RestController
@RequestMapping("/api/export")
@RequiredArgsConstructor
public class ExportController {
private final ExportService exportService;
@GetMapping("/html")
public ResponseEntity<byte[]> exportHtml(
@RequestParam(required = false) Long serverId,
@RequestParam(required = false) Long patternId,
@RequestParam(required = false) String severity,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String keyword
) {
ExportService.ExportRequest request = new ExportService.ExportRequest(
serverId, patternId, severity, startDate, endDate, keyword);
ExportService.ExportResult result = exportService.exportHtml(request);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + encodeFilename(result.filename()) + "\"")
.contentType(MediaType.TEXT_HTML)
.body(result.content());
}
@GetMapping("/txt")
public ResponseEntity<byte[]> exportTxt(
@RequestParam(required = false) Long serverId,
@RequestParam(required = false) Long patternId,
@RequestParam(required = false) String severity,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String keyword
) {
ExportService.ExportRequest request = new ExportService.ExportRequest(
serverId, patternId, severity, startDate, endDate, keyword);
ExportService.ExportResult result = exportService.exportTxt(request);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + encodeFilename(result.filename()) + "\"")
.contentType(MediaType.TEXT_PLAIN)
.body(result.content());
}
private String encodeFilename(String filename) {
return URLEncoder.encode(filename, StandardCharsets.UTF_8)
.replace("+", "%20");
}
}

View File

@@ -0,0 +1,55 @@
package research.loghunter.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import research.loghunter.dto.PatternDto;
import research.loghunter.service.PatternService;
import java.util.List;
@RestController
@RequestMapping("/api/patterns")
@RequiredArgsConstructor
public class PatternController {
private final PatternService patternService;
@GetMapping
public ResponseEntity<List<PatternDto>> findAll() {
return ResponseEntity.ok(patternService.findAll());
}
@GetMapping("/active")
public ResponseEntity<List<PatternDto>> findAllActive() {
return ResponseEntity.ok(patternService.findAllActive());
}
@GetMapping("/{id}")
public ResponseEntity<PatternDto> findById(@PathVariable Long id) {
return ResponseEntity.ok(patternService.findById(id));
}
@PostMapping
public ResponseEntity<PatternDto> create(@RequestBody PatternDto dto) {
return ResponseEntity.ok(patternService.create(dto));
}
@PutMapping("/{id}")
public ResponseEntity<PatternDto> update(@PathVariable Long id, @RequestBody PatternDto dto) {
return ResponseEntity.ok(patternService.update(id, dto));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
patternService.delete(id);
return ResponseEntity.ok().build();
}
@PostMapping("/test")
public ResponseEntity<PatternService.PatternTestResult> testPattern(
@RequestParam String regex,
@RequestParam String sampleText) {
return ResponseEntity.ok(patternService.testPattern(regex, sampleText));
}
}

View File

@@ -0,0 +1,136 @@
package research.loghunter.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import research.loghunter.entity.ScanHistory;
import research.loghunter.service.ScanService;
import research.loghunter.service.SftpService;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@RestController
@RequestMapping("/api/scan")
@RequiredArgsConstructor
public class ScanController {
private final ScanService scanService;
private final SftpService sftpService;
private final ExecutorService executor = Executors.newCachedThreadPool();
/**
* 연결 테스트
*/
@PostMapping("/test-connection/{serverId}")
public ResponseEntity<SftpService.ConnectionTestResult> testConnection(@PathVariable Long serverId) {
return ResponseEntity.ok(sftpService.testConnection(serverId));
}
/**
* 단일 서버 스캔 (SSE로 진행상황 전송)
*/
@GetMapping(value = "/start/{serverId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter startScan(@PathVariable Long serverId) {
SseEmitter emitter = new SseEmitter(300000L); // 5분 타임아웃
executor.execute(() -> {
try {
ScanService.ScanResult result = scanService.scanServer(serverId, progress -> {
try {
emitter.send(SseEmitter.event()
.name("progress")
.data(progress));
} catch (IOException e) {
emitter.completeWithError(e);
}
});
emitter.send(SseEmitter.event()
.name("complete")
.data(result));
emitter.complete();
} catch (Exception e) {
try {
emitter.send(SseEmitter.event()
.name("error")
.data(e.getMessage()));
} catch (IOException ignored) {}
emitter.completeWithError(e);
}
});
return emitter;
}
/**
* 모든 서버 스캔 (SSE로 진행상황 전송)
*/
@GetMapping(value = "/start-all", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter startAllScan() {
SseEmitter emitter = new SseEmitter(600000L); // 10분 타임아웃
executor.execute(() -> {
try {
List<ScanService.ScanResult> results = scanService.scanAllServers(progress -> {
try {
emitter.send(SseEmitter.event()
.name("progress")
.data(progress));
} catch (IOException e) {
emitter.completeWithError(e);
}
});
emitter.send(SseEmitter.event()
.name("complete")
.data(results));
emitter.complete();
} catch (Exception e) {
try {
emitter.send(SseEmitter.event()
.name("error")
.data(e.getMessage()));
} catch (IOException ignored) {}
emitter.completeWithError(e);
}
});
return emitter;
}
/**
* 단일 서버 스캔 (동기 방식)
*/
@PostMapping("/execute/{serverId}")
public ResponseEntity<ScanService.ScanResult> executeScan(@PathVariable Long serverId) {
ScanService.ScanResult result = scanService.scanServer(serverId, null);
return ResponseEntity.ok(result);
}
/**
* 현재 진행 상황 조회
*/
@GetMapping("/progress/{serverId}")
public ResponseEntity<ScanService.ScanProgress> getProgress(@PathVariable Long serverId) {
ScanService.ScanProgress progress = scanService.getProgress(serverId);
if (progress == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(progress);
}
/**
* 스캔 이력 조회
*/
@GetMapping("/history/{serverId}")
public ResponseEntity<List<ScanHistory>> getHistory(@PathVariable Long serverId) {
return ResponseEntity.ok(scanService.getHistory(serverId));
}
}

View File

@@ -0,0 +1,58 @@
package research.loghunter.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import research.loghunter.dto.ServerDto;
import research.loghunter.service.ServerService;
import research.loghunter.service.SftpService;
import java.util.List;
@RestController
@RequestMapping("/api/servers")
@RequiredArgsConstructor
public class ServerController {
private final ServerService serverService;
private final SftpService sftpService;
@GetMapping
public ResponseEntity<List<ServerDto>> findAll() {
return ResponseEntity.ok(serverService.findAll());
}
@GetMapping("/active")
public ResponseEntity<List<ServerDto>> findAllActive() {
return ResponseEntity.ok(serverService.findAllActive());
}
@GetMapping("/{id}")
public ResponseEntity<ServerDto> findById(@PathVariable Long id) {
return ResponseEntity.ok(serverService.findById(id));
}
@PostMapping
public ResponseEntity<ServerDto> create(@RequestBody ServerDto dto) {
return ResponseEntity.ok(serverService.create(dto));
}
@PutMapping("/{id}")
public ResponseEntity<ServerDto> update(@PathVariable Long id, @RequestBody ServerDto dto) {
return ResponseEntity.ok(serverService.update(id, dto));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
serverService.delete(id);
return ResponseEntity.ok().build();
}
/**
* 연결 테스트
*/
@PostMapping("/{id}/test-connection")
public ResponseEntity<SftpService.ConnectionTestResult> testConnection(@PathVariable Long id) {
return ResponseEntity.ok(sftpService.testConnection(id));
}
}

View File

@@ -0,0 +1,48 @@
package research.loghunter.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import research.loghunter.dto.ServerLogPathDto;
import research.loghunter.service.ServerLogPathService;
import java.util.List;
@RestController
@RequestMapping("/api/log-paths")
@RequiredArgsConstructor
public class ServerLogPathController {
private final ServerLogPathService logPathService;
@GetMapping("/server/{serverId}")
public ResponseEntity<List<ServerLogPathDto>> findByServerId(@PathVariable Long serverId) {
return ResponseEntity.ok(logPathService.findByServerId(serverId));
}
@GetMapping("/server/{serverId}/active")
public ResponseEntity<List<ServerLogPathDto>> findActiveByServerId(@PathVariable Long serverId) {
return ResponseEntity.ok(logPathService.findActiveByServerId(serverId));
}
@GetMapping("/{id}")
public ResponseEntity<ServerLogPathDto> findById(@PathVariable Long id) {
return ResponseEntity.ok(logPathService.findById(id));
}
@PostMapping
public ResponseEntity<ServerLogPathDto> create(@RequestBody ServerLogPathDto dto) {
return ResponseEntity.ok(logPathService.create(dto));
}
@PutMapping("/{id}")
public ResponseEntity<ServerLogPathDto> update(@PathVariable Long id, @RequestBody ServerLogPathDto dto) {
return ResponseEntity.ok(logPathService.update(id, dto));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
logPathService.delete(id);
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,51 @@
package research.loghunter.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import research.loghunter.dto.SettingDto;
import research.loghunter.service.SettingService;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/settings")
@RequiredArgsConstructor
public class SettingController {
private final SettingService settingService;
@GetMapping
public ResponseEntity<List<SettingDto>> findAll() {
return ResponseEntity.ok(settingService.findAll());
}
@GetMapping("/map")
public ResponseEntity<Map<String, String>> findAllAsMap() {
return ResponseEntity.ok(settingService.findAllAsMap());
}
@GetMapping("/{key}")
public ResponseEntity<String> getValue(@PathVariable String key) {
String value = settingService.getValue(key);
return value != null ? ResponseEntity.ok(value) : ResponseEntity.notFound().build();
}
@PostMapping
public ResponseEntity<SettingDto> save(@RequestBody SettingDto dto) {
return ResponseEntity.ok(settingService.save(dto));
}
@PutMapping
public ResponseEntity<Void> saveAll(@RequestBody Map<String, String> settings) {
settingService.saveAll(settings);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{key}")
public ResponseEntity<Void> delete(@PathVariable String key) {
settingService.delete(key);
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,24 @@
package research.loghunter.dto;
import lombok.*;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ErrorLogDto {
private Long id;
private Long serverId;
private String serverName;
private Long patternId;
private String patternName;
private String filePath;
private Integer lineNumber;
private String summary;
private String context;
private String severity;
private LocalDateTime occurredAt;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,20 @@
package research.loghunter.dto;
import lombok.*;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PatternDto {
private Long id;
private String name;
private String regex;
private String severity;
private Integer contextLines;
private String description;
private Boolean active;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,27 @@
package research.loghunter.dto;
import lombok.*;
import java.time.LocalDateTime;
import java.util.List;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ServerDto {
private Long id;
private String name;
private String host;
private Integer port;
private String username;
private String authType;
private String password; // 입력용 (저장 시 암호화)
private String keyFilePath;
private String passphrase; // 입력용 (저장 시 암호화)
private Boolean active;
private LocalDateTime lastScanAt;
private LocalDateTime lastErrorAt;
private LocalDateTime createdAt;
private List<ServerLogPathDto> logPaths;
}

View File

@@ -0,0 +1,17 @@
package research.loghunter.dto;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ServerLogPathDto {
private Long id;
private Long serverId;
private String path;
private String filePattern;
private String description;
private Boolean active;
}

View File

@@ -0,0 +1,14 @@
package research.loghunter.dto;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SettingDto {
private String key;
private String value;
private String description;
}

View File

@@ -0,0 +1,61 @@
package research.loghunter.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "error_logs")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ErrorLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "server_id", nullable = false)
private Server server;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pattern_id", nullable = false)
private Pattern pattern;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "scan_history_id")
private ScanHistory scanHistory;
@Column(nullable = false)
private String filePath; // 로그 파일 경로
private Integer lineNumber; // 에러 발생 라인 번호
@Column(nullable = false, length = 500)
private String summary; // 에러 요약 (첫 줄 또는 일부)
@Column(nullable = false, columnDefinition = "TEXT")
private String context; // 캡처된 전체 컨텍스트
@Column(nullable = false)
private String severity; // CRITICAL, ERROR, WARN
@Column(nullable = false)
private LocalDateTime occurredAt; // 에러 발생 시간 (로그 파일 내 시간 파싱)
@Column(nullable = false)
private LocalDateTime scannedAt; // 스캔/분석 시간
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt; // DB 저장 시간
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
if (scannedAt == null) scannedAt = LocalDateTime.now();
if (occurredAt == null) occurredAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,55 @@
package research.loghunter.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "patterns")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Pattern {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name; // 패턴명 (예: NullPointer, DB Connection)
@Column(nullable = false, length = 1000)
private String regex; // 정규식
@Column(nullable = false)
private String severity; // CRITICAL, ERROR, WARN
@Column(nullable = false)
private Integer contextLines; // 에러 전후 캡처할 라인 수
private String description; // 설명
@Column(nullable = false)
private Boolean active;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
if (active == null) active = true;
if (contextLines == null) contextLines = 5;
if (severity == null) severity = "ERROR";
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,46 @@
package research.loghunter.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "scan_history")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScanHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "server_id", nullable = false)
private Server server;
@Column(nullable = false)
private LocalDateTime startedAt; // 분석 시작 시간
private LocalDateTime finishedAt; // 분석 종료 시간
@Column(nullable = false)
private String status; // RUNNING, SUCCESS, FAILED
private Integer filesScanned; // 스캔한 파일 수
private Integer errorsFound; // 발견된 에러 수
@Column(length = 2000)
private String errorMessage; // 실패 시 에러 메시지
@PrePersist
protected void onCreate() {
if (startedAt == null) startedAt = LocalDateTime.now();
if (status == null) status = "RUNNING";
if (filesScanned == null) filesScanned = 0;
if (errorsFound == null) errorsFound = 0;
}
}

View File

@@ -0,0 +1,61 @@
package research.loghunter.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "scanned_files", uniqueConstraints = {
@UniqueConstraint(columnNames = {"server_id", "file_path", "file_size"})
})
public class ScannedFile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "server_id", nullable = false)
private Long serverId;
@Column(name = "log_path_id", nullable = false)
private Long logPathId;
@Column(name = "file_path", nullable = false, length = 1000)
private String filePath;
@Column(name = "file_name", nullable = false, length = 500)
private String fileName;
@Column(name = "file_size", nullable = false)
private Long fileSize;
@Column(name = "scanned_at", nullable = false)
private LocalDateTime scannedAt;
@Column(name = "error_count")
private Integer errorCount = 0;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getServerId() { return serverId; }
public void setServerId(Long serverId) { this.serverId = serverId; }
public Long getLogPathId() { return logPathId; }
public void setLogPathId(Long logPathId) { this.logPathId = logPathId; }
public String getFilePath() { return filePath; }
public void setFilePath(String filePath) { this.filePath = filePath; }
public String getFileName() { return fileName; }
public void setFileName(String fileName) { this.fileName = fileName; }
public Long getFileSize() { return fileSize; }
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
public LocalDateTime getScannedAt() { return scannedAt; }
public void setScannedAt(LocalDateTime scannedAt) { this.scannedAt = scannedAt; }
public Integer getErrorCount() { return errorCount; }
public void setErrorCount(Integer errorCount) { this.errorCount = errorCount; }
}

View File

@@ -0,0 +1,80 @@
package research.loghunter.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "servers")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Server {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name; // 서버 별칭
@Column(nullable = false)
private String host;
@Column(nullable = false)
private Integer port; // 기본 22
@Column(nullable = false)
private String username;
@Column(nullable = false)
private String authType; // PASSWORD or KEY_FILE
private String encryptedPassword; // 암호화된 비밀번호
private String keyFilePath; // 키 파일 경로
private String encryptedPassphrase; // 암호화된 passphrase
@Column(nullable = false)
private Boolean active; // 활성화 여부
// 서버 시간과 로컬 시간의 차이 (분 단위)
// 예: 서버가 UTC이고 로컬이 KST(+9)면 -540
// 로그시간 + offset = 로컬시간
private Integer timeOffsetMinutes;
// 마지막으로 시간 동기화 체크한 일시
private LocalDateTime lastTimeSyncAt;
private LocalDateTime lastScanAt; // 마지막 분석 일시
private LocalDateTime lastErrorAt; // 마지막 에러 발생 일시
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "server", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<ServerLogPath> logPaths = new ArrayList<>();
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
if (active == null) active = true;
if (port == null) port = 22;
if (timeOffsetMinutes == null) timeOffsetMinutes = 0;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,43 @@
package research.loghunter.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "server_log_paths")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ServerLogPath {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "server_id", nullable = false)
private Server server;
@Column(nullable = false)
private String path; // 예: /var/log/tomcat/
@Column(nullable = false)
private String filePattern; // 예: catalina.*.log, *.log
private String description; // 설명 (예: Tomcat 로그)
@Column(nullable = false)
private Boolean active;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
if (active == null) active = true;
}
}

View File

@@ -0,0 +1,32 @@
package research.loghunter.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "settings")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Setting {
@Id
@Column(name = "setting_key", nullable = false, unique = true)
private String key;
@Column(nullable = false, length = 2000)
private String value;
private String description;
private LocalDateTime updatedAt;
@PrePersist
@PreUpdate
protected void onSave() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,35 @@
package research.loghunter.repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import research.loghunter.entity.ErrorLog;
import java.time.LocalDateTime;
import java.util.List;
public interface ErrorLogRepository extends JpaRepository<ErrorLog, Long> {
List<ErrorLog> findByServerIdOrderByOccurredAtDesc(Long serverId);
Page<ErrorLog> findByServerIdOrderByOccurredAtDesc(Long serverId, Pageable pageable);
@Query("SELECT e FROM ErrorLog e WHERE " +
"(:serverId IS NULL OR e.server.id = :serverId) AND " +
"(:patternId IS NULL OR e.pattern.id = :patternId) AND " +
"(:severity IS NULL OR e.severity = :severity) AND " +
"(:startDate IS NULL OR e.occurredAt >= :startDate) AND " +
"(:endDate IS NULL OR e.occurredAt <= :endDate) AND " +
"(:keyword IS NULL OR LOWER(e.summary) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(e.context) LIKE LOWER(CONCAT('%', :keyword, '%'))) " +
"ORDER BY e.occurredAt DESC")
Page<ErrorLog> searchErrors(
@Param("serverId") Long serverId,
@Param("patternId") Long patternId,
@Param("severity") String severity,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate,
@Param("keyword") String keyword,
Pageable pageable);
}

View File

@@ -0,0 +1,10 @@
package research.loghunter.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import research.loghunter.entity.Pattern;
import java.util.List;
public interface PatternRepository extends JpaRepository<Pattern, Long> {
List<Pattern> findByActiveTrue();
List<Pattern> findByActiveTrueOrderBySeverityAscNameAsc();
}

View File

@@ -0,0 +1,11 @@
package research.loghunter.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import research.loghunter.entity.ScanHistory;
import java.util.List;
import java.util.Optional;
public interface ScanHistoryRepository extends JpaRepository<ScanHistory, Long> {
List<ScanHistory> findByServerIdOrderByStartedAtDesc(Long serverId);
Optional<ScanHistory> findTopByServerIdOrderByStartedAtDesc(Long serverId);
}

View File

@@ -0,0 +1,31 @@
package research.loghunter.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import research.loghunter.entity.ScannedFile;
import java.util.List;
import java.util.Optional;
public interface ScannedFileRepository extends JpaRepository<ScannedFile, Long> {
// 파일 경로와 크기로 이미 스캔된 파일인지 확인
Optional<ScannedFile> findByServerIdAndFilePathAndFileSize(Long serverId, String filePath, Long fileSize);
// 서버별 스캔된 파일 목록
List<ScannedFile> findByServerId(Long serverId);
// 경로별 스캔된 파일 목록
List<ScannedFile> findByLogPathId(Long logPathId);
// 서버와 파일경로로 스캔 이력 조회 (크기 무관 - 변경 체크용)
@Query("SELECT sf FROM ScannedFile sf WHERE sf.serverId = :serverId AND sf.filePath = :filePath ORDER BY sf.scannedAt DESC")
List<ScannedFile> findByServerIdAndFilePath(@Param("serverId") Long serverId, @Param("filePath") String filePath);
// 서버별 스캔 파일 수
long countByServerId(Long serverId);
// 경로별 스캔 파일 수
long countByLogPathId(Long logPathId);
}

View File

@@ -0,0 +1,10 @@
package research.loghunter.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import research.loghunter.entity.ServerLogPath;
import java.util.List;
public interface ServerLogPathRepository extends JpaRepository<ServerLogPath, Long> {
List<ServerLogPath> findByServerId(Long serverId);
List<ServerLogPath> findByServerIdAndActiveTrue(Long serverId);
}

View File

@@ -0,0 +1,10 @@
package research.loghunter.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import research.loghunter.entity.Server;
import java.util.List;
public interface ServerRepository extends JpaRepository<Server, Long> {
List<Server> findByActiveTrue();
List<Server> findByActiveTrueOrderByNameAsc();
}

View File

@@ -0,0 +1,7 @@
package research.loghunter.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import research.loghunter.entity.Setting;
public interface SettingRepository extends JpaRepository<Setting, String> {
}

View File

@@ -0,0 +1,67 @@
package research.loghunter.service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import research.loghunter.dto.ErrorLogDto;
import research.loghunter.entity.ErrorLog;
import research.loghunter.repository.ErrorLogRepository;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ErrorLogService {
private final ErrorLogRepository errorLogRepository;
public Page<ErrorLogDto> search(
Long serverId,
Long patternId,
String severity,
LocalDateTime startDate,
LocalDateTime endDate,
String keyword,
int page,
int size
) {
Pageable pageable = PageRequest.of(page, size);
Page<ErrorLog> errorLogs = errorLogRepository.searchErrors(
serverId, patternId, severity, startDate, endDate, keyword, pageable);
return errorLogs.map(this::toDto);
}
public Page<ErrorLogDto> findByServerId(Long serverId, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
Page<ErrorLog> errorLogs = errorLogRepository.findByServerIdOrderByOccurredAtDesc(serverId, pageable);
return errorLogs.map(this::toDto);
}
public ErrorLogDto findById(Long id) {
return errorLogRepository.findById(id)
.map(this::toDto)
.orElseThrow(() -> new RuntimeException("ErrorLog not found: " + id));
}
private ErrorLogDto toDto(ErrorLog errorLog) {
return ErrorLogDto.builder()
.id(errorLog.getId())
.serverId(errorLog.getServer().getId())
.serverName(errorLog.getServer().getName())
.patternId(errorLog.getPattern().getId())
.patternName(errorLog.getPattern().getName())
.filePath(errorLog.getFilePath())
.lineNumber(errorLog.getLineNumber())
.summary(errorLog.getSummary())
.context(errorLog.getContext())
.severity(errorLog.getSeverity())
.occurredAt(errorLog.getOccurredAt())
.createdAt(errorLog.getCreatedAt())
.build();
}
}

View File

@@ -0,0 +1,285 @@
package research.loghunter.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import research.loghunter.dto.ErrorLogDto;
import research.loghunter.entity.Server;
import research.loghunter.repository.ServerRepository;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class ExportService {
private final ErrorLogService errorLogService;
private final ServerRepository serverRepository;
private final SettingService settingService;
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter FILE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
/**
* HTML 리포트 생성
*/
public ExportResult exportHtml(ExportRequest request) {
List<ErrorLogDto> errors = getErrors(request);
String serverName = getServerName(request.serverId());
String title = generateTitle(serverName, request);
StringBuilder html = new StringBuilder();
html.append("<!DOCTYPE html>\n");
html.append("<html lang=\"ko\">\n");
html.append("<head>\n");
html.append(" <meta charset=\"UTF-8\">\n");
html.append(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
html.append(" <title>").append(escapeHtml(title)).append("</title>\n");
html.append(" <style>\n");
html.append(getHtmlStyles());
html.append(" </style>\n");
html.append("</head>\n");
html.append("<body>\n");
// Header
html.append(" <div class=\"header\">\n");
html.append(" <h1>LogHunter 에러 리포트</h1>\n");
html.append(" <p class=\"subtitle\">").append(escapeHtml(title)).append("</p>\n");
html.append(" <p class=\"generated\">생성일시: ").append(LocalDateTime.now().format(DATE_FORMAT)).append("</p>\n");
html.append(" </div>\n");
// Summary
html.append(" <div class=\"summary\">\n");
html.append(" <h2>요약</h2>\n");
html.append(" <ul>\n");
html.append(" <li>총 에러 수: <strong>").append(errors.size()).append("</strong>건</li>\n");
html.append(" <li>CRITICAL: <strong>").append(countBySeverity(errors, "CRITICAL")).append("</strong>건</li>\n");
html.append(" <li>ERROR: <strong>").append(countBySeverity(errors, "ERROR")).append("</strong>건</li>\n");
html.append(" <li>WARN: <strong>").append(countBySeverity(errors, "WARN")).append("</strong>건</li>\n");
html.append(" </ul>\n");
html.append(" </div>\n");
// Error List
html.append(" <div class=\"error-list\">\n");
html.append(" <h2>에러 목록</h2>\n");
if (errors.isEmpty()) {
html.append(" <p class=\"no-data\">검출된 에러가 없습니다.</p>\n");
} else {
for (int i = 0; i < errors.size(); i++) {
ErrorLogDto error = errors.get(i);
html.append(renderErrorHtml(error, i + 1));
}
}
html.append(" </div>\n");
// Footer
html.append(" <div class=\"footer\">\n");
html.append(" <p>Generated by LogHunter</p>\n");
html.append(" </div>\n");
html.append("</body>\n");
html.append("</html>");
String filename = "loghunter_report_" + LocalDateTime.now().format(FILE_DATE_FORMAT) + ".html";
return new ExportResult(filename, html.toString().getBytes(), "text/html");
}
/**
* TXT 리포트 생성
*/
public ExportResult exportTxt(ExportRequest request) {
List<ErrorLogDto> errors = getErrors(request);
String serverName = getServerName(request.serverId());
String title = generateTitle(serverName, request);
StringBuilder txt = new StringBuilder();
txt.append("=".repeat(80)).append("\n");
txt.append("LogHunter 에러 리포트\n");
txt.append("=".repeat(80)).append("\n\n");
txt.append("제목: ").append(title).append("\n");
txt.append("생성일시: ").append(LocalDateTime.now().format(DATE_FORMAT)).append("\n\n");
// Summary
txt.append("-".repeat(40)).append("\n");
txt.append("요약\n");
txt.append("-".repeat(40)).append("\n");
txt.append("총 에러 수: ").append(errors.size()).append("\n");
txt.append(" - CRITICAL: ").append(countBySeverity(errors, "CRITICAL")).append("\n");
txt.append(" - ERROR: ").append(countBySeverity(errors, "ERROR")).append("\n");
txt.append(" - WARN: ").append(countBySeverity(errors, "WARN")).append("\n\n");
// Error List
txt.append("-".repeat(40)).append("\n");
txt.append("에러 목록\n");
txt.append("-".repeat(40)).append("\n\n");
if (errors.isEmpty()) {
txt.append("검출된 에러가 없습니다.\n");
} else {
for (int i = 0; i < errors.size(); i++) {
ErrorLogDto error = errors.get(i);
txt.append(renderErrorTxt(error, i + 1));
}
}
txt.append("\n").append("=".repeat(80)).append("\n");
txt.append("Generated by LogHunter\n");
txt.append("=".repeat(80)).append("\n");
String filename = "loghunter_report_" + LocalDateTime.now().format(FILE_DATE_FORMAT) + ".txt";
return new ExportResult(filename, txt.toString().getBytes(), "text/plain");
}
private List<ErrorLogDto> getErrors(ExportRequest request) {
return errorLogService.search(
request.serverId(),
request.patternId(),
request.severity(),
request.startDate(),
request.endDate(),
request.keyword(),
0,
10000 // 최대 10000건
).getContent();
}
private String getServerName(Long serverId) {
if (serverId == null) return "전체 서버";
return serverRepository.findById(serverId)
.map(Server::getName)
.orElse("알 수 없음");
}
private String generateTitle(String serverName, ExportRequest request) {
StringBuilder title = new StringBuilder();
title.append(serverName);
if (request.startDate() != null || request.endDate() != null) {
title.append(" (");
if (request.startDate() != null) {
title.append(request.startDate().toLocalDate());
}
title.append(" ~ ");
if (request.endDate() != null) {
title.append(request.endDate().toLocalDate());
}
title.append(")");
}
return title.toString();
}
private long countBySeverity(List<ErrorLogDto> errors, String severity) {
return errors.stream()
.filter(e -> severity.equals(e.getSeverity()))
.count();
}
private String renderErrorHtml(ErrorLogDto error, int index) {
StringBuilder sb = new StringBuilder();
sb.append(" <div class=\"error-item\">\n");
sb.append(" <div class=\"error-header\">\n");
sb.append(" <span class=\"index\">#").append(index).append("</span>\n");
sb.append(" <span class=\"severity ").append(error.getSeverity().toLowerCase()).append("\">")
.append(error.getSeverity()).append("</span>\n");
sb.append(" <span class=\"time\">").append(formatDateTime(error.getOccurredAt())).append("</span>\n");
sb.append(" </div>\n");
sb.append(" <div class=\"error-meta\">\n");
sb.append(" <span><strong>서버:</strong> ").append(escapeHtml(error.getServerName())).append("</span>\n");
sb.append(" <span><strong>패턴:</strong> ").append(escapeHtml(error.getPatternName())).append("</span>\n");
sb.append(" <span><strong>파일:</strong> ").append(escapeHtml(error.getFilePath())).append("</span>\n");
sb.append(" <span><strong>라인:</strong> ").append(error.getLineNumber()).append("</span>\n");
sb.append(" </div>\n");
sb.append(" <div class=\"error-summary\">\n");
sb.append(" <strong>요약:</strong> ").append(escapeHtml(error.getSummary())).append("\n");
sb.append(" </div>\n");
sb.append(" <div class=\"error-context\">\n");
sb.append(" <strong>컨텍스트:</strong>\n");
sb.append(" <pre>").append(escapeHtml(error.getContext())).append("</pre>\n");
sb.append(" </div>\n");
sb.append(" </div>\n");
return sb.toString();
}
private String renderErrorTxt(ErrorLogDto error, int index) {
StringBuilder sb = new StringBuilder();
sb.append("[#").append(index).append("] ").append(error.getSeverity()).append("\n");
sb.append("발생시간: ").append(formatDateTime(error.getOccurredAt())).append("\n");
sb.append("서버: ").append(error.getServerName()).append("\n");
sb.append("패턴: ").append(error.getPatternName()).append("\n");
sb.append("파일: ").append(error.getFilePath()).append("\n");
sb.append("라인: ").append(error.getLineNumber()).append("\n");
sb.append("요약: ").append(error.getSummary()).append("\n");
sb.append("컨텍스트:\n");
sb.append(error.getContext()).append("\n");
sb.append("-".repeat(60)).append("\n\n");
return sb.toString();
}
private String formatDateTime(LocalDateTime dt) {
return dt != null ? dt.format(DATE_FORMAT) : "-";
}
private String escapeHtml(String str) {
if (str == null) return "";
return str.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
private String getHtmlStyles() {
return """
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Malgun Gothic', sans-serif; background: #f5f5f5; padding: 20px; }
.header { background: #2c3e50; color: white; padding: 30px; border-radius: 8px; margin-bottom: 20px; }
.header h1 { margin-bottom: 10px; }
.header .subtitle { font-size: 18px; opacity: 0.9; }
.header .generated { font-size: 14px; opacity: 0.7; margin-top: 10px; }
.summary { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.summary h2 { margin-bottom: 15px; color: #2c3e50; }
.summary ul { list-style: none; }
.summary li { padding: 8px 0; border-bottom: 1px solid #eee; }
.summary li:last-child { border-bottom: none; }
.error-list { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.error-list h2 { margin-bottom: 20px; color: #2c3e50; }
.error-item { border: 1px solid #ddd; border-radius: 8px; margin-bottom: 15px; overflow: hidden; }
.error-header { background: #f8f9fa; padding: 12px 15px; display: flex; align-items: center; gap: 15px; }
.error-header .index { font-weight: bold; color: #666; }
.error-header .severity { padding: 4px 10px; border-radius: 4px; font-size: 12px; font-weight: bold; color: white; }
.error-header .severity.critical { background: #c0392b; }
.error-header .severity.error { background: #e74c3c; }
.error-header .severity.warn { background: #f39c12; }
.error-header .time { margin-left: auto; color: #666; font-size: 14px; }
.error-meta { padding: 12px 15px; background: #fafafa; display: flex; flex-wrap: wrap; gap: 20px; font-size: 14px; }
.error-summary { padding: 12px 15px; border-bottom: 1px solid #eee; }
.error-context { padding: 12px 15px; }
.error-context pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 4px; overflow-x: auto; font-size: 12px; line-height: 1.5; margin-top: 10px; white-space: pre-wrap; }
.no-data { text-align: center; color: #666; padding: 40px; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 14px; }
""";
}
// DTOs
public record ExportRequest(
Long serverId,
Long patternId,
String severity,
LocalDateTime startDate,
LocalDateTime endDate,
String keyword
) {}
public record ExportResult(
String filename,
byte[] content,
String contentType
) {}
}

View File

@@ -0,0 +1,92 @@
package research.loghunter.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import research.loghunter.entity.Pattern;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
@Service
@RequiredArgsConstructor
@Slf4j
public class LogParserService {
/**
* 로그 내용에서 패턴 매칭
*/
public List<MatchResult> parseAndMatch(String content, List<Pattern> patterns, String filePath) {
List<MatchResult> results = new ArrayList<>();
String[] lines = content.split("\n");
for (Pattern pattern : patterns) {
if (!pattern.getActive()) continue;
try {
java.util.regex.Pattern compiledPattern = java.util.regex.Pattern.compile(pattern.getRegex());
for (int i = 0; i < lines.length; i++) {
Matcher matcher = compiledPattern.matcher(lines[i]);
if (matcher.find()) {
// 컨텍스트 추출
String context = extractContext(lines, i, pattern.getContextLines());
String summary = createSummary(lines[i]);
results.add(new MatchResult(
pattern,
filePath,
i + 1, // 1-based line number
summary,
context,
pattern.getSeverity()
));
}
}
} catch (Exception e) {
log.warn("Failed to match pattern {}: {}", pattern.getName(), e.getMessage());
}
}
return results;
}
/**
* 컨텍스트 추출 (에러 전후 라인)
*/
private String extractContext(String[] lines, int matchIndex, int contextLines) {
int start = Math.max(0, matchIndex - contextLines);
int end = Math.min(lines.length - 1, matchIndex + contextLines);
StringBuilder context = new StringBuilder();
for (int i = start; i <= end; i++) {
if (i == matchIndex) {
context.append(">>> "); // 에러 라인 표시
}
context.append(String.format("[%d] %s\n", i + 1, lines[i]));
}
return context.toString();
}
/**
* 요약 생성 (첫 줄, 최대 200자)
*/
private String createSummary(String line) {
if (line == null) return "";
String trimmed = line.trim();
if (trimmed.length() <= 200) return trimmed;
return trimmed.substring(0, 200) + "...";
}
// DTO
public record MatchResult(
Pattern pattern,
String filePath,
int lineNumber,
String summary,
String context,
String severity
) {}
}

View File

@@ -0,0 +1,126 @@
package research.loghunter.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import research.loghunter.dto.PatternDto;
import research.loghunter.entity.Pattern;
import research.loghunter.repository.PatternRepository;
import java.util.List;
import java.util.regex.PatternSyntaxException;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PatternService {
private final PatternRepository patternRepository;
public List<PatternDto> findAll() {
return patternRepository.findAll().stream()
.map(this::toDto)
.collect(Collectors.toList());
}
public List<PatternDto> findAllActive() {
return patternRepository.findByActiveTrueOrderBySeverityAscNameAsc().stream()
.map(this::toDto)
.collect(Collectors.toList());
}
public PatternDto findById(Long id) {
return patternRepository.findById(id)
.map(this::toDto)
.orElseThrow(() -> new RuntimeException("Pattern not found: " + id));
}
@Transactional
public PatternDto create(PatternDto dto) {
validateRegex(dto.getRegex());
Pattern pattern = Pattern.builder()
.name(dto.getName())
.regex(dto.getRegex())
.severity(dto.getSeverity() != null ? dto.getSeverity() : "ERROR")
.contextLines(dto.getContextLines() != null ? dto.getContextLines() : 5)
.description(dto.getDescription())
.active(dto.getActive() != null ? dto.getActive() : true)
.build();
pattern = patternRepository.save(pattern);
return toDto(pattern);
}
@Transactional
public PatternDto update(Long id, PatternDto dto) {
validateRegex(dto.getRegex());
Pattern pattern = patternRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Pattern not found: " + id));
pattern.setName(dto.getName());
pattern.setRegex(dto.getRegex());
pattern.setSeverity(dto.getSeverity());
pattern.setContextLines(dto.getContextLines());
pattern.setDescription(dto.getDescription());
pattern.setActive(dto.getActive());
return toDto(patternRepository.save(pattern));
}
@Transactional
public void delete(Long id) {
patternRepository.deleteById(id);
}
/**
* 정규식 패턴 테스트
*/
public PatternTestResult testPattern(String regex, String sampleText) {
try {
java.util.regex.Pattern compiledPattern = java.util.regex.Pattern.compile(regex);
java.util.regex.Matcher matcher = compiledPattern.matcher(sampleText);
boolean found = matcher.find();
String matchedText = found ? matcher.group() : null;
int matchStart = found ? matcher.start() : -1;
int matchEnd = found ? matcher.end() : -1;
return new PatternTestResult(true, found, matchedText, matchStart, matchEnd, null);
} catch (PatternSyntaxException e) {
return new PatternTestResult(false, false, null, -1, -1, e.getMessage());
}
}
private void validateRegex(String regex) {
try {
java.util.regex.Pattern.compile(regex);
} catch (PatternSyntaxException e) {
throw new RuntimeException("Invalid regex pattern: " + e.getMessage());
}
}
private PatternDto toDto(Pattern pattern) {
return PatternDto.builder()
.id(pattern.getId())
.name(pattern.getName())
.regex(pattern.getRegex())
.severity(pattern.getSeverity())
.contextLines(pattern.getContextLines())
.description(pattern.getDescription())
.active(pattern.getActive())
.createdAt(pattern.getCreatedAt())
.build();
}
public record PatternTestResult(
boolean validRegex,
boolean matched,
String matchedText,
int matchStart,
int matchEnd,
String errorMessage
) {}
}

View File

@@ -0,0 +1,453 @@
package research.loghunter.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import research.loghunter.entity.*;
import research.loghunter.repository.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
@RequiredArgsConstructor
@Slf4j
public class ScanService {
private final ServerRepository serverRepository;
private final ServerLogPathRepository logPathRepository;
private final PatternRepository patternRepository;
private final ScanHistoryRepository scanHistoryRepository;
private final ErrorLogRepository errorLogRepository;
private final ScannedFileRepository scannedFileRepository;
private final SftpService sftpService;
private final LogParserService logParserService;
// 진행 상황 저장 (serverId -> ScanProgress)
private final ConcurrentHashMap<Long, ScanProgress> progressMap = new ConcurrentHashMap<>();
// 로그 시간 파싱용 패턴들
private static final List<DateTimeFormatter> DATE_FORMATTERS = List.of(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss,SSS"), // 2026-01-06 09:57:01,114
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"), // 2026-01-06 09:57:01.114
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), // 2026-01-06 09:57:01
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"), // ISO format
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"),
DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm:ss"), // 06-Jan-2026 09:57:01
DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm:ss"), // Jan 06, 2026 09:57:01
DateTimeFormatter.ofPattern("MMM dd HH:mm:ss") // Jan 06 09:57:01 (년도 없음)
);
// 로그 라인에서 날짜 추출용 정규식
private static final Pattern DATE_PATTERN = Pattern.compile(
"(\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}[,.]?\\d{0,3})" +
"|(\\d{2}-[A-Za-z]{3}-\\d{4} \\d{2}:\\d{2}:\\d{2})" +
"|([A-Za-z]{3} \\d{2},? \\d{4} \\d{2}:\\d{2}:\\d{2})" +
"|([A-Za-z]{3} \\d{2} \\d{2}:\\d{2}:\\d{2})"
);
/**
* 단일 서버 스캔
*/
@Transactional
public ScanResult scanServer(Long serverId, Consumer<ScanProgress> progressCallback) {
Server server = serverRepository.findById(serverId)
.orElseThrow(() -> new RuntimeException("Server not found: " + serverId));
return executeScan(server, progressCallback);
}
/**
* 모든 활성 서버 스캔
*/
@Transactional
public List<ScanResult> scanAllServers(Consumer<ScanProgress> progressCallback) {
List<Server> servers = serverRepository.findByActiveTrue();
return servers.stream()
.map(server -> executeScan(server, progressCallback))
.toList();
}
/**
* 스캔 실행
*/
private ScanResult executeScan(Server server, Consumer<ScanProgress> progressCallback) {
ScanProgress progress = new ScanProgress(server.getId(), server.getName());
progressMap.put(server.getId(), progress);
LocalDateTime scanStartTime = LocalDateTime.now();
// 스캔 이력 생성
ScanHistory history = ScanHistory.builder()
.server(server)
.startedAt(scanStartTime)
.status("RUNNING")
.build();
history = scanHistoryRepository.save(history);
try {
// 1. 서버 시간 체크 (시차 확인)
progress.setMessage("서버 시간 확인 중...");
notifyProgress(progressCallback, progress);
int timeOffsetMinutes = checkAndUpdateServerTimeOffset(server);
log.info("Server {} time offset: {} minutes", server.getId(), timeOffsetMinutes);
// 2. 활성 로그 경로 조회
List<ServerLogPath> logPaths = logPathRepository.findByServerIdAndActiveTrue(server.getId());
if (logPaths.isEmpty()) {
throw new RuntimeException("등록된 로그 경로가 없습니다.");
}
// 3. 활성 패턴 조회
List<research.loghunter.entity.Pattern> patterns = patternRepository.findByActiveTrue();
if (patterns.isEmpty()) {
throw new RuntimeException("등록된 패턴이 없습니다.");
}
progress.setTotalPaths(logPaths.size());
notifyProgress(progressCallback, progress);
AtomicInteger totalFilesScanned = new AtomicInteger(0);
AtomicInteger totalFilesSkipped = new AtomicInteger(0);
AtomicInteger totalErrorsFound = new AtomicInteger(0);
for (ServerLogPath logPath : logPaths) {
progress.setCurrentPath(logPath.getPath());
progress.setMessage("파일 목록 조회 중...");
notifyProgress(progressCallback, progress);
try {
// 4. 해당 경로의 모든 파일 조회 (패턴 매칭)
List<SftpService.RemoteFile> allFiles = sftpService.listAllFiles(server, logPath);
// 5. 스캔 대상 파일 필터링 (중복 제외)
List<SftpService.RemoteFile> filesToScan = new ArrayList<>();
for (SftpService.RemoteFile file : allFiles) {
if (shouldScanFile(server.getId(), logPath.getId(), file)) {
filesToScan.add(file);
} else {
totalFilesSkipped.incrementAndGet();
}
}
progress.setTotalFiles(progress.getTotalFiles() + filesToScan.size());
progress.setMessage(String.format("전체 %d개 중 %d개 스캔 대상 (기존 %d개 스킵)",
allFiles.size(), filesToScan.size(), allFiles.size() - filesToScan.size()));
notifyProgress(progressCallback, progress);
for (SftpService.RemoteFile file : filesToScan) {
progress.setCurrentFile(file.name());
notifyProgress(progressCallback, progress);
try {
// 파일 내용 다운로드
String content = sftpService.downloadFileContent(server, file.path());
// 패턴 매칭
List<LogParserService.MatchResult> matches =
logParserService.parseAndMatch(content, patterns, file.path());
// 에러 저장
for (LogParserService.MatchResult match : matches) {
// 로그에서 시간 파싱 (시차 보정 적용)
LocalDateTime occurredAt = parseLogTime(
match.context(),
file.modifiedAt(),
timeOffsetMinutes
);
ErrorLog errorLog = ErrorLog.builder()
.server(server)
.pattern(match.pattern())
.scanHistory(history)
.filePath(match.filePath())
.lineNumber(match.lineNumber())
.summary(match.summary())
.context(match.context())
.severity(match.severity())
.occurredAt(occurredAt) // 로그 내 시간 (보정됨)
.scannedAt(scanStartTime) // 스캔 시작 시간
.build();
errorLogRepository.save(errorLog);
totalErrorsFound.incrementAndGet();
}
// 스캔 완료된 파일 기록
saveScannedFile(server.getId(), logPath.getId(), file, matches.size());
totalFilesScanned.incrementAndGet();
progress.setScannedFiles(totalFilesScanned.get());
progress.setErrorsFound(totalErrorsFound.get());
notifyProgress(progressCallback, progress);
} catch (Exception e) {
log.warn("Failed to process file {}: {}", file.path(), e.getMessage());
}
}
} catch (Exception e) {
log.warn("Failed to process log path {}: {}", logPath.getPath(), e.getMessage());
}
progress.setScannedPaths(progress.getScannedPaths() + 1);
notifyProgress(progressCallback, progress);
}
// 스캔 완료
history.setStatus("SUCCESS");
history.setFinishedAt(LocalDateTime.now());
history.setFilesScanned(totalFilesScanned.get());
history.setErrorsFound(totalErrorsFound.get());
scanHistoryRepository.save(history);
// 서버 마지막 스캔 시간 업데이트
server.setLastScanAt(LocalDateTime.now());
if (totalErrorsFound.get() > 0) {
server.setLastErrorAt(LocalDateTime.now());
}
serverRepository.save(server);
progress.setStatus("SUCCESS");
progress.setMessage(String.format("완료! (스캔: %d, 스킵: %d, 에러: %d)",
totalFilesScanned.get(), totalFilesSkipped.get(), totalErrorsFound.get()));
notifyProgress(progressCallback, progress);
return new ScanResult(server.getId(), server.getName(), true,
totalFilesScanned.get(), totalErrorsFound.get(), null);
} catch (Exception e) {
log.error("Scan failed for server {}: {}", server.getId(), e.getMessage());
history.setStatus("FAILED");
history.setFinishedAt(LocalDateTime.now());
history.setErrorMessage(e.getMessage());
scanHistoryRepository.save(history);
progress.setStatus("FAILED");
progress.setMessage("스캔 실패: " + e.getMessage());
notifyProgress(progressCallback, progress);
return new ScanResult(server.getId(), server.getName(), false, 0, 0, e.getMessage());
} finally {
progressMap.remove(server.getId());
}
}
/**
* 서버 시간 오프셋 확인 및 업데이트
*/
private int checkAndUpdateServerTimeOffset(Server server) {
try {
SftpService.TimeCheckResult result = sftpService.checkServerTime(server);
if (result.success()) {
server.setTimeOffsetMinutes(result.offsetMinutes());
server.setLastTimeSyncAt(LocalDateTime.now());
serverRepository.save(server);
log.info("Server {} time sync - Server: {}, Local: {}, Offset: {} min",
server.getName(), result.serverTime(), result.localTime(), result.offsetMinutes());
return result.offsetMinutes();
}
} catch (Exception e) {
log.warn("Failed to check server time for {}: {}", server.getId(), e.getMessage());
}
// 실패 시 기존 값 사용 (없으면 0)
return server.getTimeOffsetMinutes() != null ? server.getTimeOffsetMinutes() : 0;
}
/**
* 파일을 스캔해야 하는지 확인 (중복 체크)
* - 파일경로 + 크기가 동일하면 스킵
* - 파일경로는 같은데 크기가 다르면 재스캔
*/
private boolean shouldScanFile(Long serverId, Long logPathId, SftpService.RemoteFile file) {
Optional<ScannedFile> existing = scannedFileRepository.findByServerIdAndFilePathAndFileSize(
serverId, file.path(), file.size());
if (existing.isPresent()) {
log.debug("Skipping already scanned file: {} (size: {})", file.path(), file.size());
return false;
}
// 파일경로는 같은데 크기가 다른 경우 확인 (변경된 파일)
List<ScannedFile> previousVersions = scannedFileRepository.findByServerIdAndFilePath(serverId, file.path());
if (!previousVersions.isEmpty()) {
log.info("File size changed, will rescan: {} (old: {}, new: {})",
file.path(), previousVersions.get(0).getFileSize(), file.size());
}
return true;
}
/**
* 스캔 완료된 파일 정보 저장
*/
private void saveScannedFile(Long serverId, Long logPathId, SftpService.RemoteFile file, int errorCount) {
ScannedFile scannedFile = new ScannedFile();
scannedFile.setServerId(serverId);
scannedFile.setLogPathId(logPathId);
scannedFile.setFilePath(file.path());
scannedFile.setFileName(file.name());
scannedFile.setFileSize(file.size());
scannedFile.setScannedAt(LocalDateTime.now());
scannedFile.setErrorCount(errorCount);
scannedFileRepository.save(scannedFile);
log.debug("Saved scanned file record: {} (size: {}, errors: {})", file.path(), file.size(), errorCount);
}
/**
* 로그 내용에서 시간 파싱 (시차 보정 적용)
*/
private LocalDateTime parseLogTime(String logContent, LocalDateTime fallback, int offsetMinutes) {
if (logContent == null || logContent.isEmpty()) {
return applyOffset(fallback, offsetMinutes);
}
// 첫 줄에서 시간 추출 시도
String[] lines = logContent.split("\n");
for (String line : lines) {
LocalDateTime parsed = extractDateTime(line);
if (parsed != null) {
return applyOffset(parsed, offsetMinutes);
}
}
return applyOffset(fallback, offsetMinutes);
}
/**
* 문자열에서 날짜/시간 추출
*/
private LocalDateTime extractDateTime(String text) {
Matcher matcher = DATE_PATTERN.matcher(text);
if (!matcher.find()) {
return null;
}
String dateStr = matcher.group();
for (DateTimeFormatter formatter : DATE_FORMATTERS) {
try {
return LocalDateTime.parse(dateStr, formatter);
} catch (DateTimeParseException e) {
// 다음 포맷 시도
}
}
return null;
}
/**
* 시간에 오프셋 적용
* offset > 0: 로컬이 서버보다 앞섬 (로그시간 + offset = 로컬시간)
*/
private LocalDateTime applyOffset(LocalDateTime time, int offsetMinutes) {
if (time == null || offsetMinutes == 0) {
return time;
}
return time.plusMinutes(offsetMinutes);
}
/**
* 현재 진행 상황 조회
*/
public ScanProgress getProgress(Long serverId) {
return progressMap.get(serverId);
}
/**
* 스캔 이력 조회
*/
public List<ScanHistory> getHistory(Long serverId) {
return scanHistoryRepository.findByServerIdOrderByStartedAtDesc(serverId);
}
/**
* 스캔된 파일 목록 조회 (서버별)
*/
public List<ScannedFile> getScannedFiles(Long serverId) {
return scannedFileRepository.findByServerId(serverId);
}
/**
* 스캔된 파일 기록 초기화 (재스캔 허용)
*/
@Transactional
public void resetScannedFiles(Long serverId) {
List<ScannedFile> files = scannedFileRepository.findByServerId(serverId);
scannedFileRepository.deleteAll(files);
log.info("Reset {} scanned file records for server {}", files.size(), serverId);
}
private void notifyProgress(Consumer<ScanProgress> callback, ScanProgress progress) {
if (callback != null) {
callback.accept(progress);
}
}
// DTOs
public static class ScanProgress {
private Long serverId;
private String serverName;
private String status = "RUNNING";
private String message;
private String currentPath;
private String currentFile;
private int totalPaths;
private int scannedPaths;
private int totalFiles;
private int scannedFiles;
private int errorsFound;
public ScanProgress(Long serverId, String serverName) {
this.serverId = serverId;
this.serverName = serverName;
}
// Getters and Setters
public Long getServerId() { return serverId; }
public String getServerName() { return serverName; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getCurrentPath() { return currentPath; }
public void setCurrentPath(String currentPath) { this.currentPath = currentPath; }
public String getCurrentFile() { return currentFile; }
public void setCurrentFile(String currentFile) { this.currentFile = currentFile; }
public int getTotalPaths() { return totalPaths; }
public void setTotalPaths(int totalPaths) { this.totalPaths = totalPaths; }
public int getScannedPaths() { return scannedPaths; }
public void setScannedPaths(int scannedPaths) { this.scannedPaths = scannedPaths; }
public int getTotalFiles() { return totalFiles; }
public void setTotalFiles(int totalFiles) { this.totalFiles = totalFiles; }
public int getScannedFiles() { return scannedFiles; }
public void setScannedFiles(int scannedFiles) { this.scannedFiles = scannedFiles; }
public int getErrorsFound() { return errorsFound; }
public void setErrorsFound(int errorsFound) { this.errorsFound = errorsFound; }
}
public record ScanResult(
Long serverId,
String serverName,
boolean success,
int filesScanned,
int errorsFound,
String error
) {}
}

View File

@@ -0,0 +1,86 @@
package research.loghunter.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import research.loghunter.dto.ServerLogPathDto;
import research.loghunter.entity.Server;
import research.loghunter.entity.ServerLogPath;
import research.loghunter.repository.ServerLogPathRepository;
import research.loghunter.repository.ServerRepository;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ServerLogPathService {
private final ServerLogPathRepository logPathRepository;
private final ServerRepository serverRepository;
public List<ServerLogPathDto> findByServerId(Long serverId) {
return logPathRepository.findByServerId(serverId).stream()
.map(this::toDto)
.collect(Collectors.toList());
}
public List<ServerLogPathDto> findActiveByServerId(Long serverId) {
return logPathRepository.findByServerIdAndActiveTrue(serverId).stream()
.map(this::toDto)
.collect(Collectors.toList());
}
public ServerLogPathDto findById(Long id) {
return logPathRepository.findById(id)
.map(this::toDto)
.orElseThrow(() -> new RuntimeException("LogPath not found: " + id));
}
@Transactional
public ServerLogPathDto create(ServerLogPathDto dto) {
Server server = serverRepository.findById(dto.getServerId())
.orElseThrow(() -> new RuntimeException("Server not found: " + dto.getServerId()));
ServerLogPath logPath = ServerLogPath.builder()
.server(server)
.path(dto.getPath())
.filePattern(dto.getFilePattern())
.description(dto.getDescription())
.active(dto.getActive() != null ? dto.getActive() : true)
.build();
logPath = logPathRepository.save(logPath);
return toDto(logPath);
}
@Transactional
public ServerLogPathDto update(Long id, ServerLogPathDto dto) {
ServerLogPath logPath = logPathRepository.findById(id)
.orElseThrow(() -> new RuntimeException("LogPath not found: " + id));
logPath.setPath(dto.getPath());
logPath.setFilePattern(dto.getFilePattern());
logPath.setDescription(dto.getDescription());
logPath.setActive(dto.getActive());
return toDto(logPathRepository.save(logPath));
}
@Transactional
public void delete(Long id) {
logPathRepository.deleteById(id);
}
private ServerLogPathDto toDto(ServerLogPath logPath) {
return ServerLogPathDto.builder()
.id(logPath.getId())
.serverId(logPath.getServer().getId())
.path(logPath.getPath())
.filePattern(logPath.getFilePattern())
.description(logPath.getDescription())
.active(logPath.getActive())
.build();
}
}

View File

@@ -0,0 +1,162 @@
package research.loghunter.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import research.loghunter.dto.ServerDto;
import research.loghunter.dto.ServerLogPathDto;
import research.loghunter.entity.Server;
import research.loghunter.entity.ServerLogPath;
import research.loghunter.repository.ServerRepository;
import research.loghunter.util.CryptoUtil;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ServerService {
private final ServerRepository serverRepository;
private final CryptoUtil cryptoUtil;
public List<ServerDto> findAll() {
return serverRepository.findAll().stream()
.map(this::toDto)
.collect(Collectors.toList());
}
public List<ServerDto> findAllActive() {
return serverRepository.findByActiveTrueOrderByNameAsc().stream()
.map(this::toDto)
.collect(Collectors.toList());
}
public ServerDto findById(Long id) {
return serverRepository.findById(id)
.map(this::toDto)
.orElseThrow(() -> new RuntimeException("Server not found: " + id));
}
@Transactional
public ServerDto create(ServerDto dto) {
Server server = toEntity(dto);
server = serverRepository.save(server);
return toDto(server);
}
@Transactional
public ServerDto update(Long id, ServerDto dto) {
Server server = serverRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Server not found: " + id));
server.setName(dto.getName());
server.setHost(dto.getHost());
server.setPort(dto.getPort());
server.setUsername(dto.getUsername());
server.setAuthType(dto.getAuthType());
server.setActive(dto.getActive());
if ("PASSWORD".equals(dto.getAuthType())) {
if (dto.getPassword() != null && !dto.getPassword().isEmpty()) {
server.setEncryptedPassword(cryptoUtil.encrypt(dto.getPassword()));
}
server.setKeyFilePath(null);
server.setEncryptedPassphrase(null);
} else if ("KEY_FILE".equals(dto.getAuthType())) {
server.setKeyFilePath(dto.getKeyFilePath());
if (dto.getPassphrase() != null && !dto.getPassphrase().isEmpty()) {
server.setEncryptedPassphrase(cryptoUtil.encrypt(dto.getPassphrase()));
}
server.setEncryptedPassword(null);
}
return toDto(serverRepository.save(server));
}
@Transactional
public void delete(Long id) {
serverRepository.deleteById(id);
}
@Transactional
public void updateLastScanAt(Long id) {
serverRepository.findById(id).ifPresent(server -> {
server.setLastScanAt(java.time.LocalDateTime.now());
serverRepository.save(server);
});
}
@Transactional
public void updateLastErrorAt(Long id) {
serverRepository.findById(id).ifPresent(server -> {
server.setLastErrorAt(java.time.LocalDateTime.now());
serverRepository.save(server);
});
}
public String getDecryptedPassword(Long id) {
Server server = serverRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Server not found: " + id));
return cryptoUtil.decrypt(server.getEncryptedPassword());
}
public String getDecryptedPassphrase(Long id) {
Server server = serverRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Server not found: " + id));
return cryptoUtil.decrypt(server.getEncryptedPassphrase());
}
private ServerDto toDto(Server server) {
return ServerDto.builder()
.id(server.getId())
.name(server.getName())
.host(server.getHost())
.port(server.getPort())
.username(server.getUsername())
.authType(server.getAuthType())
.keyFilePath(server.getKeyFilePath())
.active(server.getActive())
.lastScanAt(server.getLastScanAt())
.lastErrorAt(server.getLastErrorAt())
.createdAt(server.getCreatedAt())
.logPaths(server.getLogPaths().stream()
.map(this::toLogPathDto)
.collect(Collectors.toList()))
.build();
}
private ServerLogPathDto toLogPathDto(ServerLogPath logPath) {
return ServerLogPathDto.builder()
.id(logPath.getId())
.serverId(logPath.getServer().getId())
.path(logPath.getPath())
.filePattern(logPath.getFilePattern())
.description(logPath.getDescription())
.active(logPath.getActive())
.build();
}
private Server toEntity(ServerDto dto) {
Server server = Server.builder()
.name(dto.getName())
.host(dto.getHost())
.port(dto.getPort() != null ? dto.getPort() : 22)
.username(dto.getUsername())
.authType(dto.getAuthType())
.active(dto.getActive() != null ? dto.getActive() : true)
.build();
if ("PASSWORD".equals(dto.getAuthType()) && dto.getPassword() != null) {
server.setEncryptedPassword(cryptoUtil.encrypt(dto.getPassword()));
} else if ("KEY_FILE".equals(dto.getAuthType())) {
server.setKeyFilePath(dto.getKeyFilePath());
if (dto.getPassphrase() != null) {
server.setEncryptedPassphrase(cryptoUtil.encrypt(dto.getPassphrase()));
}
}
return server;
}
}

View File

@@ -0,0 +1,116 @@
package research.loghunter.service;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import research.loghunter.dto.SettingDto;
import research.loghunter.entity.Setting;
import research.loghunter.repository.SettingRepository;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SettingService {
private final SettingRepository settingRepository;
// 기본 설정 키
public static final String KEY_EXPORT_PATH = "export.path";
public static final String KEY_RETENTION_DAYS = "retention.days";
public static final String KEY_SERVER_PORT = "server.port";
@PostConstruct
@Transactional
public void initDefaultSettings() {
createIfNotExists(KEY_EXPORT_PATH, "./exports", "HTML/TXT 내보내기 경로");
createIfNotExists(KEY_RETENTION_DAYS, "30", "로그 보관 기간 (일)");
createIfNotExists(KEY_SERVER_PORT, "8080", "웹 서버 포트");
}
public List<SettingDto> findAll() {
return settingRepository.findAll().stream()
.map(this::toDto)
.collect(Collectors.toList());
}
public Map<String, String> findAllAsMap() {
return settingRepository.findAll().stream()
.collect(Collectors.toMap(Setting::getKey, Setting::getValue));
}
public String getValue(String key) {
return settingRepository.findById(key)
.map(Setting::getValue)
.orElse(null);
}
public String getValue(String key, String defaultValue) {
return settingRepository.findById(key)
.map(Setting::getValue)
.orElse(defaultValue);
}
public Integer getIntValue(String key, Integer defaultValue) {
String value = getValue(key);
if (value == null) return defaultValue;
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
@Transactional
public SettingDto save(SettingDto dto) {
Setting setting = settingRepository.findById(dto.getKey())
.orElse(new Setting());
setting.setKey(dto.getKey());
setting.setValue(dto.getValue());
if (dto.getDescription() != null) {
setting.setDescription(dto.getDescription());
}
return toDto(settingRepository.save(setting));
}
@Transactional
public void saveAll(Map<String, String> settings) {
settings.forEach((key, value) -> {
Setting setting = settingRepository.findById(key)
.orElse(new Setting());
setting.setKey(key);
setting.setValue(value);
settingRepository.save(setting);
});
}
@Transactional
public void delete(String key) {
settingRepository.deleteById(key);
}
private void createIfNotExists(String key, String defaultValue, String description) {
if (!settingRepository.existsById(key)) {
Setting setting = Setting.builder()
.key(key)
.value(defaultValue)
.description(description)
.build();
settingRepository.save(setting);
}
}
private SettingDto toDto(Setting setting) {
return SettingDto.builder()
.key(setting.getKey())
.value(setting.getValue())
.description(setting.getDescription())
.build();
}
}

View File

@@ -0,0 +1,403 @@
package research.loghunter.service;
import com.jcraft.jsch.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import research.loghunter.entity.Server;
import research.loghunter.entity.ServerLogPath;
import research.loghunter.repository.ServerRepository;
import research.loghunter.util.CryptoUtil;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.regex.Pattern;
@Service
@RequiredArgsConstructor
@Slf4j
public class SftpService {
private final ServerRepository serverRepository;
private final CryptoUtil cryptoUtil;
private static final int CONNECT_TIMEOUT = 10000; // 10초
private static final int CHANNEL_TIMEOUT = 30000; // 30초
/**
* SFTP 연결 테스트
*/
public ConnectionTestResult testConnection(Long serverId) {
Server server = serverRepository.findById(serverId)
.orElseThrow(() -> new RuntimeException("Server not found: " + serverId));
Session session = null;
ChannelSftp channel = null;
try {
session = createSession(server);
log.info("Connecting to {}:{} as user '{}' with auth type '{}'",
server.getHost(), server.getPort(), server.getUsername(), server.getAuthType());
session.connect(CONNECT_TIMEOUT);
channel = (ChannelSftp) session.openChannel("sftp");
channel.connect(CHANNEL_TIMEOUT);
// 홈 디렉토리 확인
String home = channel.pwd();
String detail = String.format("연결 성공!\n- 호스트: %s:%d\n- 사용자: %s\n- 인증방식: %s\n- 홈 디렉토리: %s",
server.getHost(), server.getPort(), server.getUsername(),
"PASSWORD".equals(server.getAuthType()) ? "비밀번호" : "키 파일",
home);
return new ConnectionTestResult(true, detail, null);
} catch (JSchException e) {
log.error("SFTP connection test failed for server {}: {}", serverId, e.getMessage(), e);
String errorDetail = analyzeJSchException(e, server);
return new ConnectionTestResult(false, null, errorDetail);
} catch (SftpException e) {
log.error("SFTP operation failed for server {}: {}", serverId, e.getMessage());
return new ConnectionTestResult(false, null, "SFTP 오류: " + e.getMessage());
} finally {
disconnect(channel, session);
}
}
/**
* 서버 시간 조회 및 로컬 시간과의 차이 계산 (분 단위)
* 반환값: 로그시간 + offset = 로컬시간
*/
public TimeCheckResult checkServerTime(Long serverId) {
Server server = serverRepository.findById(serverId)
.orElseThrow(() -> new RuntimeException("Server not found: " + serverId));
return checkServerTime(server);
}
public TimeCheckResult checkServerTime(Server server) {
Session session = null;
ChannelExec channel = null;
try {
session = createSession(server);
session.connect(CONNECT_TIMEOUT);
// 서버 시간 조회 (epoch seconds)
channel = (ChannelExec) session.openChannel("exec");
channel.setCommand("date +%s");
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
channel.setOutputStream(outputStream);
channel.connect(CHANNEL_TIMEOUT);
// 명령 실행 대기
while (!channel.isClosed()) {
Thread.sleep(100);
}
String serverTimeStr = outputStream.toString().trim();
long serverEpoch = Long.parseLong(serverTimeStr);
long localEpoch = Instant.now().getEpochSecond();
// 분 단위 차이 계산 (로컬 - 서버)
int offsetMinutes = (int) ((localEpoch - serverEpoch) / 60);
LocalDateTime serverTime = LocalDateTime.ofInstant(
Instant.ofEpochSecond(serverEpoch), ZoneId.systemDefault());
LocalDateTime localTime = LocalDateTime.now();
log.info("Server {} time check - Server: {}, Local: {}, Offset: {} minutes",
server.getId(), serverTime, localTime, offsetMinutes);
return new TimeCheckResult(true, serverTime, localTime, offsetMinutes, null);
} catch (Exception e) {
log.error("Failed to check server time for {}: {}", server.getId(), e.getMessage());
return new TimeCheckResult(false, null, LocalDateTime.now(), 0, e.getMessage());
} finally {
if (channel != null && channel.isConnected()) {
channel.disconnect();
}
if (session != null && session.isConnected()) {
session.disconnect();
}
}
}
/**
* JSchException 분석하여 상세 에러 메시지 생성
*/
private String analyzeJSchException(JSchException e, Server server) {
String msg = e.getMessage();
StringBuilder detail = new StringBuilder();
detail.append(String.format("대상 서버: %s:%d\n", server.getHost(), server.getPort()));
detail.append(String.format("사용자: %s\n", server.getUsername()));
detail.append(String.format("인증방식: %s\n\n",
"PASSWORD".equals(server.getAuthType()) ? "비밀번호" : "키 파일"));
if (msg.contains("Auth fail") || msg.contains("Auth cancel")) {
detail.append("❌ 인증 실패\n\n");
detail.append("✔ IP/포트 연결은 성공했습니다.\n\n");
detail.append("확인 사항:\n");
if ("PASSWORD".equals(server.getAuthType())) {
detail.append("- 사용자명이 정확한가요?\n");
detail.append("- 비밀번호가 정확한가요?\n");
detail.append("- 서버에서 비밀번호 인증이 허용되어 있나요?");
} else {
detail.append("- 키 파일 경로가 정확한가요? (").append(server.getKeyFilePath()).append(")\n");
detail.append("- 키 파일이 존재하나요?\n");
detail.append("- Passphrase가 필요한 경우 입력했나요?\n");
detail.append("- 서버에 공개키가 등록되어 있나요?");
}
} else if (msg.contains("Connection refused")) {
detail.append("❌ 연결 거부\n\n");
detail.append("확인 사항:\n");
detail.append("- 포트 번호가 정확한가요? (현재: ").append(server.getPort()).append(")\n");
detail.append("- 서버에서 SSH 서비스가 실행 중인가요?\n");
detail.append("- 방화벽에서 해당 포트가 열려 있나요?");
} else if (msg.contains("UnknownHost") || msg.contains("No route to host")) {
detail.append("❌ 호스트 연결 불가\n\n");
detail.append("확인 사항:\n");
detail.append("- IP 주소가 정확한가요? (현재: ").append(server.getHost()).append(")\n");
detail.append("- 서버가 실행 중인가요?\n");
detail.append("- 네트워크 연결이 가능한가요?");
} else if (msg.contains("timeout") || msg.contains("timed out")) {
detail.append("❌ 연결 시간 초과\n\n");
detail.append("확인 사항:\n");
detail.append("- IP 주소가 정확한가요?\n");
detail.append("- 방화벽에서 차단되어 있지 않나요?\n");
detail.append("- 서버가 응답하는지 확인해주세요.");
} else if (msg.contains("invalid privatekey")) {
detail.append("❌ 키 파일 오류\n\n");
detail.append("확인 사항:\n");
detail.append("- 키 파일 형식이 올바른가요? (OpenSSH 형식 필요)\n");
detail.append("- 키 파일이 손상되지 않았나요?");
} else {
detail.append("❌ 연결 실패\n\n");
detail.append("에러 메시지: ").append(msg);
}
return detail.toString();
}
/**
* 지정된 경로의 모든 파일 목록 조회 (패턴 매칭 포함)
*/
public List<RemoteFile> listAllFiles(Server server, ServerLogPath logPath) {
List<RemoteFile> files = new ArrayList<>();
Session session = null;
ChannelSftp channel = null;
try {
session = createSession(server);
session.connect(CONNECT_TIMEOUT);
channel = (ChannelSftp) session.openChannel("sftp");
channel.connect(CHANNEL_TIMEOUT);
String path = logPath.getPath();
String filePattern = logPath.getFilePattern();
Pattern pattern = convertGlobToRegex(filePattern);
@SuppressWarnings("unchecked")
Vector<ChannelSftp.LsEntry> entries = channel.ls(path);
for (ChannelSftp.LsEntry entry : entries) {
if (entry.getAttrs().isDir()) continue;
String fileName = entry.getFilename();
if (!pattern.matcher(fileName).matches()) continue;
long mtime = entry.getAttrs().getMTime() * 1000L;
LocalDateTime fileTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(mtime), ZoneId.systemDefault());
files.add(new RemoteFile(
path + (path.endsWith("/") ? "" : "/") + fileName,
fileName,
entry.getAttrs().getSize(),
fileTime
));
}
// 최신 파일 순 정렬
files.sort((a, b) -> b.modifiedAt().compareTo(a.modifiedAt()));
log.info("Found {} files matching pattern '{}' in path '{}' on server {}",
files.size(), filePattern, path, server.getId());
} catch (Exception e) {
log.error("Failed to list files from server {}: {}", server.getId(), e.getMessage());
throw new RuntimeException("파일 목록 조회 실패: " + e.getMessage(), e);
} finally {
disconnect(channel, session);
}
return files;
}
/**
* 로그 파일 목록 조회 (since 이후 수정된 파일만)
*/
public List<RemoteFile> listLogFiles(Server server, ServerLogPath logPath, LocalDateTime since) {
List<RemoteFile> allFiles = listAllFiles(server, logPath);
if (since == null) {
return allFiles;
}
return allFiles.stream()
.filter(f -> f.modifiedAt().isAfter(since))
.toList();
}
/**
* 파일 다운로드 (내용 반환)
*/
public String downloadFileContent(Server server, String remotePath) {
Session session = null;
ChannelSftp channel = null;
try {
session = createSession(server);
session.connect(CONNECT_TIMEOUT);
channel = (ChannelSftp) session.openChannel("sftp");
channel.connect(CHANNEL_TIMEOUT);
try (InputStream is = channel.get(remotePath);
BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
StringBuilder content = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
content.append(line).append("\n");
}
return content.toString();
}
} catch (Exception e) {
log.error("Failed to download file {} from server {}: {}",
remotePath, server.getId(), e.getMessage());
throw new RuntimeException("파일 다운로드 실패: " + e.getMessage(), e);
} finally {
disconnect(channel, session);
}
}
/**
* 파일 다운로드 (로컬 파일로)
*/
public Path downloadFileToLocal(Server server, String remotePath, Path localDir) {
Session session = null;
ChannelSftp channel = null;
try {
session = createSession(server);
session.connect(CONNECT_TIMEOUT);
channel = (ChannelSftp) session.openChannel("sftp");
channel.connect(CHANNEL_TIMEOUT);
String fileName = remotePath.substring(remotePath.lastIndexOf('/') + 1);
Path localPath = localDir.resolve(fileName);
Files.createDirectories(localDir);
try (OutputStream os = Files.newOutputStream(localPath)) {
channel.get(remotePath, os);
}
return localPath;
} catch (Exception e) {
log.error("Failed to download file {} from server {}: {}",
remotePath, server.getId(), e.getMessage());
throw new RuntimeException("파일 다운로드 실패: " + e.getMessage(), e);
} finally {
disconnect(channel, session);
}
}
/**
* 세션 생성
*/
private Session createSession(Server server) throws JSchException {
JSch jsch = new JSch();
if ("KEY_FILE".equals(server.getAuthType())) {
String passphrase = cryptoUtil.decrypt(server.getEncryptedPassphrase());
if (passphrase != null && !passphrase.isEmpty()) {
jsch.addIdentity(server.getKeyFilePath(), passphrase);
} else {
jsch.addIdentity(server.getKeyFilePath());
}
}
Session session = jsch.getSession(server.getUsername(), server.getHost(), server.getPort());
if ("PASSWORD".equals(server.getAuthType())) {
String password = cryptoUtil.decrypt(server.getEncryptedPassword());
session.setPassword(password);
}
// StrictHostKeyChecking 비활성화 (실제 운영에서는 known_hosts 사용 권장)
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
return session;
}
/**
* 연결 해제
*/
private void disconnect(ChannelSftp channel, Session session) {
if (channel != null && channel.isConnected()) {
channel.disconnect();
}
if (session != null && session.isConnected()) {
session.disconnect();
}
}
/**
* Glob 패턴을 정규식으로 변환
*/
private Pattern convertGlobToRegex(String glob) {
StringBuilder regex = new StringBuilder("^");
for (char c : glob.toCharArray()) {
switch (c) {
case '*': regex.append(".*"); break;
case '?': regex.append("."); break;
case '.': regex.append("\\."); break;
default: regex.append(c);
}
}
regex.append("$");
return Pattern.compile(regex.toString());
}
// DTOs
public record ConnectionTestResult(boolean success, String message, String error) {}
public record TimeCheckResult(
boolean success,
LocalDateTime serverTime,
LocalDateTime localTime,
int offsetMinutes,
String error
) {}
public record RemoteFile(String path, String name, long size, LocalDateTime modifiedAt) {}
}

View File

@@ -0,0 +1,86 @@
package research.loghunter.util;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
@Component
public class CryptoUtil {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
@Value("${app.crypto.key}")
private String secretKey;
public String encrypt(String plainText) {
if (plainText == null || plainText.isEmpty()) {
return null;
}
try {
byte[] keyBytes = getKeyBytes();
SecretKeySpec key = new SecretKeySpec(keyBytes, ALGORITHM);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, key, parameterSpec);
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
// IV + 암호문 합치기
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new RuntimeException("Encryption failed", e);
}
}
public String decrypt(String encryptedText) {
if (encryptedText == null || encryptedText.isEmpty()) {
return null;
}
try {
byte[] keyBytes = getKeyBytes();
SecretKeySpec key = new SecretKeySpec(keyBytes, ALGORITHM);
byte[] combined = Base64.getDecoder().decode(encryptedText);
// IV와 암호문 분리
byte[] iv = Arrays.copyOfRange(combined, 0, GCM_IV_LENGTH);
byte[] encrypted = Arrays.copyOfRange(combined, GCM_IV_LENGTH, combined.length);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, key, parameterSpec);
byte[] decrypted = cipher.doFinal(encrypted);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("Decryption failed", e);
}
}
private byte[] getKeyBytes() {
byte[] keyBytes = new byte[32]; // AES-256
byte[] sourceBytes = secretKey.getBytes(StandardCharsets.UTF_8);
System.arraycopy(sourceBytes, 0, keyBytes, 0, Math.min(sourceBytes.length, keyBytes.length));
return keyBytes;
}
}

View File

@@ -0,0 +1 @@
spring.application.name=log-hunter

View File

@@ -0,0 +1 @@
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};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.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}

View File

@@ -0,0 +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};

View File

@@ -0,0 +1 @@
.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

View File

@@ -0,0 +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};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.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

View File

@@ -0,0 +1 @@
.card-header-content[data-v-6f59a208]{display:flex;justify-content:space-between;align-items:center}.card-header-content h3[data-v-6f59a208]{margin:0}.action-buttons[data-v-6f59a208]{display:flex;gap:4px}.form-group[data-v-6f59a208]{margin-bottom:16px}.form-group label[data-v-6f59a208]{display:flex;align-items:center;gap:8px;cursor:pointer}.log-path-section h4[data-v-6f59a208]{margin:0 0 12px;font-size:14px;color:#333}.log-path-form[data-v-6f59a208]{padding:16px;background:#f8f9fa;border-radius:8px;margin-bottom:20px}.log-path-inputs[data-v-6f59a208]{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:12px}.log-path-list table[data-v-6f59a208]{width:100%;border-collapse:collapse}.log-path-list th[data-v-6f59a208],.log-path-list td[data-v-6f59a208]{padding:10px 12px;text-align:left;border-bottom:1px solid #eee}.log-path-list th[data-v-6f59a208]{background:#f8f9fa;font-weight:600;font-size:13px}.empty-text[data-v-6f59a208]{color:#6c757d;text-align:center;padding:20px}.warning-text[data-v-6f59a208]{color:#e74c3c;font-size:14px}.test-result[data-v-6f59a208]{padding:16px;border-radius:8px}.test-result.success[data-v-6f59a208]{background:#d4edda;border:1px solid #c3e6cb}.test-result.fail[data-v-6f59a208]{background:#f8d7da;border:1px solid #f5c6cb}.test-result p[data-v-6f59a208]{margin:0}

View File

@@ -0,0 +1 @@
.settings[data-v-fdca948e]{max-width:800px}.card-header-content h3[data-v-fdca948e]{margin:0}.loading[data-v-fdca948e]{text-align:center;padding:40px;color:#666}.settings-form[data-v-fdca948e]{padding:10px 0}.setting-section[data-v-fdca948e]{margin-bottom:30px;padding-bottom:20px;border-bottom:1px solid #eee}.setting-section[data-v-fdca948e]:last-of-type{border-bottom:none}.setting-section h4[data-v-fdca948e]{margin:0 0 20px;font-size:16px;color:#2c3e50}.form-actions[data-v-fdca948e]{display:flex;justify-content:flex-end;gap:12px;margin-top:20px;padding-top:20px;border-top:1px solid #eee}.app-info[data-v-fdca948e]{margin-top:20px}.app-info h3[data-v-fdca948e]{margin:0}.info-list[data-v-fdca948e]{display:flex;flex-direction:column;gap:12px}.info-item[data-v-fdca948e]{display:flex;justify-content:space-between;padding:12px 0;border-bottom:1px solid #f0f0f0}.info-item[data-v-fdca948e]:last-child{border-bottom:none}.info-item .label[data-v-fdca948e]{color:#666}.info-item .value[data-v-fdca948e]{font-weight:500}

View File

@@ -0,0 +1 @@
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

View File

@@ -0,0 +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}

View 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 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}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LogHunter</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
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">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,13 @@
package research.loghunter;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class LogHunterApplicationTests {
@Test
void contextLoads() {
}
}