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