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

@@ -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>