Compare commits

...

11 Commits

Author SHA1 Message Date
d0ba4161e5 update 23 2026-01-10 22:05:33 +09:00
a6a5a291ac update 22 2026-01-07 01:48:31 +09:00
9bc8acc905 update 22 2026-01-07 01:42:27 +09:00
057a1bad41 update 22 2026-01-07 01:41:17 +09:00
66e8e21302 update 22 2026-01-07 01:14:51 +09:00
57c3eea429 update 2026-01-06 21:44:58 +09:00
716cf63f73 update 2026-01-06 21:44:36 +09:00
ceec1ad7a9 Step1 2026-01-06 11:25:51 +09:00
9ac66fbc73 Step1 2026-01-06 11:19:46 +09:00
0f4f74cbaf INIT 2026-01-06 10:56:44 +09:00
37f417f478 INIT 2026-01-06 10:56:38 +09:00
149 changed files with 12122 additions and 1 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/.gradle/
/build/

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

View File

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

Binary file not shown.

BIN
.gradle/file-system.probe Normal file

Binary file not shown.

View File

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# 디폴트 무시된 파일
/shelf/
/workspace.xml
# 쿼리 파일을 포함한 무시된 디폴트 폴더
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 에디터 기반 HTTP 클라이언트 요청
/httpRequests/

6
.idea/PMDPlugin.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PMDPlugin">
<option name="skipTestSources" value="false" />
</component>
</project>

18
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile name="Gradle Imported" enabled="true">
<outputRelativeToContentRoot value="true" />
<processorPath useClasspath="false">
<entry name="$USER_HOME$/.gradle/caches/modules-2/files-2.1/org.projectlombok/lombok/1.18.30/f195ee86e6c896ea47a1d39defbe20eb59cd149d/lombok-1.18.30.jar" />
</processorPath>
<module name="log-hunter.main" />
</profile>
</annotationProcessing>
<bytecodeTargetLevel target="17" />
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_STRING" value="-parameters" />
</component>
</project>

12
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="loghunter" uuid="431b313c-b303-45ce-85bb-58c2bc8cb968">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/loghunter.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

6
.idea/data_source_mapping.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/431b313c-b303-45ce-85bb-58c2bc8cb968/console.sql" value="431b313c-b303-45ce-85bb-58c2bc8cb968" />
</component>
</project>

16
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="temurin-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

8
.idea/log-hunter.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

10
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/log-hunter.iml" filepath="$PROJECT_DIR$/.idea/log-hunter.iml" />
</modules>
</component>
</project>

8
.idea/modules/log-hunter.main.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="AdditionalModuleElements">
<content url="file://$MODULE_DIR$/../../build/generated/sources/annotationProcessor/java/main">
<sourceFolder url="file://$MODULE_DIR$/../../build/generated/sources/annotationProcessor/java/main" isTestSource="false" generated="true" />
</content>
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

25
HELP.md Normal file
View File

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

208
README.md
View File

@@ -1 +1,207 @@
Log Hunter
# LogHunter
SFTP를 통해 원격 서버의 로그 파일을 수집하고, 등록된 패턴으로 에러를 검출하여 관리하는 데스크탑 애플리케이션
## 개요
- **목적**: 폐쇄망 환경에서 여러 리눅스 서버의 로그를 수집하여 에러를 검출하고 관리
- **실행 환경**: Windows / macOS / Linux
- **대상 서버**: Linux (SFTP)
- **동작 방식**: 필요 시 수동 실행 → 웹 UI에서 분석 실행 및 결과 조회
## 주요 기능
### 로그 수집 및 분석
- SFTP를 통한 로그 파일 수집 (비밀번호 / 키 파일 인증)
- 정규식 기반 에러 패턴 매칭
- 제외 패턴 지원 (오탐 방지)
- 에러 발생 전후 컨텍스트 캡처
- 1개월 이내 파일만 분석 (성능 최적화)
- 스캔 후 SQLite VACUUM 자동 실행 (DB 최적화)
### 대시보드
- 서버 목록 카드 형태 표시
- 서버별 최근 30일 에러 추이 차트
- 분석 실행 버튼 및 실시간 진행상황 표시
- 심각도별 색상 구분 (CRITICAL/ERROR/WARN)
### 에러 이력
- 전체 에러 조회/검색/필터
- 서버 선택 드롭다운
- 트리 구조 탐색 (경로 → 파일 2단계)
- 파일명 툴팁으로 전체 경로 표시
- 페이지당 100건, 최신순 정렬
- 상세보기 (컨텍스트 포함)
### 통계
- **월별 현황**: 선택 월의 일별 에러 추이, 서버별 필터
- **일별 현황**: 선택 일의 15분 단위 에러 추이, 서버별 필터
- 이전/다음 버튼으로 기간 이동
- 차트 막대 위 총합 표시
### 서버 관리
- 카드 형태 UI로 서버 정보 표시
- SFTP 서버 등록/수정/삭제
- 로그 경로 복수 등록 (경로, 파일 패턴, 설명)
- 연결 테스트
- **분석 실행** 버튼 및 진행상황 표시
### 패턴 관리
- 카드 형태 UI로 패턴 정보 표시
- 에러 패턴 등록/수정/삭제
- 정규식 테스트 기능
- 심각도 설정 (CRITICAL/ERROR/WARN)
- 컨텍스트 라인 수 설정
### 내보내기
- HTML 리포트 생성
- TXT 리포트 생성
- 설정에서 내보내기 경로 지정
## 기술 스택
| 구분 | 기술 |
|------|------|
| Backend | Spring Boot 3.2, Java 17 |
| Frontend | Vue 3, Vite, Chart.js |
| Database | SQLite (파일 DB) |
| SFTP | JSch |
| 배포 | 단일 jar 파일 |
## 화면 구성
| # | 화면명 | URL | 설명 |
|---|--------|-----|------|
| 1 | 대시보드 | `/` | 서버 카드, 30일 차트, 분석 실행 |
| 2 | 에러 이력 | `/errors` | 전체 에러 조회, 트리 탐색, 상세보기 |
| 3 | 월별 현황 | `/stats/monthly` | 월별 에러 통계 차트 |
| 4 | 일별 현황 | `/stats/daily` | 일별 15분 단위 에러 차트 |
| 5 | 서버 관리 | `/servers` | 서버 CRUD, 분석 실행, 로그 경로 관리 |
| 6 | 패턴 관리 | `/patterns` | 패턴 CRUD, 테스트 |
| 7 | 설정 | `/settings` | 내보내기 경로, 보관 기간 |
## 폴더 구조
```
log-hunter/
├── src/ # Spring Boot 소스
│ └── main/
│ ├── java/
│ │ └── research/loghunter/
│ │ ├── controller/ # REST API
│ │ ├── service/ # 비즈니스 로직
│ │ ├── repository/ # DB 접근
│ │ ├── entity/ # JPA Entity
│ │ └── dto/ # 데이터 전송 객체
│ └── resources/
│ └── static/ # Vue3 빌드 결과물
├── frontend/ # Vue3 (개발용)
│ └── src/
│ ├── views/ # 페이지 컴포넌트
│ ├── components/ # 공통 컴포넌트
│ └── api/ # API 클라이언트
├── data/ # SQLite DB
├── exports/ # HTML/TXT 내보내기
├── start.sh # 실행 스크립트 (macOS/Linux)
├── start.bat # 실행 스크립트 (Windows)
├── build.gradle
└── settings.gradle
```
## 실행 방법
### 개발 모드
```bash
# Frontend 개발 서버 실행
cd frontend
npm run dev
# Backend 실행 (별도 터미널)
./gradlew bootRun
```
### 프로덕션 모드
```bash
# 통합 빌드 및 실행
./start.sh # macOS/Linux
start.bat # Windows
```
### 실행 시 동작
```
log-hunter.jar 실행
내장 톰캣 (localhost:8080)
자동으로 브라우저 열림
웹 UI에서 서버별 분석 실행
```
---
## 작업 현황
### ✅ 완료된 기능
#### Backend
- [x] Spring Boot 3.2 프로젝트 설정
- [x] SQLite 연동 (JPA)
- [x] DB 스키마 설계 (6개 Entity)
- [x] 서버/로그경로/패턴/설정 CRUD API
- [x] 비밀번호/passphrase 암호화
- [x] SFTP 연결 (비밀번호 / 키 파일)
- [x] 연결 테스트 API
- [x] 로그 파싱 + 패턴 매칭 엔진
- [x] 에러 저장 (컨텍스트 포함)
- [x] 분석 실행 API (SSE 진행상황)
- [x] HTML/TXT 리포트 생성
- [x] 1개월 이내 파일만 분석
- [x] 스캔 후 VACUUM 자동 실행
- [x] 통계 API (월별/일별/시간대별)
#### Frontend
- [x] Vue 3 + Vite 프로젝트 설정
- [x] 레이아웃 (헤더, 사이드메뉴)
- [x] 라우터 설정
- [x] API 클라이언트 (axios)
- [x] 공통 컴포넌트 (Card, DataTable, Modal, FormInput, Badge, Button)
- [x] 대시보드 (서버 카드, 30일 차트, 분석 실행, 진행상황)
- [x] 에러 이력 (서버 선택, 트리 탐색, 페이지네이션, 상세보기)
- [x] 월별 현황 (일별 차트, 이전/다음)
- [x] 일별 현황 (15분 단위 차트, 이전/다음)
- [x] 서버 관리 (카드 UI, 분석 실행, 진행상황, 로그 경로 모달)
- [x] 패턴 관리 (카드 UI, 테스트 모달)
- [x] 설정 (내보내기 경로, 보관 기간)
- [x] Chart.js + datalabels 플러그인
---
## TODO
### 1. 에러를 AI 분석을 통해 서버별 일일 리포트 만드는 기능
- [ ] OpenAI API 연동
- [ ] 일일 에러 요약 생성
- [ ] 에러 원인 분석 및 해결 방안 제시
- [ ] 서버별 리포트 자동 생성
- [ ] 리포트 이메일 발송 (선택)
---
## 변경 이력
| 일시 | 내용 |
|------|------|
| 2025-01-06 | 최초 작성, 요구사항 정리 |
| 2025-01-06 | Step 1~6 완료 (기본 프로젝트 구조) |
| 2025-01-06 | Step 7 완료 (모든 화면 개발) |
| 2025-01-06 | TEST_GUIDE.md 작성 |
| 2025-01-06 | Frontend 빌드 + Backend 실행 테스트 성공 |
| 2025-01-07 | 에러 이력 정렬, 페이지당 100건, 전체 페이지 가로 해상도 최적화 |
| 2025-01-07 | 에러 이력 개선 (서버 선택, 트리 구조 2단계, 검색 로직) |
| 2025-01-07 | 1개월 이내 파일만 분석 |
| 2025-01-07 | 통계 페이지 구현 (월별/일별 현황, 차트 datalabels) |
| 2025-01-07 | 대시보드 서버별 30일 차트 추가 |
| 2025-01-07 | SQLite VACUUM 자동화 (스캔 후 실행) |
| 2025-01-07 | 서버/패턴 관리 UI 개선 (카드 형태) |
| 2025-01-07 | 서버 관리에 분석 실행 + 진행상황 표시 추가 |

256
TEST_GUIDE.md Normal file
View File

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

44
build.gradle Normal file
View File

@@ -0,0 +1,44 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.1'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'research'
version = '1.0.0'
description = 'log-hunter'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
// Spring Boot
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// SQLite
implementation 'org.xerial:sqlite-jdbc:3.45.1.0'
implementation 'org.hibernate.orm:hibernate-community-dialects:6.4.1.Final'
// SFTP
implementation 'com.jcraft:jsch:0.1.55'
// Utility
implementation 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}

1
data/.gitkeep Normal file
View File

@@ -0,0 +1 @@
# SQLite DB 파일이 이 폴더에 생성됩니다.

BIN
data/loghunter.db Normal file

Binary file not shown.

16
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Dependencies
node_modules/
# Build
dist/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Logs
*.log

26
frontend/index.html Normal file
View File

@@ -0,0 +1,26 @@
<!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">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>LogHunter</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1598
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "log-hunter-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.5",
"chart.js": "^4.5.1",
"chartjs-plugin-datalabels": "^2.2.0",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.0.11"
}
}

84
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,84 @@
<template>
<div id="app">
<header class="header">
<router-link to="/" class="logo">
<h1>📊 LogHunter</h1>
</router-link>
<nav>
<router-link to="/">대시보드</router-link>
<router-link to="/errors">에러 이력</router-link>
<router-link to="/stats/monthly">월별현황</router-link>
<router-link to="/stats/daily">일별현황</router-link>
<router-link to="/servers">서버 관리</router-link>
<router-link to="/patterns">패턴 관리</router-link>
<router-link to="/settings">설정</router-link>
</nav>
</header>
<main class="main">
<router-view />
</main>
</div>
</template>
<script setup>
</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;
}
.header {
background: #2c3e50;
color: white;
padding: 1rem 2rem;
display: flex;
align-items: center;
gap: 2rem;
}
.header h1 {
font-size: 1.5rem;
margin: 0;
}
.header .logo {
text-decoration: none;
color: white;
}
.header nav {
display: flex;
gap: 1rem;
}
.header nav a {
color: #ecf0f1;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background 0.2s;
}
.header nav a:hover {
background: #34495e;
}
.header nav a.router-link-active {
background: #3498db;
}
.main {
padding: 1.5rem;
}
</style>

173
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,173 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// Request interceptor
api.interceptors.request.use(
config => config,
error => Promise.reject(error)
)
// Response interceptor
api.interceptors.response.use(
response => response.data,
error => {
console.error('API Error:', error)
return Promise.reject(error)
}
)
// 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}`),
// 분석 결과 초기화
reset: (serverId) => api.delete(`/scan/reset/${serverId}`),
resetAll: () => api.delete('/scan/reset-all'),
// 에러 통계
getStatsByFile: (serverId) => api.get('/scan/stats/by-file', { params: { serverId } }),
getStatsByServer: () => api.get('/scan/stats/by-server'),
getStatsByPattern: (serverId) => api.get('/scan/stats/by-pattern', { params: { serverId } }),
// 대시보드용: 서버별 최근 N일 일별 통계
getDailyStatsByServer: (days = 30) => api.get('/scan/stats/daily-by-server', { params: { days } }),
// 월별현황용: 서버별 해당 월 일별 통계
getMonthlyStatsByServer: (year, month) => api.get('/scan/stats/monthly-by-server', { params: { year, month } }),
// 일별현황용: 서버별 해당 날짜 5분 단위 통계
getTimeStatsByServer: (date, intervalMinutes = 5) => api.get('/scan/stats/time-by-server', { params: { date, intervalMinutes } })
}
// 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 }),
getTree: () => api.get('/error-logs/tree'),
getFiles: (serverId) => api.get('/error-logs/files', { params: { serverId } }),
deleteByIds: (ids) => api.delete('/error-logs/batch', { data: ids }),
deleteByFile: (serverId, filePath) => api.delete('/error-logs/by-file', { params: { serverId, filePath } })
}
// Export API (Step 5에서 구현 예정)
export const exportApi = {
exportHtml: (params) => api.post('/export/html', params, { responseType: 'blob' }),
exportTxt: (params) => api.post('/export/txt', params, { responseType: 'blob' })
}
export default api

View File

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

View File

@@ -0,0 +1,129 @@
<template>
<button
ref="buttonRef"
: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>
import { ref } from 'vue'
const props = 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'])
const buttonRef = ref(null)
const focus = () => {
buttonRef.value?.focus()
}
defineExpose({ focus })
</script>
<style scoped>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-lg {
padding: 12px 24px;
font-size: 16px;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2980b9;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #5a6268;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c0392b;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover:not(:disabled) {
background: #1e8449;
}
.btn-warning {
background: #f39c12;
color: white;
}
.btn-warning:hover:not(:disabled) {
background: #d68910;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

11
frontend/src/main.js Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,44 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue')
},
{
path: '/errors',
name: 'errors',
component: () => import('@/views/ErrorLogs.vue')
},
{
path: '/servers',
name: 'servers',
component: () => import('@/views/ServerManage.vue')
},
{
path: '/stats/monthly',
name: 'monthly-stats',
component: () => import('@/views/MonthlyStats.vue')
},
{
path: '/stats/daily',
name: 'daily-stats',
component: () => import('@/views/DailyStats.vue')
},
{
path: '/patterns',
name: 'patterns',
component: () => import('@/views/PatternManage.vue')
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/Settings.vue')
}
]
})
export default router

View File

@@ -0,0 +1,289 @@
<template>
<div class="daily-stats">
<div class="page-header">
<h2>일별 에러현황</h2>
<div class="filter-section">
<button class="nav-btn" @click="prevDay"> 이전</button>
<input type="date" v-model="selectedDate" @change="loadStats" />
<button class="nav-btn" @click="nextDay">다음 </button>
</div>
</div>
<div v-if="loading" class="loading">
<p>로딩중...</p>
</div>
<div v-else-if="stats.length === 0" class="no-data">
<p>{{ formatDate(selectedDate) }} 분석된 에러 데이터가 없습니다.</p>
</div>
<div v-else class="server-charts">
<Card v-for="server in stats" :key="server.serverId" class="server-chart-card">
<template #header>
<div class="chart-header">
<h3>🖥 {{ server.serverName }}</h3>
<span class="chart-subtitle">{{ formatDate(selectedDate) }} 15 단위 에러 ({{ getTotalCount(server) }})</span>
</div>
</template>
<div class="chart-wrapper">
<div class="chart-container">
<Bar :data="getChartData(server)" :options="chartOptions" />
</div>
</div>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Card } from '@/components'
import { scanApi } from '@/api'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js'
import ChartDataLabels from 'chartjs-plugin-datalabels'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels)
// State
const loading = ref(false)
const stats = ref([])
const selectedDate = ref(getCurrentDate())
function getCurrentDate() {
const now = new Date()
return now.toISOString().split('T')[0]
}
function prevDay() {
const date = new Date(selectedDate.value)
date.setDate(date.getDate() - 1)
selectedDate.value = date.toISOString().split('T')[0]
loadStats()
}
function nextDay() {
const date = new Date(selectedDate.value)
date.setDate(date.getDate() + 1)
selectedDate.value = date.toISOString().split('T')[0]
loadStats()
}
// Chart Options (10분 단위 144개 막대)
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
tooltip: {
mode: 'index',
intersect: false
},
datalabels: {
display: (context) => {
if (context.datasetIndex !== 2) return false
const datasets = context.chart.data.datasets
const index = context.dataIndex
const total = datasets.reduce((sum, ds) => sum + (ds.data[index] || 0), 0)
return total > 0
},
anchor: 'end',
align: 'end',
offset: 0,
font: {
size: 9
},
color: '#666',
formatter: (value, context) => {
const datasets = context.chart.data.datasets
const index = context.dataIndex
return datasets.reduce((sum, ds) => sum + (ds.data[index] || 0), 0)
}
}
},
scales: {
x: {
stacked: true,
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 24,
callback: function(value, index) {
const label = this.getLabelForValue(value)
if (label && label.endsWith(':00')) {
return label
}
return ''
}
},
grid: {
display: false
}
},
y: {
stacked: true,
beginAtZero: true
}
},
barPercentage: 0.8,
categoryPercentage: 0.9
}
// Chart Data 생성
const getChartData = (server) => {
const labels = server.timeStats.map(s => s.time)
return {
labels,
datasets: [
{
label: 'CRITICAL',
data: server.timeStats.map(s => s.critical),
backgroundColor: '#9b59b6',
borderWidth: 0
},
{
label: 'ERROR',
data: server.timeStats.map(s => s.error),
backgroundColor: '#e74c3c',
borderWidth: 0
},
{
label: 'WARN',
data: server.timeStats.map(s => s.warn),
backgroundColor: '#f39c12',
borderWidth: 0
}
]
}
}
// Load data (10분 단위)
const loadStats = async () => {
loading.value = true
try {
stats.value = await scanApi.getTimeStatsByServer(selectedDate.value, 15)
} catch (e) {
console.error('Failed to load stats:', e)
stats.value = []
} finally {
loading.value = false
}
}
// Utils
const formatDate = (dateStr) => {
const date = new Date(dateStr)
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
}
const getTotalCount = (server) => {
return server.timeStats.reduce((sum, s) => sum + s.total, 0)
}
onMounted(() => {
loadStats()
})
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
}
.filter-section {
display: flex;
align-items: center;
gap: 8px;
}
.filter-section input[type="date"] {
padding: 10px 16px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.filter-section input[type="date"]:focus {
outline: none;
border-color: #3498db;
}
.nav-btn {
padding: 10px 16px;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.nav-btn:hover {
background: #f0f0f0;
border-color: #3498db;
}
.loading,
.no-data {
text-align: center;
padding: 60px;
color: #666;
background: white;
border-radius: 8px;
}
.server-charts {
display: flex;
flex-direction: column;
gap: 20px;
}
.server-chart-card {
width: 100%;
}
.chart-header {
display: flex;
align-items: center;
gap: 12px;
}
.chart-header h3 {
margin: 0;
font-size: 16px;
}
.chart-subtitle {
font-size: 13px;
color: #888;
}
.chart-wrapper {
overflow-x: auto;
}
.chart-container {
height: 220px;
min-width: 100%;
padding: 8px 0;
}
</style>

View File

@@ -0,0 +1,498 @@
<template>
<div class="dashboard">
<div class="dashboard-header">
<h2>대시보드</h2>
<div class="header-actions">
<Button
@click="scanAllServers"
:loading="scanningAll"
:disabled="activeServers.length === 0"
>
전체 분석 실행
</Button>
</div>
</div>
<!-- 서버 목록 -->
<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>
<!-- 서버별 30일 에러 차트 -->
<div v-if="dailyStats.length > 0" class="daily-charts">
<h3>최근 30 에러 추이</h3>
<div class="chart-list">
<Card v-for="server in dailyStats" :key="server.serverId" class="chart-card">
<template #header>
<div class="chart-header">
<span>🖥 {{ server.serverName }}</span>
<span class="chart-total"> {{ getTotalCount(server) }}</span>
</div>
</template>
<div class="chart-container">
<Bar :data="getChartData(server)" :options="chartOptions" />
</div>
</Card>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Card, Button, Badge } from '@/components'
import { serverApi, scanApi } from '@/api'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js'
import ChartDataLabels from 'chartjs-plugin-datalabels'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels)
// State
const servers = ref([])
const dailyStats = ref([])
const loading = ref(false)
const scanningServerId = ref(null)
const scanningAll = ref(false)
const progressMap = ref({})
// Computed
const activeServers = computed(() => servers.value.filter(s => s.active))
// Chart options
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false
},
datalabels: {
display: (context) => {
if (context.datasetIndex !== 2) return false
const datasets = context.chart.data.datasets
const index = context.dataIndex
const total = datasets.reduce((sum, ds) => sum + (ds.data[index] || 0), 0)
return total > 0
},
anchor: 'end',
align: 'end',
offset: 2,
font: {
size: 10,
weight: 'bold'
},
color: '#666',
formatter: (value, context) => {
const datasets = context.chart.data.datasets
const index = context.dataIndex
return datasets.reduce((sum, ds) => sum + (ds.data[index] || 0), 0)
}
}
},
scales: {
x: {
stacked: true,
grid: { display: false }
},
y: {
stacked: true,
beginAtZero: true,
grid: { color: '#f0f0f0' }
}
}
}
// Chart data 생성
const getChartData = (server) => {
const labels = server.dailyStats.map(s => {
const date = new Date(s.date)
return `${date.getMonth() + 1}/${date.getDate()}`
})
return {
labels,
datasets: [
{
label: 'CRITICAL',
data: server.dailyStats.map(s => s.critical),
backgroundColor: '#9b59b6',
borderRadius: 2
},
{
label: 'ERROR',
data: server.dailyStats.map(s => s.error),
backgroundColor: '#e74c3c',
borderRadius: 2
},
{
label: 'WARN',
data: server.dailyStats.map(s => s.warn),
backgroundColor: '#f39c12',
borderRadius: 2
}
]
}
}
// 총 에러 수 계산
const getTotalCount = (server) => {
return server.dailyStats.reduce((sum, s) => sum + s.total, 0)
}
// 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 loadDailyStats = async () => {
try {
dailyStats.value = await scanApi.getDailyStatsByServer(30)
} catch (e) {
console.error('Failed to load daily stats:', 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()
loadDailyStats()
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()
loadDailyStats()
setTimeout(() => {
progressMap.value = {}
}, 5000)
},
(error) => {
scanningAll.value = false
alert('분석 실패: ' + error)
}
)
}
// Utils
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ko-KR')
}
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)
}
onMounted(() => {
loadServers()
loadDailyStats()
})
</script>
<style scoped>
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.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;
margin-top: 12px;
}
.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;
}
/* 차트 섹션 */
.daily-charts {
margin-top: 32px;
}
.daily-charts h3 {
margin-bottom: 16px;
font-size: 18px;
}
.chart-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.chart-card {
width: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-total {
font-size: 13px;
color: #888;
}
.chart-container {
height: 200px;
padding: 8px 0;
}
</style>

View File

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

View File

@@ -0,0 +1,951 @@
<template>
<div class="error-history">
<div class="layout-container">
<!-- 좌측 트리 영역 -->
<div class="tree-panel">
<div class="tree-header">
<h4>파일 탐색</h4>
<Button size="sm" variant="secondary" @click="loadTree" :loading="treeLoading">
새로고침
</Button>
</div>
<!-- 서버 선택 -->
<div class="server-select">
<select v-model="selectedServerId" @change="onServerChange">
<option :value="null">전체 서버</option>
<option v-for="server in treeData" :key="server.serverId" :value="server.serverId">
{{ server.serverName }} ({{ server.totalErrorCount }})
</option>
</select>
</div>
<div class="tree-content">
<div v-if="treeLoading" class="tree-loading">로딩중...</div>
<div v-else-if="filteredTreeData.length === 0" class="tree-empty">
분석된 파일이 없습니다.
</div>
<div v-else class="tree-list">
<!-- 전체 선택 -->
<div
class="tree-item tree-all"
:class="{ active: !selectedFile }"
@click="selectAll"
>
<span class="tree-icon">📊</span>
<span class="tree-label">전체</span>
<span class="tree-count">{{ filteredErrorCount }}</span>
</div>
<!-- 경로 노드 (서버 선택 ) -->
<div v-for="server in filteredTreeData" :key="server.serverId">
<div v-for="path in server.paths" :key="path.path" class="tree-path">
<div
class="tree-item tree-path-item"
@click="togglePath(server.serverId + ':' + path.path)"
>
<span class="tree-toggle">{{ expandedPaths.includes(server.serverId + ':' + path.path) ? '▼' : '▶' }}</span>
<span class="tree-icon">📁</span>
<span class="tree-label" :title="path.path">{{ shortenPath(path.path) }}</span>
<span class="tree-count">{{ path.totalErrorCount }}</span>
</div>
<!-- 파일 노드 -->
<div v-if="expandedPaths.includes(server.serverId + ':' + path.path)" class="tree-files">
<div
v-for="file in path.files"
:key="file.filePath"
class="tree-item tree-file-item"
:class="{ active: selectedFile && selectedFile.filePath === file.filePath && selectedFile.serverId === server.serverId }"
@click="selectFile(server.serverId, file)"
:title="file.fileName"
>
<span class="tree-icon">📄</span>
<span class="tree-label">{{ file.fileName }}</span>
<span class="tree-count-detail">
<span v-if="file.criticalCount" class="critical">{{ file.criticalCount }}</span>
<span v-if="file.errorLevelCount" class="error">{{ file.errorLevelCount }}</span>
<span v-if="file.warnCount" class="warn">{{ file.warnCount }}</span>
</span>
<button class="tree-delete" @click.stop="confirmDeleteFile(server.serverId, file)" title="삭제">
🗑
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 우측 에러 목록 영역 -->
<div class="list-panel">
<Card>
<template #header>
<div class="card-header-content">
<h3>
<span v-if="selectedFile">{{ selectedFile.fileName }}</span>
<span v-else>전체 에러 이력</span>
</h3>
<div class="header-actions">
<Button size="sm" variant="secondary" @click="exportHtml">HTML</Button>
<Button size="sm" variant="secondary" @click="exportTxt">TXT</Button>
</div>
</div>
</template>
<!-- 필터 -->
<div class="filters">
<div class="filter-row">
<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 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 sortable" @click="toggleSort('occurredAt')">
발생시간 <span class="sort-icon">{{ getSortIcon('occurredAt') }}</span>
</th>
<th class="col-severity sortable" @click="toggleSort('severity')">
심각도 <span class="sort-icon">{{ getSortIcon('severity') }}</span>
</th>
<th class="col-pattern sortable" @click="toggleSort('patternName')">
패턴 <span class="sort-icon">{{ getSortIcon('patternName') }}</span>
</th>
<th class="col-summary sortable" @click="toggleSort('summary')">
요약 <span class="sort-icon">{{ getSortIcon('summary') }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="error in errors" :key="error.id">
<td class="col-time">{{ formatDate(error.occurredAt) }}</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">
<a href="#" class="summary-link" @click.prevent="showDetail(error)">
{{ truncate(error.summary, 100) }}
</a>
</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>
</div>
</div>
<!-- 상세 모달 -->
<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>
<!-- 삭제 확인 모달 -->
<Modal v-model="showDeleteModal" title="파일 삭제" width="400px">
<p>{{ deleteFileName }} 파일의 분석 결과를 삭제하시겠습니까?</p>
<p class="warning-text">에러 로그와 스캔 기록이 삭제되어 재분석이 가능해집니다.</p>
<template #footer>
<Button variant="secondary" @click="showDeleteModal = false">취소</Button>
<Button ref="deleteBtn" variant="danger" @click="executeDelete" :loading="deleting">삭제</Button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { Card, Button, Badge, Modal, FormInput } from '@/components'
import { patternApi, errorLogApi } from '@/api'
// Tree State
const treeData = ref([])
const treeLoading = ref(false)
const selectedServerId = ref(null)
const expandedPaths = ref([])
const selectedFile = ref(null)
// List State
const loading = ref(false)
const errors = ref([])
const totalElements = ref(0)
const totalPages = ref(0)
const currentPage = ref(0)
const pageSize = 100
// Sort State
const sortField = ref('occurredAt')
const sortDirection = ref('desc')
// Filters
const filters = reactive({
patternId: '',
severity: '',
keyword: ''
})
// Options
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)
// Delete Modal
const showDeleteModal = ref(false)
const deleteServerId = ref(null)
const deleteFilePath = ref(null)
const deleteFileName = ref('')
const deleting = ref(false)
const deleteBtn = ref(null)
// Computed
const filteredTreeData = computed(() => {
if (!selectedServerId.value) return treeData.value
return treeData.value.filter(s => s.serverId === selectedServerId.value)
})
const filteredErrorCount = computed(() => {
return filteredTreeData.value.reduce((sum, server) => sum + server.totalErrorCount, 0)
})
// Load tree data
const loadTree = async () => {
treeLoading.value = true
try {
treeData.value = await errorLogApi.getTree()
// 첫 번째 경로 자동 확장
if (treeData.value.length > 0 && treeData.value[0].paths.length > 0) {
expandedPaths.value = [treeData.value[0].serverId + ':' + treeData.value[0].paths[0].path]
}
} catch (e) {
console.error('Failed to load tree:', e)
} finally {
treeLoading.value = false
}
}
// Load options
const loadOptions = async () => {
try {
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)
}
}
// Server change
const onServerChange = () => {
selectedFile.value = null
expandedPaths.value = []
// 선택된 서버의 첫 번째 경로 확장
const serverData = filteredTreeData.value[0]
if (serverData && serverData.paths.length > 0) {
expandedPaths.value = [serverData.serverId + ':' + serverData.paths[0].path]
}
currentPage.value = 0
search()
}
// Tree actions
const togglePath = (pathKey) => {
const idx = expandedPaths.value.indexOf(pathKey)
if (idx >= 0) {
expandedPaths.value.splice(idx, 1)
} else {
expandedPaths.value.push(pathKey)
}
}
const selectAll = () => {
selectedFile.value = null
currentPage.value = 0
search()
}
const selectFile = (serverId, file) => {
selectedFile.value = { serverId, ...file }
currentPage.value = 0
search()
}
const shortenPath = (path) => {
if (path.length > 30) {
return '...' + path.slice(-27)
}
return path
}
// Sort
const toggleSort = (field) => {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
currentPage.value = 0
search()
}
const getSortIcon = (field) => {
if (sortField.value !== field) return '↕'
return sortDirection.value === 'asc' ? '↑' : '↓'
}
// Search
const search = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
size: pageSize,
sort: `${sortField.value},${sortDirection.value}`
}
// 서버 필터 (드롭다운 선택 또는 파일 선택)
if (selectedFile.value) {
params.serverId = selectedFile.value.serverId
params.filePath = selectedFile.value.filePath
} else if (selectedServerId.value) {
params.serverId = selectedServerId.value
}
if (filters.patternId) params.patternId = filters.patternId
if (filters.severity) params.severity = filters.severity
if (filters.keyword) params.keyword = filters.keyword
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.patternId = ''
filters.severity = ''
filters.keyword = ''
sortField.value = 'occurredAt'
sortDirection.value = 'desc'
currentPage.value = 0
search()
}
const goToPage = (page) => {
currentPage.value = page
search()
}
// Delete
const confirmDeleteFile = (serverId, file) => {
deleteServerId.value = serverId
deleteFilePath.value = file.filePath
deleteFileName.value = file.fileName
showDeleteModal.value = true
nextTick(() => {
deleteBtn.value?.focus()
})
}
const executeDelete = async () => {
deleting.value = true
try {
await errorLogApi.deleteByFile(deleteServerId.value, deleteFilePath.value)
showDeleteModal.value = false
if (selectedFile.value && selectedFile.value.filePath === deleteFilePath.value) {
selectedFile.value = null
}
await loadTree()
search()
} catch (e) {
console.error('Failed to delete:', e)
alert('삭제 실패')
} finally {
deleting.value = false
}
}
// Detail
const showDetail = async (error) => {
try {
selectedError.value = await errorLogApi.getById(error.id)
showDetailModal.value = true
} catch (e) {
selectedError.value = error
showDetailModal.value = true
}
}
// Export
const buildExportParams = () => {
const params = new URLSearchParams()
if (selectedFile.value) {
params.append('serverId', selectedFile.value.serverId)
params.append('filePath', selectedFile.value.filePath)
} else if (selectedServerId.value) {
params.append('serverId', selectedServerId.value)
}
if (filters.patternId) params.append('patternId', filters.patternId)
if (filters.severity) params.append('severity', filters.severity)
if (filters.keyword) params.append('keyword', filters.keyword)
return params.toString()
}
const exportHtml = () => {
const params = buildExportParams()
window.open(`/api/export/html?${params}`, '_blank')
}
const exportTxt = () => {
const params = buildExportParams()
window.open(`/api/export/txt?${params}`, '_blank')
}
// Utils
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
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(() => {
loadTree()
loadOptions()
search()
})
</script>
<style scoped>
.layout-container {
display: flex;
gap: 20px;
height: calc(100vh - 120px);
}
/* 트리 패널 */
.tree-panel {
width: 360px;
min-width: 360px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
}
.tree-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #eee;
}
.tree-header h4 {
margin: 0;
font-size: 15px;
}
.server-select {
padding: 12px 16px;
border-bottom: 1px solid #eee;
}
.server-select select {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
background: white;
cursor: pointer;
}
.server-select select:focus {
outline: none;
border-color: #3498db;
}
.tree-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.tree-loading,
.tree-empty {
padding: 20px;
text-align: center;
color: #666;
font-size: 13px;
}
.tree-item {
display: flex;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.tree-item:hover {
background: #f5f5f5;
}
.tree-item.active {
background: #e3f2fd;
font-weight: 500;
}
.tree-toggle {
width: 16px;
font-size: 10px;
color: #999;
margin-right: 4px;
}
.tree-icon {
margin-right: 8px;
font-size: 14px;
}
.tree-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-count {
font-size: 12px;
color: #666;
background: #f0f0f0;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
}
.tree-count-detail {
display: flex;
gap: 4px;
margin-left: 8px;
}
.tree-count-detail span {
font-size: 11px;
padding: 2px 6px;
border-radius: 8px;
font-weight: 500;
}
.tree-count-detail .critical {
background: #f3e5f5;
color: #9b59b6;
}
.tree-count-detail .error {
background: #ffebee;
color: #e74c3c;
}
.tree-count-detail .warn {
background: #fff8e1;
color: #f39c12;
}
.tree-delete {
opacity: 0;
background: none;
border: none;
cursor: pointer;
padding: 4px;
font-size: 12px;
transition: opacity 0.15s;
}
.tree-item:hover .tree-delete {
opacity: 1;
}
.tree-delete:hover {
transform: scale(1.1);
}
.tree-all {
margin-bottom: 8px;
border-bottom: 1px solid #eee;
padding-bottom: 12px;
}
.tree-path {
margin-bottom: 4px;
}
.tree-files {
margin-left: 20px;
}
.tree-file-item {
padding-left: 26px;
}
/* 목록 패널 */
.list-panel {
flex: 1;
min-width: 0;
overflow: hidden;
}
.list-panel :deep(.card) {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-panel :deep(.card-body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header-content h3 {
margin: 0;
font-size: 16px;
}
.header-actions {
display: flex;
gap: 8px;
}
.filters {
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 16px;
flex-shrink: 0;
}
.filter-row {
display: grid;
grid-template-columns: 1fr 1fr 2fr auto;
gap: 16px;
align-items: end;
}
.filter-actions {
display: flex;
gap: 8px;
padding-bottom: 4px;
}
.results-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.results-header {
margin-bottom: 12px;
color: #666;
font-size: 14px;
flex-shrink: 0;
}
.table-wrapper {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.error-table {
width: 100%;
border-collapse: collapse;
}
.error-table th,
.error-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
.error-table th {
background: #f8f9fa;
font-weight: 600;
font-size: 13px;
position: sticky;
top: 0;
z-index: 1;
}
.error-table th.sortable {
cursor: pointer;
user-select: none;
}
.error-table th.sortable:hover {
background: #e9ecef;
}
.sort-icon {
margin-left: 4px;
color: #999;
font-size: 12px;
}
.error-table tbody tr:hover {
background: #fafafa;
}
.col-time { width: 160px; white-space: nowrap; }
.col-severity { width: 90px; }
.col-pattern { width: 100px; }
.col-summary { }
.summary-link {
color: #333;
text-decoration: none;
cursor: pointer;
}
.summary-link:hover {
color: #3498db;
text-decoration: underline;
}
.empty-result,
.loading-result {
padding: 40px;
text-align: center;
color: #666;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
padding: 16px 0;
border-top: 1px solid #eee;
flex-shrink: 0;
}
.page-info {
font-size: 14px;
color: #666;
}
/* 모달 */
.warning-text {
color: #e74c3c;
font-size: 13px;
margin-top: 8px;
}
.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;
}
.file-path {
word-break: break-all;
font-family: monospace;
font-size: 12px;
}
.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: #1e1e1e;
color: #d4d4d4;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
white-space: pre;
margin: 0;
max-height: 250px;
}
</style>

View File

@@ -0,0 +1,270 @@
<template>
<div class="monthly-stats">
<div class="page-header">
<h2>월별 에러현황</h2>
<div class="filter-section">
<button class="nav-btn" @click="prevMonth"> 이전</button>
<input type="month" v-model="selectedMonth" @change="loadStats" />
<button class="nav-btn" @click="nextMonth">다음 </button>
</div>
</div>
<div v-if="loading" class="loading">
<p>로딩중...</p>
</div>
<div v-else-if="stats.length === 0" class="no-data">
<p>{{ formatMonth(selectedMonth) }} 분석된 에러 데이터가 없습니다.</p>
</div>
<div v-else class="server-charts">
<Card v-for="server in stats" :key="server.serverId" class="server-chart-card">
<template #header>
<div class="chart-header">
<h3>🖥 {{ server.serverName }}</h3>
<span class="chart-subtitle">{{ formatMonth(selectedMonth) }} 일별 에러 ({{ getTotalCount(server) }})</span>
</div>
</template>
<div class="chart-container">
<Bar :data="getChartData(server)" :options="chartOptions" />
</div>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Card } from '@/components'
import { scanApi } from '@/api'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js'
import ChartDataLabels from 'chartjs-plugin-datalabels'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels)
// State
const loading = ref(false)
const stats = ref([])
const selectedMonth = ref(getCurrentMonth())
function getCurrentMonth() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
function prevMonth() {
const [year, month] = selectedMonth.value.split('-').map(Number)
const date = new Date(year, month - 2, 1)
selectedMonth.value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
loadStats()
}
function nextMonth() {
const [year, month] = selectedMonth.value.split('-').map(Number)
const date = new Date(year, month, 1)
selectedMonth.value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
loadStats()
}
// Chart Options
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
tooltip: {
mode: 'index',
intersect: false
},
datalabels: {
display: (context) => {
if (context.datasetIndex !== 2) return false
const datasets = context.chart.data.datasets
const index = context.dataIndex
const total = datasets.reduce((sum, ds) => sum + (ds.data[index] || 0), 0)
return total > 0
},
anchor: 'end',
align: 'end',
offset: 2,
font: {
size: 10,
weight: 'bold'
},
color: '#666',
formatter: (value, context) => {
const datasets = context.chart.data.datasets
const index = context.dataIndex
return datasets.reduce((sum, ds) => sum + (ds.data[index] || 0), 0)
}
}
},
scales: {
x: {
stacked: true
},
y: {
stacked: true,
beginAtZero: true
}
}
}
// Chart Data 생성
const getChartData = (server) => {
const labels = server.dailyStats.map(s => {
const date = new Date(s.date)
return `${date.getDate()}`
})
return {
labels,
datasets: [
{
label: 'CRITICAL',
data: server.dailyStats.map(s => s.critical),
backgroundColor: '#9b59b6',
borderRadius: 2
},
{
label: 'ERROR',
data: server.dailyStats.map(s => s.error),
backgroundColor: '#e74c3c',
borderRadius: 2
},
{
label: 'WARN',
data: server.dailyStats.map(s => s.warn),
backgroundColor: '#f39c12',
borderRadius: 2
}
]
}
}
// Load data
const loadStats = async () => {
loading.value = true
try {
const [year, month] = selectedMonth.value.split('-').map(Number)
stats.value = await scanApi.getMonthlyStatsByServer(year, month)
} catch (e) {
console.error('Failed to load stats:', e)
stats.value = []
} finally {
loading.value = false
}
}
// Utils
const formatMonth = (monthStr) => {
const [year, month] = monthStr.split('-')
return `${year}${parseInt(month)}`
}
const getTotalCount = (server) => {
return server.dailyStats.reduce((sum, s) => sum + s.total, 0)
}
onMounted(() => {
loadStats()
})
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
}
.filter-section {
display: flex;
align-items: center;
gap: 8px;
}
.filter-section input[type="month"] {
padding: 10px 16px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.filter-section input[type="month"]:focus {
outline: none;
border-color: #3498db;
}
.nav-btn {
padding: 10px 16px;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.nav-btn:hover {
background: #f0f0f0;
border-color: #3498db;
}
.loading,
.no-data {
text-align: center;
padding: 60px;
color: #666;
background: white;
border-radius: 8px;
}
.server-charts {
display: flex;
flex-direction: column;
gap: 20px;
}
.server-chart-card {
width: 100%;
}
.chart-header {
display: flex;
align-items: center;
gap: 12px;
}
.chart-header h3 {
margin: 0;
font-size: 16px;
}
.chart-subtitle {
font-size: 13px;
color: #888;
}
.chart-container {
height: 220px;
padding: 8px 0;
}
</style>

View File

@@ -0,0 +1,604 @@
<template>
<div class="pattern-manage">
<div class="page-header">
<h2>패턴 관리</h2>
<Button @click="openAddModal">+ 패턴 추가</Button>
</div>
<div v-if="loading" class="loading">
<p>로딩중...</p>
</div>
<div v-else-if="patterns.length === 0" class="empty-state">
<p>등록된 패턴이 없습니다.</p>
<Button @click="openAddModal"> 패턴 추가하기</Button>
</div>
<!-- 패턴 카드 그리드 -->
<div v-else class="pattern-grid">
<Card v-for="pattern in patterns" :key="pattern.id" class="pattern-card">
<template #header>
<div class="pattern-header">
<div class="pattern-title">
<Badge :variant="getSeverityVariant(pattern.severity)" size="sm">
{{ pattern.severity }}
</Badge>
<h4>{{ pattern.name }}</h4>
</div>
<Badge :variant="pattern.active ? 'success' : 'default'" size="sm">
{{ pattern.active ? '활성' : '비활성' }}
</Badge>
</div>
</template>
<div class="pattern-body">
<div class="pattern-info">
<label>정규식</label>
<code class="regex-box">{{ pattern.regex }}</code>
</div>
<div v-if="pattern.excludeRegex" class="pattern-info">
<label>제외 정규식</label>
<code class="regex-box exclude">{{ pattern.excludeRegex }}</code>
</div>
<div class="pattern-meta">
<span class="meta-item">
<span class="meta-label">컨텍스트</span>
<span class="meta-value">{{ pattern.contextLines }}</span>
</span>
<span v-if="pattern.description" class="meta-item description">
{{ pattern.description }}
</span>
</div>
</div>
<div class="pattern-actions">
<button class="action-btn test" @click="openTestModal(pattern)" title="테스트">
🧪 테스트
</button>
<button class="action-btn edit" @click="openEditModal(pattern)" title="수정">
수정
</button>
<button class="action-btn delete" @click="confirmDelete(pattern)" title="삭제">
🗑 삭제
</button>
</div>
</Card>
</div>
<!-- 패턴 추가/수정 모달 -->
<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.excludeRegex"
label="제외 정규식"
type="textarea"
:rows="2"
placeholder="예: throws\\s+(Exception|java\\.lang\\.Exception)"
hint="이 패턴에 매칭되면 에러에서 제외됩니다. (선택)"
/>
<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>
<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 { Modal, FormInput, Button, Badge, Card } from '@/components'
import { patternApi } from '@/api'
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: '',
excludeRegex: '',
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: '',
excludeRegex: '',
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,
excludeRegex: pattern.excludeRegex || '',
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'
}
onMounted(() => {
loadPatterns()
})
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
}
.loading,
.empty-state {
text-align: center;
padding: 60px;
background: white;
border-radius: 8px;
color: #666;
}
.empty-state p {
margin-bottom: 16px;
}
/* 패턴 그리드 */
.pattern-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
}
.pattern-card {
transition: box-shadow 0.2s, transform 0.2s;
}
.pattern-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
.pattern-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pattern-title {
display: flex;
align-items: center;
gap: 10px;
}
.pattern-title h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.pattern-body {
padding: 4px 0;
}
.pattern-info {
margin-bottom: 12px;
}
.pattern-info label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.regex-box {
display: block;
font-family: 'Consolas', 'Monaco', monospace;
background: #f1f3f5;
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
word-break: break-all;
line-height: 1.4;
border-left: 3px solid #3498db;
}
.regex-box.exclude {
border-left-color: #e67e22;
background: #fef5e7;
}
.pattern-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-top: 8px;
border-top: 1px solid #eee;
font-size: 13px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.meta-label {
color: #888;
}
.meta-value {
font-weight: 500;
color: #333;
}
.meta-item.description {
flex-basis: 100%;
color: #666;
font-style: italic;
}
/* 액션 버튼 */
.pattern-actions {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #eee;
margin-top: 12px;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.action-btn.test {
background: #e8f4fd;
color: #2980b9;
}
.action-btn.test:hover {
background: #d4e9f7;
}
.action-btn.edit {
background: #fef3e2;
color: #d68910;
}
.action-btn.edit:hover {
background: #fce8c9;
}
.action-btn.delete {
background: #fdeaea;
color: #c0392b;
}
.action-btn.delete:hover {
background: #f9d6d6;
}
/* 폼 */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
/* 테스트 섹션 */
.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;
}
</style>

View File

@@ -0,0 +1,903 @@
<template>
<div class="server-manage">
<div class="page-header">
<h2>서버 관리</h2>
<Button @click="openAddModal">+ 서버 추가</Button>
</div>
<div v-if="loading" class="loading">
<p>로딩중...</p>
</div>
<div v-else-if="servers.length === 0" class="empty-state">
<p>등록된 서버가 없습니다.</p>
<Button @click="openAddModal"> 서버 추가하기</Button>
</div>
<!-- 서버 카드 그리드 -->
<div v-else 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>
</template>
<div class="server-body">
<div class="server-info-grid">
<div class="info-item">
<span class="info-icon">🌐</span>
<div class="info-content">
<span class="info-label">호스트</span>
<span class="info-value">{{ server.host }}:{{ server.port }}</span>
</div>
</div>
<div class="info-item">
<span class="info-icon">👤</span>
<div class="info-content">
<span class="info-label">사용자</span>
<span class="info-value">{{ server.username }}</span>
</div>
</div>
<div class="info-item">
<span class="info-icon">🔑</span>
<div class="info-content">
<span class="info-label">인증방식</span>
<span class="info-value">{{ server.authType === 'PASSWORD' ? '비밀번호' : '키 파일' }}</span>
</div>
</div>
<div class="info-item">
<span class="info-icon">📅</span>
<div class="info-content">
<span class="info-label">마지막 분석</span>
<span class="info-value">{{ server.lastScanAt ? formatDate(server.lastScanAt) : '-' }}</span>
</div>
</div>
</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' : (progressMap[server.id].status === 'SUCCESS' ? 'success' : 'error')">
{{ 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>
<div class="server-actions">
<button class="action-btn scan" @click="scanServer(server)" :disabled="!server.active || scanningId === server.id">
{{ scanningId === server.id ? '⏳ 분석중...' : '▶️ 분석 실행' }}
</button>
<button class="action-btn test" @click="testConnection(server)" :disabled="testingId === server.id">
{{ testingId === server.id ? '⏳' : '🔌' }}
</button>
<button class="action-btn path" @click="openLogPathModal(server)">
📁
</button>
<button class="action-btn edit" @click="openEditModal(server)">
</button>
<button class="action-btn delete" @click="confirmDelete(server)">
🗑
</button>
</div>
</Card>
</div>
<!-- 서버 추가/수정 모달 -->
<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="750px">
<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"
/>
<FormInput
v-model="logPathForm.description"
label="설명"
placeholder="예: Tomcat 로그"
/>
</div>
<Button size="sm" @click="addLogPath" :disabled="!logPathForm.path || !logPathForm.filePattern">
경로 추가
</Button>
</div>
<div class="log-path-list">
<h4>📂 등록된 경로 ({{ logPaths.length }})</h4>
<div v-if="logPaths.length === 0" class="empty-paths">
등록된 경로가 없습니다.
</div>
<div v-else class="path-cards">
<div v-for="lp in logPaths" :key="lp.id" class="path-card">
<div class="path-info">
<div class="path-main">
<code class="path-value">{{ lp.path }}</code>
<span class="path-pattern">{{ lp.filePattern }}</span>
</div>
<div class="path-meta">
<span v-if="lp.description" class="path-desc">{{ lp.description }}</span>
<Badge :variant="lp.active ? 'success' : 'default'" size="sm">
{{ lp.active ? '활성' : '비활성' }}
</Badge>
</div>
</div>
<button class="path-delete" @click="deleteLogPath(lp.id)" title="삭제">
🗑
</button>
</div>
</div>
</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 { Modal, FormInput, Button, Badge, Card } from '@/components'
import { serverApi, logPathApi, scanApi } from '@/api'
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)
// Scan
const scanningId = ref(null)
const progressMap = ref({})
// 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
}
}
// Scan Server
const scanServer = (server) => {
scanningId.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) => {
scanningId.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()
// 5초 후 진행상황 제거
setTimeout(() => {
delete progressMap.value[server.id]
}, 5000)
},
(error) => {
scanningId.value = null
progressMap.value[server.id] = {
...progressMap.value[server.id],
status: 'FAILED',
message: error
}
}
)
}
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)
}
// 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>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
}
.loading,
.empty-state {
text-align: center;
padding: 60px;
background: white;
border-radius: 8px;
color: #666;
}
.empty-state p {
margin-bottom: 16px;
}
/* 서버 그리드 */
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
}
.server-card {
transition: box-shadow 0.2s, transform 0.2s;
}
.server-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
.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;
font-weight: 600;
}
.server-body {
padding: 4px 0;
}
/* 진행 상황 */
.progress-section {
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
margin-top: 12px;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.status-text {
font-size: 13px;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 250px;
}
.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;
}
.server-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.info-icon {
font-size: 16px;
line-height: 1;
margin-top: 2px;
}
.info-content {
display: flex;
flex-direction: column;
}
.info-label {
font-size: 11px;
color: #888;
text-transform: uppercase;
}
.info-value {
font-size: 14px;
font-weight: 500;
color: #333;
}
/* 액션 버튼 */
.server-actions {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #eee;
margin-top: 12px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.action-btn.scan {
flex: 1.5;
background: #3498db;
color: white;
}
.action-btn.scan:hover:not(:disabled) {
background: #2980b9;
}
.action-btn.scan:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.action-btn.test {
flex: 0;
padding: 10px 14px;
background: #e8f8f0;
color: #27ae60;
}
.action-btn.test:hover:not(:disabled) {
background: #d4f0e3;
}
.action-btn.test:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.action-btn.path {
flex: 0;
padding: 10px 14px;
background: #e8f4fd;
color: #2980b9;
}
.action-btn.path:hover {
background: #d4e9f7;
}
.action-btn.edit {
flex: 0;
padding: 10px 14px;
background: #fef3e2;
color: #d68910;
}
.action-btn.edit:hover {
background: #fce8c9;
}
.action-btn.delete {
flex: 0;
padding: 10px 14px;
background: #fdeaea;
color: #c0392b;
}
.action-btn.delete:hover {
background: #f9d6d6;
}
/* 폼 */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
/* 로그 경로 섹션 */
.log-path-section h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: #333;
}
.log-path-form {
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 20px;
}
.log-path-inputs {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.log-path-list {
padding: 16px;
background: white;
border: 1px solid #eee;
border-radius: 8px;
}
.empty-paths {
text-align: center;
padding: 20px;
color: #888;
}
.path-cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.path-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 6px;
border-left: 3px solid #3498db;
}
.path-info {
flex: 1;
}
.path-main {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.path-value {
font-family: monospace;
font-size: 13px;
background: #e9ecef;
padding: 4px 8px;
border-radius: 4px;
}
.path-pattern {
font-size: 12px;
color: #666;
background: #fff3cd;
padding: 2px 8px;
border-radius: 4px;
}
.path-meta {
display: flex;
align-items: center;
gap: 8px;
}
.path-desc {
font-size: 12px;
color: #888;
}
.path-delete {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 8px;
border-radius: 4px;
transition: background 0.2s;
}
.path-delete:hover {
background: #fdeaea;
}
.warning-text {
color: #e74c3c;
font-size: 14px;
}
.test-result {
padding: 16px;
border-radius: 8px;
}
.test-result.success {
background: #d4edda;
border: 1px solid #c3e6cb;
}
.test-result.fail {
background: #f8d7da;
border: 1px solid #f5c6cb;
}
.test-result p {
margin: 0;
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div class="settings">
<Card>
<template #header>
<div class="card-header-content">
<h3>설정</h3>
</div>
</template>
<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="setting-section">
<h4>내보내기 설정</h4>
<FormInput
v-model="settings['export.path']"
label="내보내기 경로"
placeholder="예: C:\LogHunter\exports"
hint="리포트 파일이 저장될 기본 경로"
/>
</div>
<div class="setting-section">
<h4>데이터 관리</h4>
<FormInput
v-model="settings['retention.days']"
label="로그 보관 기간 (일)"
type="number"
hint="에러 로그 데이터 보관 기간 (0 = 무제한)"
/>
</div>
<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, onMounted } from 'vue'
import { Card, Button, FormInput } from '@/components'
import { settingApi } from '@/api'
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 {
max-width: 800px;
}
.card-header-content h3 {
margin: 0;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.settings-form {
padding: 10px 0;
}
.setting-section {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.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;
}
</style>

View File

@@ -0,0 +1 @@
import{_ as r,d as m,a as d,e as c,b as t,t as u,p as n,j as f,l as p,T as _,h as a,i as h,n as y}from"./index-D2VmGkBi.js";import"./index-Bx7gSOle.js";const v={class:"modal-header"},b={class:"modal-body"},g={key:0,class:"modal-footer"},B={__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(),m(_,{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,u(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(B,[["__scopeId","data-v-90993dd3"]]),k={__name:"Badge",props:{text:String,variant:{type:String,default:"default"}},setup(e){return(s,o)=>(a(),d("span",{class:y(["badge",`badge-${e.variant}`])},[n(s.$slots,"default",{},()=>[h(u(e.text),1)])],2))}},x=r(k,[["__scopeId","data-v-b7bd2350"]]);export{x as B,w as M};

View File

@@ -0,0 +1 @@
.dashboard-header[data-v-af1336d8]{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.dashboard-header h2[data-v-af1336d8]{margin:0}.header-actions[data-v-af1336d8]{display:flex;gap:8px}.server-grid[data-v-af1336d8]{display:grid;grid-template-columns:repeat(auto-fill,minmax(350px,1fr));gap:20px;margin-bottom:24px}.server-card[data-v-af1336d8]{transition:box-shadow .2s}.server-card[data-v-af1336d8]:hover{box-shadow:0 4px 12px #00000026}.server-header[data-v-af1336d8]{display:flex;justify-content:space-between;align-items:center}.server-title[data-v-af1336d8]{display:flex;align-items:center;gap:10px}.server-title h4[data-v-af1336d8]{margin:0;font-size:16px}.server-info[data-v-af1336d8]{margin-bottom:12px}.info-row[data-v-af1336d8]{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #f0f0f0}.info-row[data-v-af1336d8]:last-child{border-bottom:none}.info-row .label[data-v-af1336d8]{color:#666;font-size:13px}.info-row .value[data-v-af1336d8]{font-weight:500}.info-row .value.has-error[data-v-af1336d8]{color:#e74c3c}.progress-section[data-v-af1336d8]{padding:12px;background:#f8f9fa;border-radius:8px;margin-top:12px}.progress-header[data-v-af1336d8]{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.status-text[data-v-af1336d8]{font-size:13px;color:#333}.progress-bar-container[data-v-af1336d8]{height:8px;background:#e0e0e0;border-radius:4px;overflow:hidden;margin-bottom:8px}.progress-bar[data-v-af1336d8]{height:100%;background:#3498db;transition:width .3s}.progress-details[data-v-af1336d8]{display:flex;justify-content:space-between;font-size:12px;color:#666}.empty-card[data-v-af1336d8]{text-align:center}.empty-content[data-v-af1336d8]{padding:40px 20px}.empty-content p[data-v-af1336d8]{margin-bottom:16px;color:#666}.stats-section[data-v-af1336d8]{margin-bottom:24px}.stats-grid[data-v-af1336d8]{display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:20px}.stats-card h3[data-v-af1336d8]{margin:0;font-size:16px}.stats-table-wrapper[data-v-af1336d8]{max-height:300px;overflow-y:auto}.stats-table[data-v-af1336d8]{width:100%;border-collapse:collapse;font-size:13px}.stats-table th[data-v-af1336d8],.stats-table td[data-v-af1336d8]{padding:10px 8px;text-align:left;border-bottom:1px solid #eee}.stats-table th[data-v-af1336d8]{background:#f8f9fa;font-weight:600;position:sticky;top:0}.file-path-cell[data-v-af1336d8]{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace;font-size:12px}.count-col[data-v-af1336d8]{width:70px;text-align:center!important}.count-col.critical[data-v-af1336d8]{color:#9b59b6;font-weight:600}.count-col.error[data-v-af1336d8]{color:#e74c3c;font-weight:600}.count-col.warn[data-v-af1336d8]{color:#f39c12;font-weight:600}.count-col.total[data-v-af1336d8]{font-weight:700;color:#333}.recent-errors[data-v-af1336d8]{margin-top:24px}.section-header[data-v-af1336d8]{display:flex;justify-content:space-between;align-items:center}.section-header h3[data-v-af1336d8]{margin:0}.error-table[data-v-af1336d8]{width:100%;border-collapse:collapse}.error-table th[data-v-af1336d8],.error-table td[data-v-af1336d8]{padding:12px;text-align:left;border-bottom:1px solid #eee}.error-table th[data-v-af1336d8]{background:#f8f9fa;font-weight:600;font-size:13px}.error-table tbody tr[data-v-af1336d8]{cursor:pointer;transition:background .2s}.error-table tbody tr[data-v-af1336d8]:hover{background:#f8f9fa}.summary-cell[data-v-af1336d8]{max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.warning-text[data-v-af1336d8]{color:#e74c3c;font-size:13px}.error-detail[data-v-af1336d8]{max-height:60vh;overflow-y:auto}.detail-grid[data-v-af1336d8]{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px}.detail-item[data-v-af1336d8]{display:flex;flex-direction:column;gap:4px}.detail-item label[data-v-af1336d8]{font-size:12px;color:#666}.detail-section[data-v-af1336d8]{margin-bottom:16px}.detail-section label[data-v-af1336d8]{display:block;font-size:12px;color:#666;margin-bottom:8px}.summary-box[data-v-af1336d8]{padding:12px;background:#f8f9fa;border-radius:4px;font-size:14px}.context-box[data-v-af1336d8]{padding:12px;background:#2d2d2d;color:#f8f8f2;border-radius:4px;font-size:12px;line-height:1.5;overflow-x:auto;white-space:pre;margin:0}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import"./index-Bx7gSOle.js";import{_ as h,a as e,h as a,b as o,e as u,F as d,g as i,j as f,t as c,p as y,i as g}from"./index-D2VmGkBi.js";const b={class:"data-table-wrapper"},p={class:"data-table"},$={key:0,class:"actions-col"},_={key:0},D=["colspan"],S={key:1},T=["colspan"],B=["onClick"],N={key:0,class:"actions-col"},V={__name:"DataTable",props:{columns:{type:Array,required:!0},data:{type:Array,default:()=>[]},loading:{type:Boolean,default:!1},emptyText:{type:String,default:"데이터가 없습니다."}},emits:["row-click"],setup(n){const m=(t,r)=>t==null?"-":r.type==="date"&&t?new Date(t).toLocaleString("ko-KR"):r.type==="boolean"?t?"Y":"N":t;return(t,r)=>(a(),e("div",b,[o("table",p,[o("thead",null,[o("tr",null,[(a(!0),e(d,null,i(n.columns,s=>(a(),e("th",{key:s.key,style:f({width:s.width})},c(s.label),5))),128)),t.$slots.actions?(a(),e("th",$,"작업")):u("",!0)])]),o("tbody",null,[n.loading?(a(),e("tr",_,[o("td",{colspan:n.columns.length+(t.$slots.actions?1:0),class:"loading-cell"}," 로딩 중... ",8,D)])):!n.data||n.data.length===0?(a(),e("tr",S,[o("td",{colspan:n.columns.length+(t.$slots.actions?1:0),class:"empty-cell"},c(n.emptyText),9,T)])):(a(!0),e(d,{key:2},i(n.data,(s,k)=>(a(),e("tr",{key:s.id||k,onClick:l=>t.$emit("row-click",s)},[(a(!0),e(d,null,i(n.columns,l=>(a(),e("td",{key:l.key},[y(t.$slots,l.key,{row:s,value:s[l.key]},()=>[g(c(m(s[l.key],l)),1)])]))),128)),t.$slots.actions?(a(),e("td",N,[y(t.$slots,"actions",{row:s},void 0)])):u("",!0)],8,B))),128))])])]))}},A=h(V,[["__scopeId","data-v-db5e24a9"]]);export{A as D};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.layout-container[data-v-138c0e4a]{display:flex;gap:20px;height:calc(100vh - 120px)}.tree-panel[data-v-138c0e4a]{width:320px;min-width:280px;background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a;display:flex;flex-direction:column}.tree-header[data-v-138c0e4a]{display:flex;justify-content:space-between;align-items:center;padding:16px;border-bottom:1px solid #eee}.tree-header h4[data-v-138c0e4a]{margin:0;font-size:15px}.tree-content[data-v-138c0e4a]{flex:1;overflow-y:auto;padding:8px}.tree-loading[data-v-138c0e4a],.tree-empty[data-v-138c0e4a]{padding:20px;text-align:center;color:#666;font-size:13px}.tree-item[data-v-138c0e4a]{display:flex;align-items:center;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;transition:background .15s}.tree-item[data-v-138c0e4a]:hover{background:#f5f5f5}.tree-item.active[data-v-138c0e4a]{background:#e3f2fd;font-weight:500}.tree-toggle[data-v-138c0e4a]{width:16px;font-size:10px;color:#999;margin-right:4px}.tree-icon[data-v-138c0e4a]{margin-right:8px;font-size:14px}.tree-label[data-v-138c0e4a]{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-count[data-v-138c0e4a]{font-size:12px;color:#666;background:#f0f0f0;padding:2px 8px;border-radius:10px;margin-left:8px}.tree-count-detail[data-v-138c0e4a]{display:flex;gap:4px;margin-left:8px}.tree-count-detail span[data-v-138c0e4a]{font-size:11px;padding:2px 6px;border-radius:8px;font-weight:500}.tree-count-detail .critical[data-v-138c0e4a]{background:#f3e5f5;color:#9b59b6}.tree-count-detail .error[data-v-138c0e4a]{background:#ffebee;color:#e74c3c}.tree-count-detail .warn[data-v-138c0e4a]{background:#fff8e1;color:#f39c12}.tree-delete[data-v-138c0e4a]{opacity:0;background:none;border:none;cursor:pointer;padding:4px;font-size:12px;transition:opacity .15s}.tree-item:hover .tree-delete[data-v-138c0e4a]{opacity:1}.tree-delete[data-v-138c0e4a]:hover{transform:scale(1.1)}.tree-all[data-v-138c0e4a]{margin-bottom:8px;border-bottom:1px solid #eee;padding-bottom:12px}.tree-server[data-v-138c0e4a]{margin-bottom:4px}.tree-paths[data-v-138c0e4a],.tree-files[data-v-138c0e4a]{margin-left:20px}.tree-file-item[data-v-138c0e4a]{padding-left:26px}.list-panel[data-v-138c0e4a]{flex:1;min-width:0}.list-panel[data-v-138c0e4a] .card{height:100%;display:flex;flex-direction:column}.card-header-content[data-v-138c0e4a]{display:flex;justify-content:space-between;align-items:center}.card-header-content h3[data-v-138c0e4a]{margin:0;font-size:16px}.header-actions[data-v-138c0e4a]{display:flex;gap:8px}.filters[data-v-138c0e4a]{padding:16px;background:#f8f9fa;border-radius:8px;margin-bottom:16px}.filter-row[data-v-138c0e4a]{display:grid;grid-template-columns:1fr 1fr 2fr auto;gap:16px;align-items:end}.filter-actions[data-v-138c0e4a]{display:flex;gap:8px;padding-bottom:4px}.results-section[data-v-138c0e4a]{flex:1;display:flex;flex-direction:column;min-height:0}.results-header[data-v-138c0e4a]{margin-bottom:12px;color:#666;font-size:14px}.table-wrapper[data-v-138c0e4a]{flex:1;overflow:auto}.error-table[data-v-138c0e4a]{width:100%;border-collapse:collapse}.error-table th[data-v-138c0e4a],.error-table td[data-v-138c0e4a]{padding:10px 8px;text-align:left;border-bottom:1px solid #eee}.error-table th[data-v-138c0e4a]{background:#f8f9fa;font-weight:600;font-size:13px;position:sticky;top:0}.error-table tbody tr[data-v-138c0e4a]:hover{background:#fafafa}.col-time[data-v-138c0e4a]{width:150px;white-space:nowrap}.col-severity[data-v-138c0e4a]{width:80px}.col-pattern[data-v-138c0e4a]{width:100px}.col-action[data-v-138c0e4a]{width:70px;text-align:center}.empty-result[data-v-138c0e4a],.loading-result[data-v-138c0e4a]{padding:40px;text-align:center;color:#666}.pagination[data-v-138c0e4a]{display:flex;justify-content:center;align-items:center;gap:16px;padding:16px 0;border-top:1px solid #eee;margin-top:auto}.page-info[data-v-138c0e4a]{font-size:14px;color:#666}.warning-text[data-v-138c0e4a]{color:#e74c3c;font-size:13px;margin-top:8px}.error-detail[data-v-138c0e4a]{max-height:60vh;overflow-y:auto}.detail-grid[data-v-138c0e4a]{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px}.detail-item[data-v-138c0e4a]{display:flex;flex-direction:column;gap:4px}.detail-item label[data-v-138c0e4a]{font-size:12px;color:#666}.file-path[data-v-138c0e4a]{word-break:break-all;font-family:monospace;font-size:12px}.detail-section[data-v-138c0e4a]{margin-bottom:16px}.detail-section label[data-v-138c0e4a]{display:block;font-size:12px;color:#666;margin-bottom:8px}.summary-box[data-v-138c0e4a]{padding:12px;background:#f8f9fa;border-radius:4px;font-size:14px}.context-box[data-v-138c0e4a]{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:250px}

View File

@@ -0,0 +1 @@
import"./index-Bx7gSOle.js";import{_ as i,c as s,a as t,h as l,e as o,i as c,t as n,F as m,g as y}from"./index-D2VmGkBi.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};

View File

@@ -0,0 +1 @@
.card-header-content[data-v-fc6fccb5]{display:flex;justify-content:space-between;align-items:center}.card-header-content h3[data-v-fc6fccb5]{margin:0}.action-buttons[data-v-fc6fccb5]{display:flex;gap:4px}.regex-code[data-v-fc6fccb5]{font-family:monospace;background:#f1f3f4;padding:2px 6px;border-radius:3px;font-size:12px}.form-group[data-v-fc6fccb5]{margin-bottom:16px}.form-group label[data-v-fc6fccb5]{display:flex;align-items:center;gap:8px;cursor:pointer}.test-section[data-v-fc6fccb5]{display:flex;flex-direction:column;gap:16px}.test-pattern label[data-v-fc6fccb5]{display:block;font-weight:500;margin-bottom:6px}.regex-display[data-v-fc6fccb5]{display:block;font-family:monospace;background:#f8f9fa;padding:12px;border-radius:4px;font-size:13px;word-break:break-all}.test-result[data-v-fc6fccb5]{padding:16px;border-radius:8px;margin-top:8px}.test-result.success[data-v-fc6fccb5]{background:#d4edda;border:1px solid #c3e6cb}.test-result.fail[data-v-fc6fccb5]{background:#f8d7da;border:1px solid #f5c6cb}.test-result h4[data-v-fc6fccb5]{margin:0 0 12px}.test-result p[data-v-fc6fccb5]{margin:0}.match-info[data-v-fc6fccb5]{margin-top:8px}.match-info label[data-v-fc6fccb5]{font-weight:500;margin-right:8px}.match-info code[data-v-fc6fccb5]{background:#0000001a;padding:2px 6px;border-radius:3px}.error-msg[data-v-fc6fccb5]{color:#721c24}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -0,0 +1 @@
import{_ as S,r as v,o as F,a as p,f as a,w as i,u as n,h as m,l as B,b as t,i as c}from"./index-D2VmGkBi.js";import{C as V,b,B as y}from"./index-Bx7gSOle.js";import{F as u}from"./FormInput-DHZMRclc.js";const k={class:"settings"},w={key:0,class:"loading"},C={class:"setting-section"},U={class:"setting-section"},h={class:"setting-section"},M={class:"setting-section"},z={class:"form-actions"},A={__name:"Settings",setup(N){const d=v(!1),r=v(!1),l=v({}),f={"server.port":"8080","export.path":"./exports","retention.days":"90","scan.timeout":"30","scan.maxFileSize":"100"},g=async()=>{d.value=!0;try{const o=await b.getAllAsMap();l.value={...f,...o}}catch(o){console.error("Failed to load settings:",o),l.value={...f}}finally{d.value=!1}},x=async()=>{r.value=!0;try{for(const[o,e]of Object.entries(l.value))await b.save({key:o,value:String(e)});alert("설정이 저장되었습니다.")}catch(o){console.error("Failed to save settings:",o),alert("설정 저장에 실패했습니다.")}finally{r.value=!1}};return F(()=>{g()}),(o,e)=>(m(),p("div",k,[a(n(V),null,{header:i(()=>[...e[5]||(e[5]=[t("div",{class:"card-header-content"},[t("h3",null,"설정")],-1)])]),default:i(()=>[d.value?(m(),p("div",w,"로딩중...")):(m(),p("form",{key:1,onSubmit:B(x,["prevent"]),class:"settings-form"},[t("div",C,[e[6]||(e[6]=t("h4",null,"일반 설정",-1)),a(n(u),{modelValue:l.value["server.port"],"onUpdate:modelValue":e[0]||(e[0]=s=>l.value["server.port"]=s),label:"서버 포트",type:"number",hint:"애플리케이션이 실행될 포트 번호 (기본: 8080)"},null,8,["modelValue"])]),t("div",U,[e[7]||(e[7]=t("h4",null,"내보내기 설정",-1)),a(n(u),{modelValue:l.value["export.path"],"onUpdate:modelValue":e[1]||(e[1]=s=>l.value["export.path"]=s),label:"내보내기 경로",placeholder:"예: C:\\LogHunter\\exports",hint:"리포트 파일이 저장될 기본 경로"},null,8,["modelValue"])]),t("div",h,[e[8]||(e[8]=t("h4",null,"데이터 관리",-1)),a(n(u),{modelValue:l.value["retention.days"],"onUpdate:modelValue":e[2]||(e[2]=s=>l.value["retention.days"]=s),label:"로그 보관 기간 (일)",type:"number",hint:"에러 로그 데이터 보관 기간 (0 = 무제한)"},null,8,["modelValue"])]),t("div",M,[e[9]||(e[9]=t("h4",null,"스캔 설정",-1)),a(n(u),{modelValue:l.value["scan.timeout"],"onUpdate:modelValue":e[3]||(e[3]=s=>l.value["scan.timeout"]=s),label:"스캔 타임아웃 (초)",type:"number",hint:"SFTP 연결 및 파일 다운로드 타임아웃"},null,8,["modelValue"]),a(n(u),{modelValue:l.value["scan.maxFileSize"],"onUpdate:modelValue":e[4]||(e[4]=s=>l.value["scan.maxFileSize"]=s),label:"최대 파일 크기 (MB)",type:"number",hint:"분석할 로그 파일의 최대 크기"},null,8,["modelValue"])]),t("div",z,[a(n(y),{onClick:g,variant:"secondary"},{default:i(()=>[...e[10]||(e[10]=[c("초기화",-1)])]),_:1}),a(n(y),{type:"submit",loading:r.value},{default:i(()=>[...e[11]||(e[11]=[c("저장",-1)])]),_:1},8,["loading"])])],32))]),_:1}),a(n(V),{class:"app-info"},{header:i(()=>[...e[12]||(e[12]=[t("h3",null,"애플리케이션 정보",-1)])]),default:i(()=>[e[13]||(e[13]=t("div",{class:"info-list"},[t("div",{class:"info-item"},[t("span",{class:"label"},"버전"),t("span",{class:"value"},"1.0.0")]),t("div",{class:"info-item"},[t("span",{class:"label"},"프레임워크"),t("span",{class:"value"},"Spring Boot 3.2 + Vue 3")]),t("div",{class:"info-item"},[t("span",{class:"label"},"데이터베이스"),t("span",{class:"value"},"SQLite (./data/loghunter.db)")])],-1))]),_:1})]))}},T=S(A,[["__scopeId","data-v-fdca948e"]]);export{T as default};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.data-table-wrapper[data-v-db5e24a9]{overflow-x:auto}.data-table[data-v-db5e24a9]{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px #0000001a}.data-table th[data-v-db5e24a9],.data-table td[data-v-db5e24a9]{padding:12px 16px;text-align:left;border-bottom:1px solid #eee}.data-table th[data-v-db5e24a9]{background:#f8f9fa;font-weight:600;color:#495057}.data-table tbody tr[data-v-db5e24a9]:hover{background:#f8f9fa}.data-table tbody tr:last-child td[data-v-db5e24a9]{border-bottom:none}.actions-col[data-v-db5e24a9]{width:120px;text-align:center}.loading-cell[data-v-db5e24a9],.empty-cell[data-v-db5e24a9]{text-align:center;color:#6c757d;padding:40px!important}.modal-overlay[data-v-90993dd3]{position:fixed;top:0;left:0;right:0;bottom:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal[data-v-90993dd3]{background:#fff;border-radius:8px;max-height:90vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 4px 20px #00000026}.modal-header[data-v-90993dd3]{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #eee}.modal-header h3[data-v-90993dd3]{margin:0;font-size:1.1rem}.close-btn[data-v-90993dd3]{background:none;border:none;font-size:1.5rem;cursor:pointer;color:#666;padding:0;line-height:1}.close-btn[data-v-90993dd3]:hover{color:#333}.modal-body[data-v-90993dd3]{padding:20px;overflow-y:auto;flex:1}.modal-footer[data-v-90993dd3]{padding:16px 20px;border-top:1px solid #eee;display:flex;justify-content:flex-end;gap:8px}.form-group[data-v-45f49038]{margin-bottom:16px}label[data-v-45f49038]{display:block;margin-bottom:6px;font-weight:500;color:#333}.required[data-v-45f49038]{color:#e74c3c}.form-input[data-v-45f49038]{width:100%;padding:10px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px;transition:border-color .2s}.form-input[data-v-45f49038]:focus{outline:none;border-color:#3498db}.form-input[data-v-45f49038]:disabled{background:#f5f5f5;cursor:not-allowed}textarea.form-input[data-v-45f49038]{resize:vertical}select.form-input[data-v-45f49038]{cursor:pointer}.error-text[data-v-45f49038]{display:block;margin-top:4px;font-size:12px;color:#e74c3c}.hint-text[data-v-45f49038]{display:block;margin-top:4px;font-size:12px;color:#6c757d}.btn[data-v-e5da414f]{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:10px 16px;border:none;border-radius:4px;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s}.btn[data-v-e5da414f]:disabled{opacity:.6;cursor:not-allowed}.btn-sm[data-v-e5da414f]{padding:6px 12px;font-size:12px}.btn-lg[data-v-e5da414f]{padding:12px 24px;font-size:16px}.btn-primary[data-v-e5da414f]{background:#3498db;color:#fff}.btn-primary[data-v-e5da414f]:hover:not(:disabled){background:#2980b9}.btn-secondary[data-v-e5da414f]{background:#6c757d;color:#fff}.btn-secondary[data-v-e5da414f]:hover:not(:disabled){background:#5a6268}.btn-danger[data-v-e5da414f]{background:#e74c3c;color:#fff}.btn-danger[data-v-e5da414f]:hover:not(:disabled){background:#c0392b}.btn-success[data-v-e5da414f]{background:#27ae60;color:#fff}.btn-success[data-v-e5da414f]:hover:not(:disabled){background:#1e8449}.btn-warning[data-v-e5da414f]{background:#f39c12;color:#fff}.btn-warning[data-v-e5da414f]:hover:not(:disabled){background:#d68910}.spinner[data-v-e5da414f]{width:14px;height:14px;border:2px solid transparent;border-top-color:currentColor;border-radius:50%;animation:spin-e5da414f .8s linear infinite}@keyframes spin-e5da414f{to{transform:rotate(360deg)}}.badge[data-v-b7bd2350]{display:inline-block;padding:4px 8px;font-size:12px;font-weight:500;border-radius:4px}.badge-default[data-v-b7bd2350]{background:#e9ecef;color:#495057}.badge-critical[data-v-b7bd2350],.badge-error[data-v-b7bd2350]{background:#e74c3c;color:#fff}.badge-warn[data-v-b7bd2350]{background:#f39c12;color:#fff}.badge-success[data-v-b7bd2350]{background:#27ae60;color:#fff}.badge-info[data-v-b7bd2350]{background:#3498db;color:#fff}.card[data-v-2f260fa2]{background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a;overflow:hidden}.card-header[data-v-2f260fa2]{padding:16px 20px;border-bottom:1px solid #eee}.card-header h3[data-v-2f260fa2]{margin:0;font-size:1.1rem;color:#333}.card-body[data-v-2f260fa2]{padding:20px}.card-footer[data-v-2f260fa2]{padding:16px 20px;border-top:1px solid #eee;background:#f8f9fa}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}#app{min-height:100vh;background:#f5f5f5}.header{background:#2c3e50;color:#fff;padding:1rem 2rem;display:flex;align-items:center;gap:2rem}.header h1{font-size:1.5rem;margin:0}.header nav{display:flex;gap:1rem}.header nav a{color:#ecf0f1;text-decoration:none;padding:.5rem 1rem;border-radius:4px;transition:background .2s}.header nav a:hover{background:#34495e}.header nav a.router-link-active{background:#3498db}.main{padding:2rem;max-width:1400px;margin:0 auto}

View File

@@ -0,0 +1,27 @@
<!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">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>LogHunter</title>
<style>
* {
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-D2VmGkBi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GLdO36rE.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

25
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
},
build: {
outDir: '../src/main/resources/static',
emptyOutDir: true
}
})

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

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

248
gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
settings.gradle Normal file
View File

@@ -0,0 +1 @@
rootProject.name = 'log-hunter'

View File

@@ -0,0 +1,29 @@
package research.loghunter;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import java.awt.Desktop;
import java.net.URI;
@SpringBootApplication
public class LogHunterApplication {
public static void main(String[] args) {
SpringApplication.run(LogHunterApplication.class, args);
}
@EventListener(ApplicationReadyEvent.class)
public void openBrowser() {
String url = "http://localhost:8080";
try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(new URI(url));
}
} catch (Exception e) {
System.out.println("브라우저를 자동으로 열 수 없습니다. 직접 " + url + " 에 접속해주세요.");
}
}
}

View File

@@ -0,0 +1,61 @@
package research.loghunter.config;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 데이터베이스 및 앱 디렉토리 초기화 설정
*
* 앱 실행 시 필요한 디렉토리를 자동으로 생성합니다.
* - ~/.loghunter/data/ : SQLite DB 파일
* - ~/.loghunter/exports/ : 내보내기 파일
*/
@Slf4j
@Configuration
public class DatabaseConfig {
@Value("${app.base-path}")
private String basePath;
@Value("${app.export.path}")
private String exportPath;
@PostConstruct
public void init() {
try {
// 기본 디렉토리 생성
Path baseDir = Paths.get(basePath);
createDirectoryIfNotExists(baseDir);
// 데이터 디렉토리 생성
Path dataDir = baseDir.resolve("data");
createDirectoryIfNotExists(dataDir);
// 내보내기 디렉토리 생성
Path exportsDir = Paths.get(exportPath);
createDirectoryIfNotExists(exportsDir);
log.info("LogHunter 디렉토리 초기화 완료: {}", basePath);
log.info(" - 데이터: {}", dataDir);
log.info(" - 내보내기: {}", exportsDir);
} catch (IOException e) {
log.error("디렉토리 생성 실패: {}", e.getMessage());
throw new RuntimeException("LogHunter 디렉토리 초기화 실패", e);
}
}
private void createDirectoryIfNotExists(Path dir) throws IOException {
if (!Files.exists(dir)) {
Files.createDirectories(dir);
log.info("디렉토리 생성: {}", dir);
}
}
}

View File

@@ -0,0 +1,39 @@
package research.loghunter.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(String resourcePath, Resource location) throws IOException {
Resource requestedResource = location.createRelative(resourcePath);
// 실제 파일이 존재하면 반환
if (requestedResource.exists() && requestedResource.isReadable()) {
return requestedResource;
}
// API 요청이 아니면 index.html 반환 (SPA 라우팅)
if (!resourcePath.startsWith("api/")) {
return new ClassPathResource("/static/index.html");
}
return null;
}
});
}
}

View File

@@ -0,0 +1,94 @@
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.dto.FileTreeDto;
import research.loghunter.service.ErrorLogService;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@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) String filePath,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return ResponseEntity.ok(errorLogService.search(
serverId, patternId, severity, filePath, 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));
}
/**
* 트리 구조 데이터 조회 (서버 > 로그경로 > 파일)
*/
@GetMapping("/tree")
public ResponseEntity<List<FileTreeDto.ServerNode>> getFileTree() {
return ResponseEntity.ok(errorLogService.getFileTree());
}
/**
* 서버별 파일 목록 조회
*/
@GetMapping("/files")
public ResponseEntity<List<String>> getFilesByServer(
@RequestParam(required = false) Long serverId
) {
return ResponseEntity.ok(errorLogService.getFilesByServer(serverId));
}
/**
* 선택한 에러 삭제
*/
@DeleteMapping("/batch")
public ResponseEntity<Map<String, Object>> deleteByIds(@RequestBody List<Long> ids) {
int deleted = errorLogService.deleteByIds(ids);
return ResponseEntity.ok(Map.of(
"success", true,
"deleted", deleted
));
}
/**
* 파일별 에러 및 스캔기록 삭제
*/
@DeleteMapping("/by-file")
public ResponseEntity<Map<String, Object>> deleteByFile(
@RequestParam Long serverId,
@RequestParam String filePath
) {
Map<String, Object> result = errorLogService.deleteFileAndErrors(serverId, filePath);
return ResponseEntity.ok(result);
}
}

View File

@@ -0,0 +1,70 @@
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) String filePath,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String keyword
) {
ExportService.ExportRequest request = new ExportService.ExportRequest(
serverId, patternId, severity, filePath, 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) String filePath,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String keyword
) {
ExportService.ExportRequest request = new ExportService.ExportRequest(
serverId, patternId, severity, filePath, startDate, endDate, keyword);
ExportService.ExportResult result = exportService.exportTxt(request);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + encodeFilename(result.filename()) + "\"")
.contentType(MediaType.TEXT_PLAIN)
.body(result.content());
}
private String encodeFilename(String filename) {
return URLEncoder.encode(filename, StandardCharsets.UTF_8)
.replace("+", "%20");
}
}

View File

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

View File

@@ -0,0 +1,221 @@
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.Map;
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));
}
/**
* 분석 결과 초기화 (서버별)
*/
@DeleteMapping("/reset/{serverId}")
public ResponseEntity<Map<String, Object>> resetScanData(@PathVariable Long serverId) {
ScanService.ResetResult result = scanService.resetScanData(serverId);
return ResponseEntity.ok(Map.of(
"success", true,
"deletedErrors", result.deletedErrors(),
"deletedFiles", result.deletedFiles(),
"deletedHistories", result.deletedHistories()
));
}
/**
* 전체 분석 결과 초기화
*/
@DeleteMapping("/reset-all")
public ResponseEntity<Map<String, Object>> resetAllScanData() {
ScanService.ResetResult result = scanService.resetAllScanData();
return ResponseEntity.ok(Map.of(
"success", true,
"deletedErrors", result.deletedErrors(),
"deletedFiles", result.deletedFiles(),
"deletedHistories", result.deletedHistories()
));
}
/**
* 파일별 에러 통계 조회
*/
@GetMapping("/stats/by-file")
public ResponseEntity<List<ScanService.FileErrorStats>> getErrorStatsByFile(
@RequestParam(required = false) Long serverId) {
return ResponseEntity.ok(scanService.getErrorStatsByFile(serverId));
}
/**
* 서버별 에러 통계 조회
*/
@GetMapping("/stats/by-server")
public ResponseEntity<List<ScanService.ServerErrorStats>> getErrorStatsByServer() {
return ResponseEntity.ok(scanService.getErrorStatsByServer());
}
/**
* 패턴별 에러 통계 조회
*/
@GetMapping("/stats/by-pattern")
public ResponseEntity<List<ScanService.PatternErrorStats>> getErrorStatsByPattern(
@RequestParam(required = false) Long serverId) {
return ResponseEntity.ok(scanService.getErrorStatsByPattern(serverId));
}
/**
* 대시보드용: 서버별 최근 N일 일별 통계
*/
@GetMapping("/stats/daily-by-server")
public ResponseEntity<List<ScanService.ServerDailyStats>> getDailyStatsByServer(
@RequestParam(defaultValue = "30") int days) {
return ResponseEntity.ok(scanService.getDailyStatsByServer(days));
}
/**
* 월별현황용: 서버별 해당 월 일별 통계
*/
@GetMapping("/stats/monthly-by-server")
public ResponseEntity<List<ScanService.ServerDailyStats>> getMonthlyStatsByServer(
@RequestParam int year,
@RequestParam int month) {
return ResponseEntity.ok(scanService.getMonthlyStatsByServer(year, month));
}
/**
* 일별현황용: 서버별 해당 날짜 5분 단위 통계
*/
@GetMapping("/stats/time-by-server")
public ResponseEntity<List<ScanService.ServerTimeStats>> getTimeStatsByServer(
@RequestParam String date,
@RequestParam(defaultValue = "5") int intervalMinutes) {
java.time.LocalDate localDate = java.time.LocalDate.parse(date);
return ResponseEntity.ok(scanService.getTimeStatsByServer(localDate, intervalMinutes));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
package research.loghunter.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
public class FileTreeDto {
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ServerNode {
private Long serverId;
private String serverName;
private int totalErrorCount;
private List<PathNode> paths;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class PathNode {
private String path;
private int totalErrorCount;
private List<FileNode> files;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class FileNode {
private String filePath;
private String fileName;
private int errorCount;
private int criticalCount;
private int errorLevelCount;
private int warnCount;
}
}

View File

@@ -0,0 +1,21 @@
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 excludeRegex; // 제외 정규식
private String severity;
private Integer contextLines;
private String description;
private Boolean active;
private LocalDateTime createdAt;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
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(length = 1000)
private String excludeRegex; // 제외 정규식 (매칭되면 에러에서 제외)
@Column(nullable = false)
private String severity; // CRITICAL, ERROR, WARN
@Column(nullable = false)
private Integer contextLines; // 에러 전후 캡처할 라인 수
private String description; // 설명
@Column(nullable = false)
private Boolean active;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
if (active == null) active = true;
if (contextLines == null) contextLines = 5;
if (severity == null) severity = "ERROR";
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More