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