update
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user