Compare commits
11 Commits
03837d21fd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d0ba4161e5 | |||
| a6a5a291ac | |||
| 9bc8acc905 | |||
| 057a1bad41 | |||
| 66e8e21302 | |||
| 57c3eea429 | |||
| 716cf63f73 | |||
| ceec1ad7a9 | |||
| 9ac66fbc73 | |||
| 0f4f74cbaf | |||
| 37f417f478 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/.gradle/
|
||||
/build/
|
||||
BIN
.gradle/8.5/checksums/checksums.lock
Normal file
BIN
.gradle/8.5/checksums/checksums.lock
Normal file
Binary file not shown.
BIN
.gradle/8.5/checksums/sha1-checksums.bin
Normal file
BIN
.gradle/8.5/checksums/sha1-checksums.bin
Normal file
Binary file not shown.
BIN
.gradle/8.5/dependencies-accessors/dependencies-accessors.lock
Normal file
BIN
.gradle/8.5/dependencies-accessors/dependencies-accessors.lock
Normal file
Binary file not shown.
0
.gradle/8.5/dependencies-accessors/gc.properties
Normal file
0
.gradle/8.5/dependencies-accessors/gc.properties
Normal file
BIN
.gradle/8.5/executionHistory/executionHistory.bin
Normal file
BIN
.gradle/8.5/executionHistory/executionHistory.bin
Normal file
Binary file not shown.
BIN
.gradle/8.5/executionHistory/executionHistory.lock
Normal file
BIN
.gradle/8.5/executionHistory/executionHistory.lock
Normal file
Binary file not shown.
BIN
.gradle/8.5/fileChanges/last-build.bin
Normal file
BIN
.gradle/8.5/fileChanges/last-build.bin
Normal file
Binary file not shown.
BIN
.gradle/8.5/fileHashes/fileHashes.bin
Normal file
BIN
.gradle/8.5/fileHashes/fileHashes.bin
Normal file
Binary file not shown.
BIN
.gradle/8.5/fileHashes/fileHashes.lock
Normal file
BIN
.gradle/8.5/fileHashes/fileHashes.lock
Normal file
Binary file not shown.
BIN
.gradle/8.5/fileHashes/resourceHashesCache.bin
Normal file
BIN
.gradle/8.5/fileHashes/resourceHashesCache.bin
Normal file
Binary file not shown.
0
.gradle/8.5/gc.properties
Normal file
0
.gradle/8.5/gc.properties
Normal file
BIN
.gradle/buildOutputCleanup/buildOutputCleanup.lock
Normal file
BIN
.gradle/buildOutputCleanup/buildOutputCleanup.lock
Normal file
Binary file not shown.
2
.gradle/buildOutputCleanup/cache.properties
Normal file
2
.gradle/buildOutputCleanup/cache.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
#Tue Jan 06 21:43:32 KST 2026
|
||||
gradle.version=8.5
|
||||
BIN
.gradle/buildOutputCleanup/outputFiles.bin
Normal file
BIN
.gradle/buildOutputCleanup/outputFiles.bin
Normal file
Binary file not shown.
BIN
.gradle/file-system.probe
Normal file
BIN
.gradle/file-system.probe
Normal file
Binary file not shown.
0
.gradle/vcs-1/gc.properties
Normal file
0
.gradle/vcs-1/gc.properties
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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
6
.idea/PMDPlugin.xml
generated
Normal 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
18
.idea/compiler.xml
generated
Normal 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
12
.idea/dataSources.xml
generated
Normal 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
6
.idea/data_source_mapping.xml
generated
Normal 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
16
.idea/gradle.xml
generated
Normal 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
8
.idea/log-hunter.iml
generated
Normal 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
10
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
8
.idea/modules/log-hunter.main.iml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
25
HELP.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Getting Started
|
||||
|
||||
### Reference Documentation
|
||||
|
||||
For further reference, please consider the following sections:
|
||||
|
||||
* [Official Gradle documentation](https://docs.gradle.org)
|
||||
* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/4.0.1/gradle-plugin)
|
||||
* [Create an OCI image](https://docs.spring.io/spring-boot/4.0.1/gradle-plugin/packaging-oci-image.html)
|
||||
* [Spring Web](https://docs.spring.io/spring-boot/4.0.1/reference/web/servlet.html)
|
||||
|
||||
### Guides
|
||||
|
||||
The following guides illustrate how to use some features concretely:
|
||||
|
||||
* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/)
|
||||
* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/)
|
||||
* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/)
|
||||
|
||||
### Additional Links
|
||||
|
||||
These additional references should also help you:
|
||||
|
||||
* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle)
|
||||
|
||||
208
README.md
208
README.md
@@ -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
256
TEST_GUIDE.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# LogHunter 테스트 가이드
|
||||
|
||||
## 1. 사전 요구사항
|
||||
|
||||
| 항목 | 버전 | 확인 명령어 |
|
||||
|------|------|-------------|
|
||||
| Java | 17 이상 | `java -version` |
|
||||
| Node.js | 18 이상 | `node -v` |
|
||||
| npm | 9 이상 | `npm -v` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 프로젝트 실행
|
||||
|
||||
### 2-1. Frontend 빌드
|
||||
|
||||
```bash
|
||||
cd /Users/coziny/devs/osolit-research/workspace/log-hunter/frontend
|
||||
|
||||
# 의존성 설치 (최초 1회)
|
||||
npm install
|
||||
|
||||
# 빌드 (결과물이 ../src/main/resources/static 으로 복사됨)
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 2-2. Backend 실행
|
||||
|
||||
```bash
|
||||
cd /Users/coziny/devs/osolit-research/workspace/log-hunter
|
||||
|
||||
# Gradle Wrapper로 실행
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
### 2-3. 접속
|
||||
|
||||
- 실행 시 브라우저가 자동으로 열림
|
||||
- 수동 접속: http://localhost:8080
|
||||
|
||||
---
|
||||
|
||||
## 3. 테스트 시나리오
|
||||
|
||||
### 3-1. 패턴 등록 (먼저!)
|
||||
|
||||
1. **패턴 관리** 메뉴 클릭
|
||||
2. **+ 패턴 추가** 클릭
|
||||
3. 샘플 패턴 입력:
|
||||
|
||||
| 패턴명 | 정규식 | 심각도 | 컨텍스트 |
|
||||
|--------|--------|--------|----------|
|
||||
| Exception | `Exception\|Error` | ERROR | 5 |
|
||||
| NullPointer | `NullPointerException` | CRITICAL | 5 |
|
||||
| WARN 로그 | `\[WARN\]` | WARN | 3 |
|
||||
| Tomcat 에러 | `SEVERE\|FATAL` | CRITICAL | 5 |
|
||||
|
||||
4. **테스트** 버튼으로 정규식 검증 가능
|
||||
|
||||
### 3-2. 서버 등록
|
||||
|
||||
1. **서버 관리** 메뉴 클릭
|
||||
2. **+ 서버 추가** 클릭
|
||||
3. 서버 정보 입력:
|
||||
|
||||
**비밀번호 인증 예시:**
|
||||
```
|
||||
서버명: 운영서버1
|
||||
호스트: 192.168.1.100
|
||||
포트: 22
|
||||
사용자명: username
|
||||
인증방식: 비밀번호
|
||||
비밀번호: ********
|
||||
```
|
||||
|
||||
**키파일 인증 예시:**
|
||||
```
|
||||
서버명: 개발서버
|
||||
호스트: 10.0.0.50
|
||||
포트: 22
|
||||
사용자명: deploy
|
||||
인증방식: 키 파일
|
||||
키 파일 경로: C:\Users\사용자\.ssh\id_rsa
|
||||
Passphrase: (없으면 비워둠)
|
||||
```
|
||||
|
||||
4. **테스트** 버튼으로 연결 확인
|
||||
|
||||
### 3-3. 로그 경로 등록
|
||||
|
||||
1. 서버 목록에서 **경로** 버튼 클릭
|
||||
2. 로그 경로 추가:
|
||||
|
||||
| 경로 | 파일 패턴 | 설명 |
|
||||
|------|-----------|------|
|
||||
| `/var/log/tomcat/` | `catalina.*.log` | Tomcat 로그 |
|
||||
| `/var/log/` | `*.log` | 시스템 로그 |
|
||||
| `/app/logs/` | `application-*.log` | 애플리케이션 로그 |
|
||||
|
||||
### 3-4. 분석 실행
|
||||
|
||||
1. **대시보드** 메뉴 클릭
|
||||
2. 개별 서버 **분석 실행** 또는 **전체 분석 실행**
|
||||
3. 진행상황 확인 (프로그레스 바)
|
||||
4. 완료 후 **에러 이력**에서 결과 확인
|
||||
|
||||
### 3-5. 결과 확인 및 내보내기
|
||||
|
||||
1. **에러 이력** 메뉴 클릭
|
||||
2. 필터로 검색 (서버, 심각도, 기간 등)
|
||||
3. **상세** 버튼으로 컨텍스트 확인
|
||||
4. **HTML 내보내기** 또는 **TXT 내보내기**
|
||||
|
||||
---
|
||||
|
||||
## 4. 단일 JAR 빌드
|
||||
|
||||
### 4-1. 전체 빌드 (Frontend + Backend)
|
||||
|
||||
```bash
|
||||
cd /Users/coziny/devs/osolit-research/workspace/log-hunter
|
||||
|
||||
# Frontend 빌드
|
||||
cd frontend && npm run build && cd ..
|
||||
|
||||
# JAR 빌드
|
||||
./gradlew clean bootJar
|
||||
```
|
||||
|
||||
### 4-2. 빌드 결과
|
||||
|
||||
```
|
||||
build/libs/log-hunter-0.0.1-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
### 4-3. JAR 실행
|
||||
|
||||
```bash
|
||||
java -jar build/libs/log-hunter-0.0.1-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Windows 실행 스크립트
|
||||
|
||||
### run.bat 생성
|
||||
|
||||
```batch
|
||||
@echo off
|
||||
title LogHunter
|
||||
echo LogHunter 시작 중...
|
||||
java -jar log-hunter.jar
|
||||
pause
|
||||
```
|
||||
|
||||
### 배포 폴더 구조
|
||||
|
||||
```
|
||||
LogHunter/
|
||||
├── log-hunter.jar
|
||||
├── run.bat
|
||||
└── data/ <- 자동 생성됨 (SQLite DB)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 트러블슈팅
|
||||
|
||||
### 포트 충돌 (8080 사용 중)
|
||||
|
||||
```bash
|
||||
# application.yml에서 포트 변경
|
||||
server:
|
||||
port: 9090
|
||||
```
|
||||
|
||||
또는 실행 시 지정:
|
||||
```bash
|
||||
java -jar log-hunter.jar --server.port=9090
|
||||
```
|
||||
|
||||
### SFTP 연결 실패
|
||||
|
||||
- 호스트/포트 확인
|
||||
- 방화벽 확인 (22번 포트)
|
||||
- 사용자명/비밀번호 확인
|
||||
- 키파일 경로 확인 (Windows: `C:\Users\...`, 절대경로)
|
||||
|
||||
### 한글 깨짐
|
||||
|
||||
- 로그 파일 인코딩이 UTF-8인지 확인
|
||||
- 필요시 서버에서 `file -i 파일명`으로 인코딩 확인
|
||||
|
||||
### DB 초기화 (데이터 삭제)
|
||||
|
||||
```bash
|
||||
# data 폴더의 DB 파일 삭제
|
||||
rm -rf data/loghunter.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 개발 모드 (Hot Reload)
|
||||
|
||||
### Frontend 개발 서버
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
- http://localhost:5173 에서 실행
|
||||
- API 프록시 설정으로 백엔드(8080)와 연동됨
|
||||
|
||||
### Backend만 재시작
|
||||
|
||||
```bash
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. API 테스트 (curl 예시)
|
||||
|
||||
```bash
|
||||
# 서버 목록 조회
|
||||
curl http://localhost:8080/api/servers
|
||||
|
||||
# 패턴 목록 조회
|
||||
curl http://localhost:8080/api/patterns
|
||||
|
||||
# 연결 테스트
|
||||
curl -X POST http://localhost:8080/api/servers/1/test-connection
|
||||
|
||||
# 스캔 실행 (동기)
|
||||
curl -X POST http://localhost:8080/api/scan/execute/1
|
||||
|
||||
# 에러 로그 검색
|
||||
curl "http://localhost:8080/api/error-logs?page=0&size=10"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
- [x] Java 17+ 설치 확인
|
||||
- [x] Node.js 18+ 설치 확인
|
||||
- [x] Frontend 빌드 완료 (2025-01-06 19:23 확인)
|
||||
- [x] Backend 실행 성공 (2025-01-06 19:23 확인)
|
||||
- [x] 브라우저 접속 확인 (localhost:8080)
|
||||
- [ ] 패턴 등록
|
||||
- [ ] 서버 등록
|
||||
- [ ] 연결 테스트 성공
|
||||
- [ ] 로그 경로 등록
|
||||
- [ ] 분석 실행
|
||||
- [ ] 에러 검출 확인
|
||||
- [ ] 내보내기 테스트
|
||||
44
build.gradle
Normal file
44
build.gradle
Normal 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
1
data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# SQLite DB 파일이 이 폴더에 생성됩니다.
|
||||
BIN
data/loghunter.db
Normal file
BIN
data/loghunter.db
Normal file
Binary file not shown.
16
frontend/.gitignore
vendored
Normal file
16
frontend/.gitignore
vendored
Normal 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
26
frontend/index.html
Normal 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
1598
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal 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
84
frontend/src/App.vue
Normal 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
173
frontend/src/api/index.js
Normal 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
|
||||
56
frontend/src/components/Badge.vue
Normal file
56
frontend/src/components/Badge.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<span :class="['badge', `badge-${variant}`]">
|
||||
<slot>{{ text }}</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: String,
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
// default, critical, error, warn, success, info
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.badge-default {
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.badge-critical {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
background: #f39c12;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
129
frontend/src/components/Button.vue
Normal file
129
frontend/src/components/Button.vue
Normal 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>
|
||||
51
frontend/src/components/Card.vue
Normal file
51
frontend/src/components/Card.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div v-if="title || $slots.header" class="card-header">
|
||||
<slot name="header">
|
||||
<h3>{{ title }}</h3>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="card-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: String
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #eee;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
120
frontend/src/components/DataTable.vue
Normal file
120
frontend/src/components/DataTable.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="data-table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="col in columns" :key="col.key" :style="{ width: col.width }">
|
||||
{{ col.label }}
|
||||
</th>
|
||||
<th v-if="$slots.actions" class="actions-col">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td :colspan="columns.length + ($slots.actions ? 1 : 0)" class="loading-cell">
|
||||
로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="!data || data.length === 0">
|
||||
<td :colspan="columns.length + ($slots.actions ? 1 : 0)" class="empty-cell">
|
||||
{{ emptyText }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="(row, idx) in data" :key="row.id || idx" @click="$emit('row-click', row)">
|
||||
<td v-for="col in columns" :key="col.key">
|
||||
<slot :name="col.key" :row="row" :value="row[col.key]">
|
||||
{{ formatValue(row[col.key], col) }}
|
||||
</slot>
|
||||
</td>
|
||||
<td v-if="$slots.actions" class="actions-col">
|
||||
<slot name="actions" :row="row"></slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true
|
||||
// { key: 'name', label: '이름', width: '100px', type: 'date' }
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '데이터가 없습니다.'
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['row-click'])
|
||||
|
||||
const formatValue = (value, col) => {
|
||||
if (value === null || value === undefined) return '-'
|
||||
|
||||
if (col.type === 'date' && value) {
|
||||
return new Date(value).toLocaleString('ko-KR')
|
||||
}
|
||||
if (col.type === 'boolean') {
|
||||
return value ? 'Y' : 'N'
|
||||
}
|
||||
return value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.actions-col {
|
||||
width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-cell,
|
||||
.empty-cell {
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
padding: 40px !important;
|
||||
}
|
||||
</style>
|
||||
138
frontend/src/components/FormInput.vue
Normal file
138
frontend/src/components/FormInput.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="form-group">
|
||||
<label v-if="label" :for="inputId">
|
||||
{{ label }}
|
||||
<span v-if="required" class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="type !== 'textarea' && type !== 'select'"
|
||||
:id="inputId"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
class="form-input"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="type === 'textarea'"
|
||||
:id="inputId"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:rows="rows"
|
||||
class="form-input"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="type === 'select'"
|
||||
:id="inputId"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
class="form-input"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-if="placeholder" value="">{{ placeholder }}</option>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="error" class="error-text">{{ error }}</span>
|
||||
<span v-if="hint" class="hint-text">{{ hint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
label: String,
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
placeholder: String,
|
||||
required: Boolean,
|
||||
disabled: Boolean,
|
||||
readonly: Boolean,
|
||||
error: String,
|
||||
hint: String,
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
// [{ value: 'a', label: 'A' }]
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const inputId = computed(() => `input-${Math.random().toString(36).slice(2, 9)}`)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
background: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
textarea.form-input {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
select.form-input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
108
frontend/src/components/Modal.vue
Normal file
108
frontend/src/components/Modal.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="modal-overlay" @click.self="close">
|
||||
<div class="modal" :style="{ width: width }">
|
||||
<div class="modal-header">
|
||||
<h3>{{ title }}</h3>
|
||||
<button class="close-btn" @click="close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="modal-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '500px'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
const close = () => {
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
6
frontend/src/components/index.js
Normal file
6
frontend/src/components/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as DataTable } from './DataTable.vue'
|
||||
export { default as Modal } from './Modal.vue'
|
||||
export { default as FormInput } from './FormInput.vue'
|
||||
export { default as Button } from './Button.vue'
|
||||
export { default as Badge } from './Badge.vue'
|
||||
export { default as Card } from './Card.vue'
|
||||
11
frontend/src/main.js
Normal file
11
frontend/src/main.js
Normal 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')
|
||||
44
frontend/src/router/index.js
Normal file
44
frontend/src/router/index.js
Normal 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
|
||||
289
frontend/src/views/DailyStats.vue
Normal file
289
frontend/src/views/DailyStats.vue
Normal 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>
|
||||
498
frontend/src/views/Dashboard.vue
Normal file
498
frontend/src/views/Dashboard.vue
Normal 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>
|
||||
554
frontend/src/views/ErrorHistory.vue
Normal file
554
frontend/src/views/ErrorHistory.vue
Normal file
@@ -0,0 +1,554 @@
|
||||
<template>
|
||||
<div class="error-history">
|
||||
<Card>
|
||||
<template #header>
|
||||
<div class="card-header-content">
|
||||
<h3>에러 이력</h3>
|
||||
<div class="header-actions">
|
||||
<Button size="sm" variant="secondary" @click="exportHtml" :loading="exporting === 'html'">
|
||||
HTML
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" @click="exportTxt" :loading="exporting === 'txt'">
|
||||
TXT
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="filters">
|
||||
<div class="filter-row">
|
||||
<FormInput
|
||||
v-model="filters.serverId"
|
||||
label="서버"
|
||||
type="select"
|
||||
:options="serverOptions"
|
||||
placeholder="전체"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="filters.patternId"
|
||||
label="패턴"
|
||||
type="select"
|
||||
:options="patternOptions"
|
||||
placeholder="전체"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="filters.severity"
|
||||
label="심각도"
|
||||
type="select"
|
||||
:options="severityOptions"
|
||||
placeholder="전체"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="filters.keyword"
|
||||
label="키워드"
|
||||
placeholder="검색어 입력..."
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<FormInput
|
||||
v-model="filters.startDate"
|
||||
label="시작일"
|
||||
type="date"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="filters.endDate"
|
||||
label="종료일"
|
||||
type="date"
|
||||
/>
|
||||
<div class="filter-actions">
|
||||
<Button @click="search">검색</Button>
|
||||
<Button variant="secondary" @click="resetFilters">초기화</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 테이블 -->
|
||||
<div class="results-section">
|
||||
<div class="results-header">
|
||||
<span v-if="totalElements > 0">총 {{ totalElements }}건</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="error-table" v-if="errors.length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-time">발생시간</th>
|
||||
<th class="col-server">서버</th>
|
||||
<th class="col-severity">심각도</th>
|
||||
<th class="col-pattern">패턴</th>
|
||||
<th class="col-summary">요약</th>
|
||||
<th class="col-action">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="error in errors" :key="error.id">
|
||||
<td class="col-time">{{ formatDate(error.occurredAt) }}</td>
|
||||
<td class="col-server">{{ error.serverName }}</td>
|
||||
<td class="col-severity">
|
||||
<Badge :variant="getSeverityVariant(error.severity)">{{ error.severity }}</Badge>
|
||||
</td>
|
||||
<td class="col-pattern">{{ error.patternName }}</td>
|
||||
<td class="col-summary">{{ truncate(error.summary, 50) }}</td>
|
||||
<td class="col-action">
|
||||
<Button size="sm" variant="secondary" @click="showDetail(error)">상세</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="errors.length === 0 && !loading" class="empty-result">
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-result">
|
||||
<p>로딩중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
<div v-if="totalPages > 1" class="pagination">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
:disabled="currentPage === 0"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<span class="page-info">{{ currentPage + 1 }} / {{ totalPages }}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
:disabled="currentPage >= totalPages - 1"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 상세 모달 -->
|
||||
<Modal v-model="showDetailModal" title="에러 상세" width="900px">
|
||||
<div v-if="selectedError" class="error-detail">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>서버</label>
|
||||
<span>{{ selectedError.serverName }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>심각도</label>
|
||||
<Badge :variant="getSeverityVariant(selectedError.severity)">{{ selectedError.severity }}</Badge>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>패턴</label>
|
||||
<span>{{ selectedError.patternName }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>파일</label>
|
||||
<span class="file-path">{{ selectedError.filePath }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>라인</label>
|
||||
<span>{{ selectedError.lineNumber }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>발생시간</label>
|
||||
<span>{{ formatDate(selectedError.occurredAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<label>요약</label>
|
||||
<div class="summary-box">{{ selectedError.summary }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<label>컨텍스트</label>
|
||||
<pre class="context-box">{{ selectedError.context }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="secondary" @click="showDetailModal = false">닫기</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Card, Button, Badge, Modal, FormInput } from '@/components'
|
||||
import { serverApi, patternApi, errorLogApi } from '@/api'
|
||||
|
||||
// State
|
||||
const loading = ref(false)
|
||||
const exporting = ref(null)
|
||||
const errors = ref([])
|
||||
const totalElements = ref(0)
|
||||
const totalPages = ref(0)
|
||||
const currentPage = ref(0)
|
||||
const pageSize = 20
|
||||
|
||||
// Filters
|
||||
const filters = reactive({
|
||||
serverId: '',
|
||||
patternId: '',
|
||||
severity: '',
|
||||
keyword: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
// Options
|
||||
const serverOptions = ref([])
|
||||
const patternOptions = ref([])
|
||||
const severityOptions = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'CRITICAL', label: 'CRITICAL' },
|
||||
{ value: 'ERROR', label: 'ERROR' },
|
||||
{ value: 'WARN', label: 'WARN' }
|
||||
]
|
||||
|
||||
// Detail Modal
|
||||
const showDetailModal = ref(false)
|
||||
const selectedError = ref(null)
|
||||
|
||||
// Load options
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const servers = await serverApi.getAll()
|
||||
serverOptions.value = [
|
||||
{ value: '', label: '전체' },
|
||||
...servers.map(s => ({ value: s.id, label: s.name }))
|
||||
]
|
||||
|
||||
const patterns = await patternApi.getAll()
|
||||
patternOptions.value = [
|
||||
{ value: '', label: '전체' },
|
||||
...patterns.map(p => ({ value: p.id, label: p.name }))
|
||||
]
|
||||
} catch (e) {
|
||||
console.error('Failed to load options:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Search
|
||||
const search = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
size: pageSize
|
||||
}
|
||||
|
||||
if (filters.serverId) params.serverId = filters.serverId
|
||||
if (filters.patternId) params.patternId = filters.patternId
|
||||
if (filters.severity) params.severity = filters.severity
|
||||
if (filters.keyword) params.keyword = filters.keyword
|
||||
if (filters.startDate) params.startDate = filters.startDate + 'T00:00:00'
|
||||
if (filters.endDate) params.endDate = filters.endDate + 'T23:59:59'
|
||||
|
||||
const result = await errorLogApi.search(params)
|
||||
errors.value = result.content || []
|
||||
totalElements.value = result.totalElements || 0
|
||||
totalPages.value = result.totalPages || 0
|
||||
} catch (e) {
|
||||
console.error('Failed to search errors:', e)
|
||||
errors.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.serverId = ''
|
||||
filters.patternId = ''
|
||||
filters.severity = ''
|
||||
filters.keyword = ''
|
||||
filters.startDate = ''
|
||||
filters.endDate = ''
|
||||
currentPage.value = 0
|
||||
search()
|
||||
}
|
||||
|
||||
const goToPage = (page) => {
|
||||
currentPage.value = page
|
||||
search()
|
||||
}
|
||||
|
||||
// Detail
|
||||
const showDetail = async (error) => {
|
||||
try {
|
||||
selectedError.value = await errorLogApi.getById(error.id)
|
||||
showDetailModal.value = true
|
||||
} catch (e) {
|
||||
console.error('Failed to load error detail:', e)
|
||||
selectedError.value = error
|
||||
showDetailModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// Export
|
||||
const buildExportParams = () => {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.serverId) params.append('serverId', filters.serverId)
|
||||
if (filters.patternId) params.append('patternId', filters.patternId)
|
||||
if (filters.severity) params.append('severity', filters.severity)
|
||||
if (filters.keyword) params.append('keyword', filters.keyword)
|
||||
if (filters.startDate) params.append('startDate', filters.startDate + 'T00:00:00')
|
||||
if (filters.endDate) params.append('endDate', filters.endDate + 'T23:59:59')
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
const exportHtml = () => {
|
||||
exporting.value = 'html'
|
||||
const params = buildExportParams()
|
||||
window.open(`/api/export/html?${params}`, '_blank')
|
||||
setTimeout(() => { exporting.value = null }, 1000)
|
||||
}
|
||||
|
||||
const exportTxt = () => {
|
||||
exporting.value = 'txt'
|
||||
const params = buildExportParams()
|
||||
window.open(`/api/export/txt?${params}`, '_blank')
|
||||
setTimeout(() => { exporting.value = null }, 1000)
|
||||
}
|
||||
|
||||
// Utils
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('ko-KR')
|
||||
}
|
||||
|
||||
const truncate = (str, len) => {
|
||||
if (!str) return ''
|
||||
return str.length > len ? str.substring(0, len) + '...' : str
|
||||
}
|
||||
|
||||
const getSeverityVariant = (severity) => {
|
||||
const map = { 'CRITICAL': 'critical', 'ERROR': 'error', 'WARN': 'warn' }
|
||||
return map[severity] || 'default'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOptions()
|
||||
search()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header-content h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-actions :deep(.btn) {
|
||||
white-space: nowrap;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.filter-actions :deep(.btn) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
margin-bottom: 12px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.error-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.error-table th,
|
||||
.error-table td {
|
||||
padding: 10px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.error-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error-table tbody tr:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* Column widths */
|
||||
.col-time {
|
||||
width: 140px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-server {
|
||||
width: 130px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-severity {
|
||||
width: 90px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-pattern {
|
||||
width: 120px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-summary {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.col-action {
|
||||
width: 70px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-action :deep(.btn) {
|
||||
white-space: nowrap;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.empty-result,
|
||||
.loading-result {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.pagination :deep(.btn) {
|
||||
white-space: nowrap;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Error Detail Modal */
|
||||
.error-detail {
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-section label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.context-box {
|
||||
padding: 12px;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
951
frontend/src/views/ErrorLogs.vue
Normal file
951
frontend/src/views/ErrorLogs.vue
Normal 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>
|
||||
270
frontend/src/views/MonthlyStats.vue
Normal file
270
frontend/src/views/MonthlyStats.vue
Normal 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>
|
||||
604
frontend/src/views/PatternManage.vue
Normal file
604
frontend/src/views/PatternManage.vue
Normal 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>
|
||||
903
frontend/src/views/ServerManage.vue
Normal file
903
frontend/src/views/ServerManage.vue
Normal 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>
|
||||
223
frontend/src/views/Settings.vue
Normal file
223
frontend/src/views/Settings.vue
Normal 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>
|
||||
1
frontend/static/assets/Badge-B139BweD.js
Normal file
1
frontend/static/assets/Badge-B139BweD.js
Normal 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};
|
||||
1
frontend/static/assets/Dashboard-NFq9XFpA.css
Normal file
1
frontend/static/assets/Dashboard-NFq9XFpA.css
Normal 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}
|
||||
1
frontend/static/assets/Dashboard-p5aGD1-G.js
Normal file
1
frontend/static/assets/Dashboard-p5aGD1-G.js
Normal file
File diff suppressed because one or more lines are too long
1
frontend/static/assets/DataTable-G-j5cPZJ.js
Normal file
1
frontend/static/assets/DataTable-G-j5cPZJ.js
Normal 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};
|
||||
1
frontend/static/assets/ErrorLogs-BGpGqIKR.js
Normal file
1
frontend/static/assets/ErrorLogs-BGpGqIKR.js
Normal file
File diff suppressed because one or more lines are too long
1
frontend/static/assets/ErrorLogs-CRKaHl3n.css
Normal file
1
frontend/static/assets/ErrorLogs-CRKaHl3n.css
Normal 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}
|
||||
1
frontend/static/assets/FormInput-DHZMRclc.js
Normal file
1
frontend/static/assets/FormInput-DHZMRclc.js
Normal 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};
|
||||
1
frontend/static/assets/PatternManage-13drcwyX.css
Normal file
1
frontend/static/assets/PatternManage-13drcwyX.css
Normal 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}
|
||||
1
frontend/static/assets/PatternManage-Bx04TG1c.js
Normal file
1
frontend/static/assets/PatternManage-Bx04TG1c.js
Normal file
File diff suppressed because one or more lines are too long
1
frontend/static/assets/ServerManage-AXN0dje_.js
Normal file
1
frontend/static/assets/ServerManage-AXN0dje_.js
Normal file
File diff suppressed because one or more lines are too long
1
frontend/static/assets/ServerManage-DG-6txhK.css
Normal file
1
frontend/static/assets/ServerManage-DG-6txhK.css
Normal 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}
|
||||
1
frontend/static/assets/Settings-CipHZo9K.css
Normal file
1
frontend/static/assets/Settings-CipHZo9K.css
Normal file
@@ -0,0 +1 @@
|
||||
.settings[data-v-fdca948e]{max-width:800px}.card-header-content h3[data-v-fdca948e]{margin:0}.loading[data-v-fdca948e]{text-align:center;padding:40px;color:#666}.settings-form[data-v-fdca948e]{padding:10px 0}.setting-section[data-v-fdca948e]{margin-bottom:30px;padding-bottom:20px;border-bottom:1px solid #eee}.setting-section[data-v-fdca948e]:last-of-type{border-bottom:none}.setting-section h4[data-v-fdca948e]{margin:0 0 20px;font-size:16px;color:#2c3e50}.form-actions[data-v-fdca948e]{display:flex;justify-content:flex-end;gap:12px;margin-top:20px;padding-top:20px;border-top:1px solid #eee}.app-info[data-v-fdca948e]{margin-top:20px}.app-info h3[data-v-fdca948e]{margin:0}.info-list[data-v-fdca948e]{display:flex;flex-direction:column;gap:12px}.info-item[data-v-fdca948e]{display:flex;justify-content:space-between;padding:12px 0;border-bottom:1px solid #f0f0f0}.info-item[data-v-fdca948e]:last-child{border-bottom:none}.info-item .label[data-v-fdca948e]{color:#666}.info-item .value[data-v-fdca948e]{font-weight:500}
|
||||
1
frontend/static/assets/Settings-CoGozHRg.js
Normal file
1
frontend/static/assets/Settings-CoGozHRg.js
Normal 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};
|
||||
6
frontend/static/assets/index-Bx7gSOle.js
Normal file
6
frontend/static/assets/index-Bx7gSOle.js
Normal file
File diff suppressed because one or more lines are too long
1
frontend/static/assets/index-Celvq1V5.css
Normal file
1
frontend/static/assets/index-Celvq1V5.css
Normal file
@@ -0,0 +1 @@
|
||||
.data-table-wrapper[data-v-db5e24a9]{overflow-x:auto}.data-table[data-v-db5e24a9]{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px #0000001a}.data-table th[data-v-db5e24a9],.data-table td[data-v-db5e24a9]{padding:12px 16px;text-align:left;border-bottom:1px solid #eee}.data-table th[data-v-db5e24a9]{background:#f8f9fa;font-weight:600;color:#495057}.data-table tbody tr[data-v-db5e24a9]:hover{background:#f8f9fa}.data-table tbody tr:last-child td[data-v-db5e24a9]{border-bottom:none}.actions-col[data-v-db5e24a9]{width:120px;text-align:center}.loading-cell[data-v-db5e24a9],.empty-cell[data-v-db5e24a9]{text-align:center;color:#6c757d;padding:40px!important}.modal-overlay[data-v-90993dd3]{position:fixed;top:0;left:0;right:0;bottom:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal[data-v-90993dd3]{background:#fff;border-radius:8px;max-height:90vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 4px 20px #00000026}.modal-header[data-v-90993dd3]{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #eee}.modal-header h3[data-v-90993dd3]{margin:0;font-size:1.1rem}.close-btn[data-v-90993dd3]{background:none;border:none;font-size:1.5rem;cursor:pointer;color:#666;padding:0;line-height:1}.close-btn[data-v-90993dd3]:hover{color:#333}.modal-body[data-v-90993dd3]{padding:20px;overflow-y:auto;flex:1}.modal-footer[data-v-90993dd3]{padding:16px 20px;border-top:1px solid #eee;display:flex;justify-content:flex-end;gap:8px}.form-group[data-v-45f49038]{margin-bottom:16px}label[data-v-45f49038]{display:block;margin-bottom:6px;font-weight:500;color:#333}.required[data-v-45f49038]{color:#e74c3c}.form-input[data-v-45f49038]{width:100%;padding:10px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px;transition:border-color .2s}.form-input[data-v-45f49038]:focus{outline:none;border-color:#3498db}.form-input[data-v-45f49038]:disabled{background:#f5f5f5;cursor:not-allowed}textarea.form-input[data-v-45f49038]{resize:vertical}select.form-input[data-v-45f49038]{cursor:pointer}.error-text[data-v-45f49038]{display:block;margin-top:4px;font-size:12px;color:#e74c3c}.hint-text[data-v-45f49038]{display:block;margin-top:4px;font-size:12px;color:#6c757d}.btn[data-v-e5da414f]{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:10px 16px;border:none;border-radius:4px;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s}.btn[data-v-e5da414f]:disabled{opacity:.6;cursor:not-allowed}.btn-sm[data-v-e5da414f]{padding:6px 12px;font-size:12px}.btn-lg[data-v-e5da414f]{padding:12px 24px;font-size:16px}.btn-primary[data-v-e5da414f]{background:#3498db;color:#fff}.btn-primary[data-v-e5da414f]:hover:not(:disabled){background:#2980b9}.btn-secondary[data-v-e5da414f]{background:#6c757d;color:#fff}.btn-secondary[data-v-e5da414f]:hover:not(:disabled){background:#5a6268}.btn-danger[data-v-e5da414f]{background:#e74c3c;color:#fff}.btn-danger[data-v-e5da414f]:hover:not(:disabled){background:#c0392b}.btn-success[data-v-e5da414f]{background:#27ae60;color:#fff}.btn-success[data-v-e5da414f]:hover:not(:disabled){background:#1e8449}.btn-warning[data-v-e5da414f]{background:#f39c12;color:#fff}.btn-warning[data-v-e5da414f]:hover:not(:disabled){background:#d68910}.spinner[data-v-e5da414f]{width:14px;height:14px;border:2px solid transparent;border-top-color:currentColor;border-radius:50%;animation:spin-e5da414f .8s linear infinite}@keyframes spin-e5da414f{to{transform:rotate(360deg)}}.badge[data-v-b7bd2350]{display:inline-block;padding:4px 8px;font-size:12px;font-weight:500;border-radius:4px}.badge-default[data-v-b7bd2350]{background:#e9ecef;color:#495057}.badge-critical[data-v-b7bd2350],.badge-error[data-v-b7bd2350]{background:#e74c3c;color:#fff}.badge-warn[data-v-b7bd2350]{background:#f39c12;color:#fff}.badge-success[data-v-b7bd2350]{background:#27ae60;color:#fff}.badge-info[data-v-b7bd2350]{background:#3498db;color:#fff}.card[data-v-2f260fa2]{background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a;overflow:hidden}.card-header[data-v-2f260fa2]{padding:16px 20px;border-bottom:1px solid #eee}.card-header h3[data-v-2f260fa2]{margin:0;font-size:1.1rem;color:#333}.card-body[data-v-2f260fa2]{padding:20px}.card-footer[data-v-2f260fa2]{padding:16px 20px;border-top:1px solid #eee;background:#f8f9fa}
|
||||
30
frontend/static/assets/index-D2VmGkBi.js
Normal file
30
frontend/static/assets/index-D2VmGkBi.js
Normal file
File diff suppressed because one or more lines are too long
1
frontend/static/assets/index-GLdO36rE.css
Normal file
1
frontend/static/assets/index-GLdO36rE.css
Normal file
@@ -0,0 +1 @@
|
||||
*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}#app{min-height:100vh;background:#f5f5f5}.header{background:#2c3e50;color:#fff;padding:1rem 2rem;display:flex;align-items:center;gap:2rem}.header h1{font-size:1.5rem;margin:0}.header nav{display:flex;gap:1rem}.header nav a{color:#ecf0f1;text-decoration:none;padding:.5rem 1rem;border-radius:4px;transition:background .2s}.header nav a:hover{background:#34495e}.header nav a.router-link-active{background:#3498db}.main{padding:2rem;max-width:1400px;margin:0 auto}
|
||||
27
frontend/static/index.html
Normal file
27
frontend/static/index.html
Normal 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
25
frontend/vite.config.js
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
gradlew
vendored
Executable 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
93
gradlew.bat
vendored
Normal 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
1
settings.gradle
Normal file
@@ -0,0 +1 @@
|
||||
rootProject.name = 'log-hunter'
|
||||
29
src/main/java/research/loghunter/LogHunterApplication.java
Normal file
29
src/main/java/research/loghunter/LogHunterApplication.java
Normal 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 + " 에 접속해주세요.");
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/main/java/research/loghunter/config/DatabaseConfig.java
Normal file
61
src/main/java/research/loghunter/config/DatabaseConfig.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/main/java/research/loghunter/config/WebConfig.java
Normal file
39
src/main/java/research/loghunter/config/WebConfig.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package research.loghunter.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
protected Resource getResource(String resourcePath, Resource location) throws IOException {
|
||||
Resource requestedResource = location.createRelative(resourcePath);
|
||||
|
||||
// 실제 파일이 존재하면 반환
|
||||
if (requestedResource.exists() && requestedResource.isReadable()) {
|
||||
return requestedResource;
|
||||
}
|
||||
|
||||
// API 요청이 아니면 index.html 반환 (SPA 라우팅)
|
||||
if (!resourcePath.startsWith("api/")) {
|
||||
return new ClassPathResource("/static/index.html");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
221
src/main/java/research/loghunter/controller/ScanController.java
Normal file
221
src/main/java/research/loghunter/controller/ScanController.java
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package research.loghunter.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import research.loghunter.dto.ServerDto;
|
||||
import research.loghunter.service.ServerService;
|
||||
import research.loghunter.service.SftpService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/servers")
|
||||
@RequiredArgsConstructor
|
||||
public class ServerController {
|
||||
|
||||
private final ServerService serverService;
|
||||
private final SftpService sftpService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<ServerDto>> findAll() {
|
||||
return ResponseEntity.ok(serverService.findAll());
|
||||
}
|
||||
|
||||
@GetMapping("/active")
|
||||
public ResponseEntity<List<ServerDto>> findAllActive() {
|
||||
return ResponseEntity.ok(serverService.findAllActive());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ServerDto> findById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(serverService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ServerDto> create(@RequestBody ServerDto dto) {
|
||||
return ResponseEntity.ok(serverService.create(dto));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ServerDto> update(@PathVariable Long id, @RequestBody ServerDto dto) {
|
||||
return ResponseEntity.ok(serverService.update(id, dto));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
serverService.delete(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 테스트
|
||||
*/
|
||||
@PostMapping("/{id}/test-connection")
|
||||
public ResponseEntity<SftpService.ConnectionTestResult> testConnection(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(sftpService.testConnection(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package research.loghunter.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import research.loghunter.dto.ServerLogPathDto;
|
||||
import research.loghunter.service.ServerLogPathService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/log-paths")
|
||||
@RequiredArgsConstructor
|
||||
public class ServerLogPathController {
|
||||
|
||||
private final ServerLogPathService logPathService;
|
||||
|
||||
@GetMapping("/server/{serverId}")
|
||||
public ResponseEntity<List<ServerLogPathDto>> findByServerId(@PathVariable Long serverId) {
|
||||
return ResponseEntity.ok(logPathService.findByServerId(serverId));
|
||||
}
|
||||
|
||||
@GetMapping("/server/{serverId}/active")
|
||||
public ResponseEntity<List<ServerLogPathDto>> findActiveByServerId(@PathVariable Long serverId) {
|
||||
return ResponseEntity.ok(logPathService.findActiveByServerId(serverId));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ServerLogPathDto> findById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(logPathService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ServerLogPathDto> create(@RequestBody ServerLogPathDto dto) {
|
||||
return ResponseEntity.ok(logPathService.create(dto));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ServerLogPathDto> update(@PathVariable Long id, @RequestBody ServerLogPathDto dto) {
|
||||
return ResponseEntity.ok(logPathService.update(id, dto));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
logPathService.delete(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package research.loghunter.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import research.loghunter.dto.SettingDto;
|
||||
import research.loghunter.service.SettingService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/settings")
|
||||
@RequiredArgsConstructor
|
||||
public class SettingController {
|
||||
|
||||
private final SettingService settingService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<SettingDto>> findAll() {
|
||||
return ResponseEntity.ok(settingService.findAll());
|
||||
}
|
||||
|
||||
@GetMapping("/map")
|
||||
public ResponseEntity<Map<String, String>> findAllAsMap() {
|
||||
return ResponseEntity.ok(settingService.findAllAsMap());
|
||||
}
|
||||
|
||||
@GetMapping("/{key}")
|
||||
public ResponseEntity<String> getValue(@PathVariable String key) {
|
||||
String value = settingService.getValue(key);
|
||||
return value != null ? ResponseEntity.ok(value) : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<SettingDto> save(@RequestBody SettingDto dto) {
|
||||
return ResponseEntity.ok(settingService.save(dto));
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public ResponseEntity<Void> saveAll(@RequestBody Map<String, String> settings) {
|
||||
settingService.saveAll(settings);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{key}")
|
||||
public ResponseEntity<Void> delete(@PathVariable String key) {
|
||||
settingService.delete(key);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
24
src/main/java/research/loghunter/dto/ErrorLogDto.java
Normal file
24
src/main/java/research/loghunter/dto/ErrorLogDto.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package research.loghunter.dto;
|
||||
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ErrorLogDto {
|
||||
private Long id;
|
||||
private Long serverId;
|
||||
private String serverName;
|
||||
private Long patternId;
|
||||
private String patternName;
|
||||
private String filePath;
|
||||
private Integer lineNumber;
|
||||
private String summary;
|
||||
private String context;
|
||||
private String severity;
|
||||
private LocalDateTime occurredAt;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
45
src/main/java/research/loghunter/dto/FileTreeDto.java
Normal file
45
src/main/java/research/loghunter/dto/FileTreeDto.java
Normal file
@@ -0,0 +1,45 @@
|
||||
package research.loghunter.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class FileTreeDto {
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ServerNode {
|
||||
private Long serverId;
|
||||
private String serverName;
|
||||
private int totalErrorCount;
|
||||
private List<PathNode> paths;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class PathNode {
|
||||
private String path;
|
||||
private int totalErrorCount;
|
||||
private List<FileNode> files;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class FileNode {
|
||||
private String filePath;
|
||||
private String fileName;
|
||||
private int errorCount;
|
||||
private int criticalCount;
|
||||
private int errorLevelCount;
|
||||
private int warnCount;
|
||||
}
|
||||
}
|
||||
21
src/main/java/research/loghunter/dto/PatternDto.java
Normal file
21
src/main/java/research/loghunter/dto/PatternDto.java
Normal 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;
|
||||
}
|
||||
27
src/main/java/research/loghunter/dto/ServerDto.java
Normal file
27
src/main/java/research/loghunter/dto/ServerDto.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package research.loghunter.dto;
|
||||
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ServerDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String host;
|
||||
private Integer port;
|
||||
private String username;
|
||||
private String authType;
|
||||
private String password; // 입력용 (저장 시 암호화)
|
||||
private String keyFilePath;
|
||||
private String passphrase; // 입력용 (저장 시 암호화)
|
||||
private Boolean active;
|
||||
private LocalDateTime lastScanAt;
|
||||
private LocalDateTime lastErrorAt;
|
||||
private LocalDateTime createdAt;
|
||||
private List<ServerLogPathDto> logPaths;
|
||||
}
|
||||
17
src/main/java/research/loghunter/dto/ServerLogPathDto.java
Normal file
17
src/main/java/research/loghunter/dto/ServerLogPathDto.java
Normal file
@@ -0,0 +1,17 @@
|
||||
package research.loghunter.dto;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ServerLogPathDto {
|
||||
private Long id;
|
||||
private Long serverId;
|
||||
private String path;
|
||||
private String filePattern;
|
||||
private String description;
|
||||
private Boolean active;
|
||||
}
|
||||
14
src/main/java/research/loghunter/dto/SettingDto.java
Normal file
14
src/main/java/research/loghunter/dto/SettingDto.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package research.loghunter.dto;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SettingDto {
|
||||
private String key;
|
||||
private String value;
|
||||
private String description;
|
||||
}
|
||||
61
src/main/java/research/loghunter/entity/ErrorLog.java
Normal file
61
src/main/java/research/loghunter/entity/ErrorLog.java
Normal file
@@ -0,0 +1,61 @@
|
||||
package research.loghunter.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "error_logs")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ErrorLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "server_id", nullable = false)
|
||||
private Server server;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "pattern_id", nullable = false)
|
||||
private Pattern pattern;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "scan_history_id")
|
||||
private ScanHistory scanHistory;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String filePath; // 로그 파일 경로
|
||||
|
||||
private Integer lineNumber; // 에러 발생 라인 번호
|
||||
|
||||
@Column(nullable = false, length = 500)
|
||||
private String summary; // 에러 요약 (첫 줄 또는 일부)
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String context; // 캡처된 전체 컨텍스트
|
||||
|
||||
@Column(nullable = false)
|
||||
private String severity; // CRITICAL, ERROR, WARN
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime occurredAt; // 에러 발생 시간 (로그 파일 내 시간 파싱)
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime scannedAt; // 스캔/분석 시간
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt; // DB 저장 시간
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (scannedAt == null) scannedAt = LocalDateTime.now();
|
||||
if (occurredAt == null) occurredAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
58
src/main/java/research/loghunter/entity/Pattern.java
Normal file
58
src/main/java/research/loghunter/entity/Pattern.java
Normal 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();
|
||||
}
|
||||
}
|
||||
46
src/main/java/research/loghunter/entity/ScanHistory.java
Normal file
46
src/main/java/research/loghunter/entity/ScanHistory.java
Normal file
@@ -0,0 +1,46 @@
|
||||
package research.loghunter.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "scan_history")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ScanHistory {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "server_id", nullable = false)
|
||||
private Server server;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime startedAt; // 분석 시작 시간
|
||||
|
||||
private LocalDateTime finishedAt; // 분석 종료 시간
|
||||
|
||||
@Column(nullable = false)
|
||||
private String status; // RUNNING, SUCCESS, FAILED
|
||||
|
||||
private Integer filesScanned; // 스캔한 파일 수
|
||||
|
||||
private Integer errorsFound; // 발견된 에러 수
|
||||
|
||||
@Column(length = 2000)
|
||||
private String errorMessage; // 실패 시 에러 메시지
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (startedAt == null) startedAt = LocalDateTime.now();
|
||||
if (status == null) status = "RUNNING";
|
||||
if (filesScanned == null) filesScanned = 0;
|
||||
if (errorsFound == null) errorsFound = 0;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user