This commit is contained in:
2026-01-06 11:19:46 +09:00
parent 0f4f74cbaf
commit 9ac66fbc73
23 changed files with 2387 additions and 2 deletions

View File

@@ -70,8 +70,8 @@ log-hunter.jar 실행
### Step 1. 프로젝트 초기 설정 ### Step 1. 프로젝트 초기 설정
- [ ] 1-1. Spring Boot 프로젝트 생성 - [x] 1-1. Spring Boot 프로젝트 생성
- [ ] 1-2. Vue3 프로젝트 생성 (frontend 폴더) - [x] 1-2. Vue3 프로젝트 생성 (frontend 폴더)
- [ ] 1-3. 빌드 연동 (Vue → static 폴더로) - [ ] 1-3. 빌드 연동 (Vue → static 폴더로)
- [ ] 1-4. SQLite 연동 확인 - [ ] 1-4. SQLite 연동 확인
- [ ] 1-5. 앱 실행 시 브라우저 자동 오픈 - [ ] 1-5. 앱 실행 시 브라우저 자동 오픈
@@ -158,3 +158,4 @@ Step 1 → Step 2 → Step 3 → Step 6 (공통)
| 날짜 | 내용 | | 날짜 | 내용 |
|------|------| |------|------|
| 2025-01-06 | 최초 작성, 요구사항 정리 | | 2025-01-06 | 최초 작성, 요구사항 정리 |
| 2025-01-06 | Step 1-1, 1-2 완료 (Spring Boot, Vue3 프로젝트 생성) |

25
backend/.gitignore vendored Normal file
View File

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

51
backend/build.gradle Normal file
View File

@@ -0,0 +1,51 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.1'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.osolit'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
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'
}
tasks.named('test') {
useJUnitPlatform()
}
// Vue3 빌드 결과물을 static 폴더로 복사
task copyFrontend(type: Copy) {
from '../frontend/dist'
into 'src/main/resources/static'
}
// 빌드 전에 frontend 복사
bootJar {
dependsOn copyFrontend
}

1
backend/data/.gitkeep Normal file
View File

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

View File

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

92
backend/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,92 @@
@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
@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.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
: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 having the _cmd_ return code (for example, to use in scripts)
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
backend/settings.gradle Normal file
View File

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

View File

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

View File

@@ -0,0 +1,26 @@
server:
port: 8080
spring:
application:
name: log-hunter
datasource:
url: jdbc:sqlite:./data/loghunter.db
driver-class-name: org.sqlite.JDBC
jpa:
database-platform: org.hibernate.community.dialect.SQLiteDialect
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
format_sql: true
# 앱 설정
app:
crypto:
key: ${LOGHUNTER_CRYPTO_KEY:LogHunterDefaultKey32Bytes!!}
export:
path: ./exports

16
frontend/.gitignore vendored Normal file
View File

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

23
frontend/index.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LogHunter</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1560
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "log-hunter-frontend",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"axios": "^1.6.5",
"pinia": "^2.1.7"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.0.11"
}
}

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

@@ -0,0 +1,69 @@
<template>
<div id="app">
<header class="header">
<h1>📊 LogHunter</h1>
<nav>
<router-link to="/">대시보드</router-link>
<router-link to="/errors">에러 이력</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>
#app {
min-height: 100vh;
background: #f5f5f5;
}
</style>
.header {
background: #2c3e50;
color: white;
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: 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: 2rem;
max-width: 1400px;
margin: 0 auto;
}
</style>

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

@@ -0,0 +1,26 @@
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)
}
)
export default api

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

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

View File

@@ -0,0 +1,34 @@
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: '/patterns',
name: 'patterns',
component: () => import('@/views/PatternManage.vue')
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/Settings.vue')
}
]
})
export default router

View File

@@ -0,0 +1,61 @@
<template>
<div class="dashboard">
<h2>대시보드</h2>
<p>서버 목록과 분석 실행 화면입니다.</p>
<div class="server-list">
<table>
<thead>
<tr>
<th>서버명</th>
<th>마지막 분석</th>
<th>마지막 에러</th>
<th>액션</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="4" class="empty">등록된 서버가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
</script>
<style scoped>
.dashboard h2 {
margin-bottom: 1rem;
}
.server-list {
background: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8f9fa;
font-weight: 600;
}
.empty {
text-align: center;
color: #999;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="error-logs">
<h2>에러 이력</h2>
<p>수집된 에러 목록을 조회합니다.</p>
<div class="filters">
<select><option>전체 서버</option></select>
<input type="date" placeholder="시작일">
<input type="date" placeholder="종료일">
<input type="text" placeholder="키워드 검색">
<button>검색</button>
</div>
<div class="error-list">
<table>
<thead>
<tr>
<th>발생일시</th>
<th>서버</th>
<th>패턴</th>
<th>에러 요약</th>
<th>상세</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="empty">에러 이력이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
</script>
<style scoped>
.error-logs h2 {
margin-bottom: 1rem;
}
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters select,
.filters input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.filters button {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.error-list {
background: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8f9fa;
}
.empty {
text-align: center;
color: #999;
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<div class="pattern-manage">
<h2>패턴 관리</h2>
<p>에러 검출 패턴을 관리합니다.</p>
<div class="actions">
<button class="btn-primary">+ 패턴 추가</button>
</div>
<div class="pattern-list">
<table>
<thead>
<tr>
<th>패턴명</th>
<th>정규식</th>
<th>심각도</th>
<th>컨텍스트</th>
<th>활성화</th>
<th>액션</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="empty">등록된 패턴이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
</script>
<style scoped>
.pattern-manage h2 { margin-bottom: 1rem; }
.actions { margin-bottom: 1rem; }
.btn-primary {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.pattern-list {
background: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; }
.empty { text-align: center; color: #999; }
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="server-manage">
<h2>서버 관리</h2>
<p>SFTP 서버 접속 정보를 관리합니다.</p>
<div class="actions">
<button class="btn-primary">+ 서버 추가</button>
</div>
<div class="server-list">
<table>
<thead>
<tr>
<th>서버명</th>
<th>호스트</th>
<th>포트</th>
<th>인증방식</th>
<th>활성화</th>
<th>액션</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="empty">등록된 서버가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
</script>
<style scoped>
.server-manage h2 {
margin-bottom: 1rem;
}
.actions {
margin-bottom: 1rem;
}
.btn-primary {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.server-list {
background: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8f9fa;
}
.empty {
text-align: center;
color: #999;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="settings">
<h2>설정</h2>
<p>애플리케이션 설정을 관리합니다.</p>
<div class="settings-form">
<div class="form-group">
<label>내보내기 경로</label>
<input type="text" v-model="form.exportPath" placeholder="./exports">
</div>
<div class="form-group">
<label>로그 보관 기간 ()</label>
<input type="number" v-model="form.retentionDays" min="1">
</div>
<div class="form-group">
<label> 서버 포트</label>
<input type="number" v-model="form.port" min="1" max="65535">
</div>
<div class="actions">
<button class="btn-primary">저장</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const form = ref({
exportPath: './exports',
retentionDays: 30,
port: 8080
})
</script>
<style scoped>
.settings h2 { margin-bottom: 1rem; }
.settings-form {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
max-width: 500px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.actions {
margin-top: 1.5rem;
}
.btn-primary {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>

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

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