546 lines
12 KiB
Vue
546 lines
12 KiB
Vue
<template>
|
|
<div class="dashboard">
|
|
<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>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<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>
|
|
</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-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.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;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.error-table th,
|
|
.error-table td {
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.error-table th {
|
|
background: #f8f9fa;
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.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>
|