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>
|
||||
|
||||
Reference in New Issue
Block a user