update 22

This commit is contained in:
2026-01-07 01:41:17 +09:00
parent 66e8e21302
commit 057a1bad41
86 changed files with 779 additions and 194 deletions

View File

@@ -1,39 +1,71 @@
<template>
<div class="pattern-manage">
<Card>
<template #header>
<div class="card-header-content">
<h3>패턴 관리</h3>
<Button @click="openAddModal">+ 패턴 추가</Button>
</div>
</template>
<div class="page-header">
<h2>패턴 관리</h2>
<Button @click="openAddModal">+ 패턴 추가</Button>
</div>
<DataTable
:columns="columns"
:data="patterns"
:loading="loading"
empty-text="등록된 패턴이 없습니다."
>
<template #severity="{ value }">
<Badge :variant="getSeverityVariant(value)">{{ value }}</Badge>
</template>
<template #regex="{ value }">
<code class="regex-code">{{ truncate(value, 50) }}</code>
</template>
<template #active="{ value }">
<Badge :variant="value ? 'success' : 'default'">
{{ value ? '활성' : '비활성' }}
</Badge>
</template>
<template #actions="{ row }">
<div class="action-buttons">
<Button size="sm" variant="secondary" @click="openTestModal(row)">테스트</Button>
<Button size="sm" @click="openEditModal(row)">수정</Button>
<Button size="sm" variant="danger" @click="confirmDelete(row)">삭제</Button>
<div v-if="loading" class="loading">
<p>로딩중...</p>
</div>
<div v-else-if="patterns.length === 0" class="empty-state">
<p>등록된 패턴이 없습니다.</p>
<Button @click="openAddModal"> 패턴 추가하기</Button>
</div>
<!-- 패턴 카드 그리드 -->
<div v-else class="pattern-grid">
<Card v-for="pattern in patterns" :key="pattern.id" class="pattern-card">
<template #header>
<div class="pattern-header">
<div class="pattern-title">
<Badge :variant="getSeverityVariant(pattern.severity)" size="sm">
{{ pattern.severity }}
</Badge>
<h4>{{ pattern.name }}</h4>
</div>
<Badge :variant="pattern.active ? 'success' : 'default'" size="sm">
{{ pattern.active ? '활성' : '비활성' }}
</Badge>
</div>
</template>
</DataTable>
</Card>
<div class="pattern-body">
<div class="pattern-info">
<label>정규식</label>
<code class="regex-box">{{ pattern.regex }}</code>
</div>
<div v-if="pattern.excludeRegex" class="pattern-info">
<label>제외 정규식</label>
<code class="regex-box exclude">{{ pattern.excludeRegex }}</code>
</div>
<div class="pattern-meta">
<span class="meta-item">
<span class="meta-label">컨텍스트</span>
<span class="meta-value">{{ pattern.contextLines }}</span>
</span>
<span v-if="pattern.description" class="meta-item description">
{{ pattern.description }}
</span>
</div>
</div>
<div class="pattern-actions">
<button class="action-btn test" @click="openTestModal(pattern)" title="테스트">
🧪 테스트
</button>
<button class="action-btn edit" @click="openEditModal(pattern)" title="수정">
수정
</button>
<button class="action-btn delete" @click="confirmDelete(pattern)" title="삭제">
🗑 삭제
</button>
</div>
</Card>
</div>
<!-- 패턴 추가/수정 모달 -->
<Modal v-model="showPatternModal" :title="isEdit ? '패턴 수정' : '패턴 추가'" width="600px">
@@ -154,17 +186,9 @@
<script setup>
import { ref, onMounted } from 'vue'
import { DataTable, Modal, FormInput, Button, Badge, Card } from '@/components'
import { Modal, FormInput, Button, Badge, Card } from '@/components'
import { patternApi } from '@/api'
const columns = [
{ key: 'name', label: '패턴명', width: '150px' },
{ key: 'regex', label: '정규식' },
{ key: 'severity', label: '심각도', width: '100px' },
{ key: 'contextLines', label: '컨텍스트', width: '90px' },
{ key: 'active', label: '상태', width: '80px' }
]
const severityOptions = [
{ value: 'CRITICAL', label: 'CRITICAL' },
{ value: 'ERROR', label: 'ERROR' },
@@ -323,40 +347,184 @@ const getSeverityVariant = (severity) => {
return map[severity] || 'default'
}
const truncate = (str, len) => {
if (!str) return ''
return str.length > len ? str.substring(0, len) + '...' : str
}
onMounted(() => {
loadPatterns()
})
</script>
<style scoped>
.card-header-content {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
}
.loading,
.empty-state {
text-align: center;
padding: 60px;
background: white;
border-radius: 8px;
color: #666;
}
.empty-state p {
margin-bottom: 16px;
}
/* 패턴 그리드 */
.pattern-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
}
.pattern-card {
transition: box-shadow 0.2s, transform 0.2s;
}
.pattern-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
.pattern-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header-content h3 {
margin: 0;
.pattern-title {
display: flex;
align-items: center;
gap: 10px;
}
.action-buttons {
.pattern-title h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.pattern-body {
padding: 4px 0;
}
.pattern-info {
margin-bottom: 12px;
}
.pattern-info label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.regex-box {
display: block;
font-family: 'Consolas', 'Monaco', monospace;
background: #f1f3f5;
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
word-break: break-all;
line-height: 1.4;
border-left: 3px solid #3498db;
}
.regex-box.exclude {
border-left-color: #e67e22;
background: #fef5e7;
}
.pattern-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-top: 8px;
border-top: 1px solid #eee;
font-size: 13px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.regex-code {
font-family: monospace;
background: #f1f3f4;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
.meta-label {
color: #888;
}
.meta-value {
font-weight: 500;
color: #333;
}
.meta-item.description {
flex-basis: 100%;
color: #666;
font-style: italic;
}
/* 액션 버튼 */
.pattern-actions {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #eee;
margin-top: 12px;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.action-btn.test {
background: #e8f4fd;
color: #2980b9;
}
.action-btn.test:hover {
background: #d4e9f7;
}
.action-btn.edit {
background: #fef3e2;
color: #d68910;
}
.action-btn.edit:hover {
background: #fce8c9;
}
.action-btn.delete {
background: #fdeaea;
color: #c0392b;
}
.action-btn.delete:hover {
background: #f9d6d6;
}
/* 폼 */
.form-group {
margin-bottom: 16px;
}
@@ -368,6 +536,7 @@ onMounted(() => {
cursor: pointer;
}
/* 테스트 섹션 */
.test-section {
display: flex;
flex-direction: column;

View File

@@ -1,40 +1,105 @@
<template>
<div class="server-manage">
<Card>
<template #header>
<div class="card-header-content">
<h3>서버 관리</h3>
<Button @click="openAddModal">+ 서버 추가</Button>
</div>
</template>
<div class="page-header">
<h2>서버 관리</h2>
<Button @click="openAddModal">+ 서버 추가</Button>
</div>
<DataTable
:columns="columns"
:data="servers"
:loading="loading"
empty-text="등록된 서버가 없습니다."
>
<template #active="{ value }">
<Badge :variant="value ? 'success' : 'default'">
{{ value ? '활성' : '비활성' }}
</Badge>
</template>
<template #authType="{ value }">
{{ value === 'PASSWORD' ? '비밀번호' : '키 파일' }}
</template>
<template #lastScanAt="{ value }">
{{ value ? formatDate(value) : '-' }}
</template>
<template #actions="{ row }">
<div class="action-buttons">
<Button size="sm" variant="success" @click="testConnection(row)" :loading="testingId === row.id">테스트</Button>
<Button size="sm" variant="secondary" @click="openLogPathModal(row)">경로</Button>
<Button size="sm" @click="openEditModal(row)">수정</Button>
<Button size="sm" variant="danger" @click="confirmDelete(row)">삭제</Button>
<div v-if="loading" class="loading">
<p>로딩중...</p>
</div>
<div v-else-if="servers.length === 0" class="empty-state">
<p>등록된 서버가 없습니다.</p>
<Button @click="openAddModal"> 서버 추가하기</Button>
</div>
<!-- 서버 카드 그리드 -->
<div v-else 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>
</template>
</DataTable>
</Card>
<div class="server-body">
<div class="server-info-grid">
<div class="info-item">
<span class="info-icon">🌐</span>
<div class="info-content">
<span class="info-label">호스트</span>
<span class="info-value">{{ server.host }}:{{ server.port }}</span>
</div>
</div>
<div class="info-item">
<span class="info-icon">👤</span>
<div class="info-content">
<span class="info-label">사용자</span>
<span class="info-value">{{ server.username }}</span>
</div>
</div>
<div class="info-item">
<span class="info-icon">🔑</span>
<div class="info-content">
<span class="info-label">인증방식</span>
<span class="info-value">{{ server.authType === 'PASSWORD' ? '비밀번호' : '키 파일' }}</span>
</div>
</div>
<div class="info-item">
<span class="info-icon">📅</span>
<div class="info-content">
<span class="info-label">마지막 분석</span>
<span class="info-value">{{ server.lastScanAt ? formatDate(server.lastScanAt) : '-' }}</span>
</div>
</div>
</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' : (progressMap[server.id].status === 'SUCCESS' ? 'success' : 'error')">
{{ 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>
<div class="server-actions">
<button class="action-btn scan" @click="scanServer(server)" :disabled="!server.active || scanningId === server.id">
{{ scanningId === server.id ? '⏳ 분석중...' : '▶️ 분석 실행' }}
</button>
<button class="action-btn test" @click="testConnection(server)" :disabled="testingId === server.id">
{{ testingId === server.id ? '⏳' : '🔌' }}
</button>
<button class="action-btn path" @click="openLogPathModal(server)">
📁
</button>
<button class="action-btn edit" @click="openEditModal(server)">
</button>
<button class="action-btn delete" @click="confirmDelete(server)">
🗑
</button>
</div>
</Card>
</div>
<!-- 서버 추가/수정 모달 -->
<Modal v-model="showServerModal" :title="isEdit ? '서버 수정' : '서버 추가'" width="500px">
@@ -106,10 +171,10 @@
</Modal>
<!-- 로그 경로 관리 모달 -->
<Modal v-model="showLogPathModal" :title="`로그 경로 관리 - ${selectedServer?.name || ''}`" width="700px">
<Modal v-model="showLogPathModal" :title="`로그 경로 관리 - ${selectedServer?.name || ''}`" width="750px">
<div class="log-path-section">
<div class="log-path-form">
<h4>경로 추가</h4>
<h4> 경로 추가</h4>
<div class="log-path-inputs">
<FormInput
v-model="logPathForm.path"
@@ -119,7 +184,7 @@
<FormInput
v-model="logPathForm.filePattern"
label="파일 패턴"
placeholder="예: *.log, catalina.*.log"
placeholder="예: *.log"
/>
<FormInput
v-model="logPathForm.description"
@@ -128,39 +193,36 @@
/>
</div>
<Button size="sm" @click="addLogPath" :disabled="!logPathForm.path || !logPathForm.filePattern">
추가
경로 추가
</Button>
</div>
<div class="log-path-list">
<h4>등록된 경로</h4>
<table v-if="logPaths.length > 0">
<thead>
<tr>
<th>경로</th>
<th>파일 패턴</th>
<th>설명</th>
<th>활성</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="lp in logPaths" :key="lp.id">
<td>{{ lp.path }}</td>
<td>{{ lp.filePattern }}</td>
<td>{{ lp.description || '-' }}</td>
<td>
<Badge :variant="lp.active ? 'success' : 'default'">
{{ lp.active ? 'Y' : 'N' }}
<h4>📂 등록된 경로 ({{ logPaths.length }})</h4>
<div v-if="logPaths.length === 0" class="empty-paths">
등록된 경로가 없습니다.
</div>
<div v-else class="path-cards">
<div v-for="lp in logPaths" :key="lp.id" class="path-card">
<div class="path-info">
<div class="path-main">
<code class="path-value">{{ lp.path }}</code>
<span class="path-pattern">{{ lp.filePattern }}</span>
</div>
<div class="path-meta">
<span v-if="lp.description" class="path-desc">{{ lp.description }}</span>
<Badge :variant="lp.active ? 'success' : 'default'" size="sm">
{{ lp.active ? '활성' : '비활성' }}
</Badge>
</td>
<td>
<Button size="sm" variant="danger" @click="deleteLogPath(lp.id)">삭제</Button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-text">등록된 경로가 없습니다.</p>
</div>
</div>
<button class="path-delete" @click="deleteLogPath(lp.id)" title="삭제">
🗑
</button>
</div>
</div>
</div>
</div>
<template #footer>
@@ -171,7 +233,7 @@
<!-- 삭제 확인 모달 -->
<Modal v-model="showDeleteModal" title="서버 삭제" width="400px">
<p>정말로 <strong>{{ deleteTarget?.name }}</strong> 서버를 삭제하시겠습니까?</p>
<p class="warning-text">관련된 모든 로그 경로와 에러 이력도 함께 삭제됩니다.</p>
<p class="warning-text"> 관련된 모든 로그 경로와 에러 이력도 함께 삭제됩니다.</p>
<template #footer>
<Button variant="secondary" @click="showDeleteModal = false">취소</Button>
<Button variant="danger" @click="deleteServer" :loading="deleting">삭제</Button>
@@ -197,17 +259,8 @@
<script setup>
import { ref, onMounted } from 'vue'
import { DataTable, Modal, FormInput, Button, Badge, Card } from '@/components'
import { serverApi, logPathApi } from '@/api'
const columns = [
{ key: 'name', label: '서버명', width: '150px' },
{ key: 'host', label: '호스트' },
{ key: 'port', label: '포트', width: '80px' },
{ key: 'authType', label: '인증방식', width: '100px' },
{ key: 'active', label: '상태', width: '80px' },
{ key: 'lastScanAt', label: '마지막 분석', width: '150px' }
]
import { Modal, FormInput, Button, Badge, Card } from '@/components'
import { serverApi, logPathApi, scanApi } from '@/api'
const authTypeOptions = [
{ value: 'PASSWORD', label: '비밀번호' },
@@ -250,6 +303,10 @@ const logPathForm = ref({
const showDeleteModal = ref(false)
const deleteTarget = ref(null)
// Scan
const scanningId = ref(null)
const progressMap = ref({})
// Test Connection
const testingId = ref(null)
const showTestResultModal = ref(false)
@@ -348,6 +405,68 @@ const deleteServer = async () => {
}
}
// Scan Server
const scanServer = (server) => {
scanningId.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) => {
scanningId.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()
// 5초 후 진행상황 제거
setTimeout(() => {
delete progressMap.value[server.id]
}, 5000)
},
(error) => {
scanningId.value = null
progressMap.value[server.id] = {
...progressMap.value[server.id],
status: 'FAILED',
message: error
}
}
)
}
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)
}
// Test Connection
const testConnection = async (server) => {
testingId.value = server.id
@@ -415,21 +534,236 @@ onMounted(() => {
</script>
<style scoped>
.card-header-content {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
}
.loading,
.empty-state {
text-align: center;
padding: 60px;
background: white;
border-radius: 8px;
color: #666;
}
.empty-state p {
margin-bottom: 16px;
}
/* 서버 그리드 */
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
}
.server-card {
transition: box-shadow 0.2s, transform 0.2s;
}
.server-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
.server-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header-content h3 {
margin: 0;
}
.action-buttons {
.server-title {
display: flex;
gap: 4px;
align-items: center;
gap: 10px;
}
.server-title h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.server-body {
padding: 4px 0;
}
/* 진행 상황 */
.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;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 250px;
}
.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;
}
.server-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.info-icon {
font-size: 16px;
line-height: 1;
margin-top: 2px;
}
.info-content {
display: flex;
flex-direction: column;
}
.info-label {
font-size: 11px;
color: #888;
text-transform: uppercase;
}
.info-value {
font-size: 14px;
font-weight: 500;
color: #333;
}
/* 액션 버튼 */
.server-actions {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #eee;
margin-top: 12px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.action-btn.scan {
flex: 1.5;
background: #3498db;
color: white;
}
.action-btn.scan:hover:not(:disabled) {
background: #2980b9;
}
.action-btn.scan:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.action-btn.test {
flex: 0;
padding: 10px 14px;
background: #e8f8f0;
color: #27ae60;
}
.action-btn.test:hover:not(:disabled) {
background: #d4f0e3;
}
.action-btn.test:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.action-btn.path {
flex: 0;
padding: 10px 14px;
background: #e8f4fd;
color: #2980b9;
}
.action-btn.path:hover {
background: #d4e9f7;
}
.action-btn.edit {
flex: 0;
padding: 10px 14px;
background: #fef3e2;
color: #d68910;
}
.action-btn.edit:hover {
background: #fce8c9;
}
.action-btn.delete {
flex: 0;
padding: 10px 14px;
background: #fdeaea;
color: #c0392b;
}
.action-btn.delete:hover {
background: #f9d6d6;
}
/* 폼 */
.form-group {
margin-bottom: 16px;
}
@@ -441,6 +775,7 @@ onMounted(() => {
cursor: pointer;
}
/* 로그 경로 섹션 */
.log-path-section h4 {
margin: 0 0 12px 0;
font-size: 14px;
@@ -456,33 +791,90 @@ onMounted(() => {
.log-path-inputs {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-columns: 1.5fr 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.log-path-list table {
width: 100%;
border-collapse: collapse;
.log-path-list {
padding: 16px;
background: white;
border: 1px solid #eee;
border-radius: 8px;
}
.log-path-list th,
.log-path-list td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
.log-path-list th {
background: #f8f9fa;
font-weight: 600;
font-size: 13px;
}
.empty-text {
color: #6c757d;
.empty-paths {
text-align: center;
padding: 20px;
color: #888;
}
.path-cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.path-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 6px;
border-left: 3px solid #3498db;
}
.path-info {
flex: 1;
}
.path-main {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.path-value {
font-family: monospace;
font-size: 13px;
background: #e9ecef;
padding: 4px 8px;
border-radius: 4px;
}
.path-pattern {
font-size: 12px;
color: #666;
background: #fff3cd;
padding: 2px 8px;
border-radius: 4px;
}
.path-meta {
display: flex;
align-items: center;
gap: 8px;
}
.path-desc {
font-size: 12px;
color: #888;
}
.path-delete {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 8px;
border-radius: 4px;
transition: background 0.2s;
}
.path-delete:hover {
background: #fdeaea;
}
.warning-text {