update
This commit is contained in:
BIN
.gradle/8.5/checksums/checksums.lock
Normal file
BIN
.gradle/8.5/checksums/checksums.lock
Normal file
Binary file not shown.
BIN
.gradle/8.5/dependencies-accessors/dependencies-accessors.lock
Normal file
BIN
.gradle/8.5/dependencies-accessors/dependencies-accessors.lock
Normal file
Binary file not shown.
0
.gradle/8.5/dependencies-accessors/gc.properties
Normal file
0
.gradle/8.5/dependencies-accessors/gc.properties
Normal file
BIN
.gradle/8.5/executionHistory/executionHistory.lock
Normal file
BIN
.gradle/8.5/executionHistory/executionHistory.lock
Normal file
Binary file not shown.
BIN
.gradle/8.5/fileChanges/last-build.bin
Normal file
BIN
.gradle/8.5/fileChanges/last-build.bin
Normal file
Binary file not shown.
BIN
.gradle/8.5/fileHashes/fileHashes.lock
Normal file
BIN
.gradle/8.5/fileHashes/fileHashes.lock
Normal file
Binary file not shown.
0
.gradle/8.5/gc.properties
Normal file
0
.gradle/8.5/gc.properties
Normal file
BIN
.gradle/buildOutputCleanup/buildOutputCleanup.lock
Normal file
BIN
.gradle/buildOutputCleanup/buildOutputCleanup.lock
Normal file
Binary file not shown.
2
.gradle/buildOutputCleanup/cache.properties
Normal file
2
.gradle/buildOutputCleanup/cache.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
#Tue Jan 06 21:43:32 KST 2026
|
||||
gradle.version=8.5
|
||||
0
.gradle/vcs-1/gc.properties
Normal file
0
.gradle/vcs-1/gc.properties
Normal file
25
HELP.md
Normal file
25
HELP.md
Normal 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)
|
||||
|
||||
92
README.md
92
README.md
@@ -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
256
TEST_GUIDE.md
Normal 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
25
backend/.gitignore
vendored
@@ -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
|
||||
@@ -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
BIN
data/loghunter.db
Normal file
Binary file not shown.
429
frontend/package-lock.json
generated
429
frontend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
56
frontend/src/components/Badge.vue
Normal file
56
frontend/src/components/Badge.vue
Normal 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>
|
||||
118
frontend/src/components/Button.vue
Normal file
118
frontend/src/components/Button.vue
Normal 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>
|
||||
51
frontend/src/components/Card.vue
Normal file
51
frontend/src/components/Card.vue
Normal 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>
|
||||
120
frontend/src/components/DataTable.vue
Normal file
120
frontend/src/components/DataTable.vue
Normal 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>
|
||||
138
frontend/src/components/FormInput.vue
Normal file
138
frontend/src/components/FormInput.vue
Normal 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>
|
||||
108
frontend/src/components/Modal.vue
Normal file
108
frontend/src/components/Modal.vue
Normal 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">×</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>
|
||||
6
frontend/src/components/index.js
Normal file
6
frontend/src/components/index.js
Normal 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'
|
||||
@@ -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>
|
||||
|
||||
554
frontend/src/views/ErrorHistory.vue
Normal file
554
frontend/src/views/ErrorHistory.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
outDir: '../src/main/resources/static',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
0
backend/gradlew → gradlew
vendored
0
backend/gradlew → gradlew
vendored
0
backend/gradlew.bat → gradlew.bat
vendored
0
backend/gradlew.bat → gradlew.bat
vendored
@@ -1,4 +1,4 @@
|
||||
package com.osolit.loghunter;
|
||||
package research.loghunter;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
136
src/main/java/research/loghunter/controller/ScanController.java
Normal file
136
src/main/java/research/loghunter/controller/ScanController.java
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
24
src/main/java/research/loghunter/dto/ErrorLogDto.java
Normal file
24
src/main/java/research/loghunter/dto/ErrorLogDto.java
Normal 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;
|
||||
}
|
||||
20
src/main/java/research/loghunter/dto/PatternDto.java
Normal file
20
src/main/java/research/loghunter/dto/PatternDto.java
Normal 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;
|
||||
}
|
||||
27
src/main/java/research/loghunter/dto/ServerDto.java
Normal file
27
src/main/java/research/loghunter/dto/ServerDto.java
Normal 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;
|
||||
}
|
||||
17
src/main/java/research/loghunter/dto/ServerLogPathDto.java
Normal file
17
src/main/java/research/loghunter/dto/ServerLogPathDto.java
Normal 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;
|
||||
}
|
||||
14
src/main/java/research/loghunter/dto/SettingDto.java
Normal file
14
src/main/java/research/loghunter/dto/SettingDto.java
Normal 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;
|
||||
}
|
||||
61
src/main/java/research/loghunter/entity/ErrorLog.java
Normal file
61
src/main/java/research/loghunter/entity/ErrorLog.java
Normal 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();
|
||||
}
|
||||
}
|
||||
55
src/main/java/research/loghunter/entity/Pattern.java
Normal file
55
src/main/java/research/loghunter/entity/Pattern.java
Normal 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();
|
||||
}
|
||||
}
|
||||
46
src/main/java/research/loghunter/entity/ScanHistory.java
Normal file
46
src/main/java/research/loghunter/entity/ScanHistory.java
Normal 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;
|
||||
}
|
||||
}
|
||||
61
src/main/java/research/loghunter/entity/ScannedFile.java
Normal file
61
src/main/java/research/loghunter/entity/ScannedFile.java
Normal 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; }
|
||||
}
|
||||
80
src/main/java/research/loghunter/entity/Server.java
Normal file
80
src/main/java/research/loghunter/entity/Server.java
Normal 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();
|
||||
}
|
||||
}
|
||||
43
src/main/java/research/loghunter/entity/ServerLogPath.java
Normal file
43
src/main/java/research/loghunter/entity/ServerLogPath.java
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/main/java/research/loghunter/entity/Setting.java
Normal file
32
src/main/java/research/loghunter/entity/Setting.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
285
src/main/java/research/loghunter/service/ExportService.java
Normal file
285
src/main/java/research/loghunter/service/ExportService.java
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
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
|
||||
) {}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
126
src/main/java/research/loghunter/service/PatternService.java
Normal file
126
src/main/java/research/loghunter/service/PatternService.java
Normal 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
|
||||
) {}
|
||||
}
|
||||
453
src/main/java/research/loghunter/service/ScanService.java
Normal file
453
src/main/java/research/loghunter/service/ScanService.java
Normal 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
|
||||
) {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
162
src/main/java/research/loghunter/service/ServerService.java
Normal file
162
src/main/java/research/loghunter/service/ServerService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
116
src/main/java/research/loghunter/service/SettingService.java
Normal file
116
src/main/java/research/loghunter/service/SettingService.java
Normal 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();
|
||||
}
|
||||
}
|
||||
403
src/main/java/research/loghunter/service/SftpService.java
Normal file
403
src/main/java/research/loghunter/service/SftpService.java
Normal 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) {}
|
||||
}
|
||||
86
src/main/java/research/loghunter/util/CryptoUtil.java
Normal file
86
src/main/java/research/loghunter/util/CryptoUtil.java
Normal 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;
|
||||
}
|
||||
}
|
||||
1
src/main/resources/application.properties
Normal file
1
src/main/resources/application.properties
Normal file
@@ -0,0 +1 @@
|
||||
spring.application.name=log-hunter
|
||||
1
src/main/resources/static/assets/Badge-CipWR1K7.js
Normal file
1
src/main/resources/static/assets/Badge-CipWR1K7.js
Normal 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};
|
||||
1
src/main/resources/static/assets/Dashboard-Cl3L5M8K.js
Normal file
1
src/main/resources/static/assets/Dashboard-Cl3L5M8K.js
Normal file
File diff suppressed because one or more lines are too long
1
src/main/resources/static/assets/Dashboard-DNECAX78.css
Normal file
1
src/main/resources/static/assets/Dashboard-DNECAX78.css
Normal 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}
|
||||
1
src/main/resources/static/assets/DataTable-C9ofeIbS.js
Normal file
1
src/main/resources/static/assets/DataTable-C9ofeIbS.js
Normal 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};
|
||||
1
src/main/resources/static/assets/ErrorLogs-C9EE68c2.css
Normal file
1
src/main/resources/static/assets/ErrorLogs-C9EE68c2.css
Normal 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}
|
||||
1
src/main/resources/static/assets/ErrorLogs-DJwTSi5x.js
Normal file
1
src/main/resources/static/assets/ErrorLogs-DJwTSi5x.js
Normal file
File diff suppressed because one or more lines are too long
1
src/main/resources/static/assets/FormInput-C3OYA9sE.js
Normal file
1
src/main/resources/static/assets/FormInput-C3OYA9sE.js
Normal 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
@@ -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
@@ -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}
|
||||
1
src/main/resources/static/assets/Settings-DBvWE7fX.css
Normal file
1
src/main/resources/static/assets/Settings-DBvWE7fX.css
Normal 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}
|
||||
1
src/main/resources/static/assets/Settings-Dq2hc7rp.js
Normal file
1
src/main/resources/static/assets/Settings-Dq2hc7rp.js
Normal 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};
|
||||
31
src/main/resources/static/assets/index-CZ3IEKgR.js
Normal file
31
src/main/resources/static/assets/index-CZ3IEKgR.js
Normal file
File diff suppressed because one or more lines are too long
1
src/main/resources/static/assets/index-D2qTZo40.css
Normal file
1
src/main/resources/static/assets/index-D2qTZo40.css
Normal 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}
|
||||
1
src/main/resources/static/assets/index-Dy1O9dj-.css
Normal file
1
src/main/resources/static/assets/index-Dy1O9dj-.css
Normal file
@@ -0,0 +1 @@
|
||||
*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}#app{min-height:100vh;background:#f5f5f5}.header{background:#2c3e50;color:#fff;padding:1rem 2rem;display:flex;align-items:center;gap:2rem}.header h1{font-size:1.5rem;margin:0}.header 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}
|
||||
5
src/main/resources/static/assets/index-jV6SX453.js
Normal file
5
src/main/resources/static/assets/index-jV6SX453.js
Normal file
File diff suppressed because one or more lines are too long
24
src/main/resources/static/index.html
Normal file
24
src/main/resources/static/index.html
Normal 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>
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user