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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,79 +1,223 @@
<template>
<div class="settings">
<h2>설정</h2>
<p>애플리케이션 설정을 관리합니다.</p>
<div class="settings-form">
<div class="form-group">
<label>내보내기 경로</label>
<input type="text" v-model="form.exportPath" placeholder="./exports">
<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>
<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>
</Card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { Card, Button, FormInput } from '@/components'
import { settingApi } from '@/api'
const form = ref({
exportPath: './exports',
retentionDays: 30,
port: 8080
const loading = ref(false)
const saving = ref(false)
const settings = ref({})
// 기본값
const defaultSettings = {
'server.port': '8080',
'export.path': './exports',
'retention.days': '90',
'scan.timeout': '30',
'scan.maxFileSize': '100'
}
const loadSettings = async () => {
loading.value = true
try {
const data = await settingApi.getAllAsMap()
settings.value = { ...defaultSettings, ...data }
} catch (e) {
console.error('Failed to load settings:', e)
settings.value = { ...defaultSettings }
} finally {
loading.value = false
}
}
const saveSettings = async () => {
saving.value = true
try {
// 각 설정 저장
for (const [key, value] of Object.entries(settings.value)) {
await settingApi.save({ key, value: String(value) })
}
alert('설정이 저장되었습니다.')
} catch (e) {
console.error('Failed to save settings:', e)
alert('설정 저장에 실패했습니다.')
} finally {
saving.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>
<style scoped>
.settings h2 { margin-bottom: 1rem; }
.settings {
max-width: 800px;
}
.card-header-content h3 {
margin: 0;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.settings-form {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
max-width: 500px;
padding: 10px 0;
}
.form-group {
margin-bottom: 1rem;
.setting-section {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
.setting-section:last-of-type {
border-bottom: none;
}
.setting-section h4 {
margin: 0 0 20px 0;
font-size: 16px;
color: #2c3e50;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.app-info {
margin-top: 20px;
}
.app-info h3 {
margin: 0;
}
.info-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-item:last-child {
border-bottom: none;
}
.info-item .label {
color: #666;
}
.info-item .value {
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.actions {
margin-top: 1.5rem;
}
.btn-primary {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>