605 lines
13 KiB
Vue
605 lines
13 KiB
Vue
<template>
|
||
<div class="pattern-manage">
|
||
<div class="page-header">
|
||
<h2>패턴 관리</h2>
|
||
<Button @click="openAddModal">+ 패턴 추가</Button>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<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">
|
||
<form @submit.prevent="savePattern">
|
||
<FormInput
|
||
v-model="form.name"
|
||
label="패턴명"
|
||
placeholder="예: NullPointerException"
|
||
required
|
||
/>
|
||
<FormInput
|
||
v-model="form.regex"
|
||
label="정규식"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder="예: (Exception|Error|SEVERE|FATAL)"
|
||
required
|
||
hint="Java 정규식 문법을 사용합니다."
|
||
/>
|
||
<FormInput
|
||
v-model="form.excludeRegex"
|
||
label="제외 정규식"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="예: throws\\s+(Exception|java\\.lang\\.Exception)"
|
||
hint="이 패턴에 매칭되면 에러에서 제외됩니다. (선택)"
|
||
/>
|
||
<FormInput
|
||
v-model="form.severity"
|
||
label="심각도"
|
||
type="select"
|
||
:options="severityOptions"
|
||
required
|
||
/>
|
||
<FormInput
|
||
v-model="form.contextLines"
|
||
label="컨텍스트 라인 수"
|
||
type="number"
|
||
placeholder="5"
|
||
hint="에러 전후로 캡처할 라인 수"
|
||
/>
|
||
<FormInput
|
||
v-model="form.description"
|
||
label="설명"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="이 패턴에 대한 설명"
|
||
/>
|
||
<div class="form-group">
|
||
<label>
|
||
<input type="checkbox" v-model="form.active" />
|
||
활성화
|
||
</label>
|
||
</div>
|
||
</form>
|
||
<template #footer>
|
||
<Button variant="secondary" @click="showPatternModal = false">취소</Button>
|
||
<Button @click="savePattern" :loading="saving">저장</Button>
|
||
</template>
|
||
</Modal>
|
||
|
||
<!-- 패턴 테스트 모달 -->
|
||
<Modal v-model="showTestModal" :title="`패턴 테스트 - ${testTarget?.name || ''}`" width="700px">
|
||
<div class="test-section">
|
||
<div class="test-pattern">
|
||
<label>정규식</label>
|
||
<code class="regex-display">{{ testTarget?.regex }}</code>
|
||
</div>
|
||
|
||
<FormInput
|
||
v-model="testSampleText"
|
||
label="테스트할 텍스트"
|
||
type="textarea"
|
||
:rows="6"
|
||
placeholder="로그 텍스트를 붙여넣으세요..."
|
||
/>
|
||
|
||
<Button @click="runPatternTest" :loading="testing" :disabled="!testSampleText">
|
||
테스트 실행
|
||
</Button>
|
||
|
||
<div v-if="testResult" class="test-result" :class="{ success: testResult.matched, fail: !testResult.matched }">
|
||
<h4>테스트 결과</h4>
|
||
<div v-if="!testResult.validRegex" class="error-msg">
|
||
❌ 정규식 오류: {{ testResult.errorMessage }}
|
||
</div>
|
||
<div v-else-if="testResult.matched">
|
||
<p>✅ 매칭 성공!</p>
|
||
<div class="match-info">
|
||
<label>매칭된 텍스트:</label>
|
||
<code>{{ testResult.matchedText }}</code>
|
||
</div>
|
||
<div class="match-info">
|
||
<label>위치:</label>
|
||
<span>{{ testResult.matchStart }} ~ {{ testResult.matchEnd }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-else>
|
||
<p>❌ 매칭 없음</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<Button variant="secondary" @click="showTestModal = false">닫기</Button>
|
||
</template>
|
||
</Modal>
|
||
|
||
<!-- 삭제 확인 모달 -->
|
||
<Modal v-model="showDeleteModal" title="패턴 삭제" width="400px">
|
||
<p>정말로 <strong>{{ deleteTarget?.name }}</strong> 패턴을 삭제하시겠습니까?</p>
|
||
<template #footer>
|
||
<Button variant="secondary" @click="showDeleteModal = false">취소</Button>
|
||
<Button variant="danger" @click="deletePattern" :loading="deleting">삭제</Button>
|
||
</template>
|
||
</Modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted } from 'vue'
|
||
import { Modal, FormInput, Button, Badge, Card } from '@/components'
|
||
import { patternApi } from '@/api'
|
||
|
||
const severityOptions = [
|
||
{ value: 'CRITICAL', label: 'CRITICAL' },
|
||
{ value: 'ERROR', label: 'ERROR' },
|
||
{ value: 'WARN', label: 'WARN' }
|
||
]
|
||
|
||
// State
|
||
const patterns = ref([])
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const deleting = ref(false)
|
||
const testing = ref(false)
|
||
|
||
// Pattern Modal
|
||
const showPatternModal = ref(false)
|
||
const isEdit = ref(false)
|
||
const editId = ref(null)
|
||
const form = ref({
|
||
name: '',
|
||
regex: '',
|
||
excludeRegex: '',
|
||
severity: 'ERROR',
|
||
contextLines: 5,
|
||
description: '',
|
||
active: true
|
||
})
|
||
|
||
// Test Modal
|
||
const showTestModal = ref(false)
|
||
const testTarget = ref(null)
|
||
const testSampleText = ref('')
|
||
const testResult = ref(null)
|
||
|
||
// Delete Modal
|
||
const showDeleteModal = ref(false)
|
||
const deleteTarget = ref(null)
|
||
|
||
// Load patterns
|
||
const loadPatterns = async () => {
|
||
loading.value = true
|
||
try {
|
||
patterns.value = await patternApi.getAll()
|
||
} catch (e) {
|
||
console.error('Failed to load patterns:', e)
|
||
alert('패턴 목록을 불러오는데 실패했습니다.')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// Open Add Modal
|
||
const openAddModal = () => {
|
||
isEdit.value = false
|
||
editId.value = null
|
||
form.value = {
|
||
name: '',
|
||
regex: '',
|
||
excludeRegex: '',
|
||
severity: 'ERROR',
|
||
contextLines: 5,
|
||
description: '',
|
||
active: true
|
||
}
|
||
showPatternModal.value = true
|
||
}
|
||
|
||
// Open Edit Modal
|
||
const openEditModal = (pattern) => {
|
||
isEdit.value = true
|
||
editId.value = pattern.id
|
||
form.value = {
|
||
name: pattern.name,
|
||
regex: pattern.regex,
|
||
excludeRegex: pattern.excludeRegex || '',
|
||
severity: pattern.severity,
|
||
contextLines: pattern.contextLines,
|
||
description: pattern.description || '',
|
||
active: pattern.active
|
||
}
|
||
showPatternModal.value = true
|
||
}
|
||
|
||
// Save Pattern
|
||
const savePattern = async () => {
|
||
if (!form.value.name || !form.value.regex) {
|
||
alert('필수 항목을 입력해주세요.')
|
||
return
|
||
}
|
||
|
||
saving.value = true
|
||
try {
|
||
if (isEdit.value) {
|
||
await patternApi.update(editId.value, form.value)
|
||
} else {
|
||
await patternApi.create(form.value)
|
||
}
|
||
showPatternModal.value = false
|
||
await loadPatterns()
|
||
} catch (e) {
|
||
console.error('Failed to save pattern:', e)
|
||
alert('저장에 실패했습니다. 정규식 문법을 확인해주세요.')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
// Delete
|
||
const confirmDelete = (pattern) => {
|
||
deleteTarget.value = pattern
|
||
showDeleteModal.value = true
|
||
}
|
||
|
||
const deletePattern = async () => {
|
||
deleting.value = true
|
||
try {
|
||
await patternApi.delete(deleteTarget.value.id)
|
||
showDeleteModal.value = false
|
||
await loadPatterns()
|
||
} catch (e) {
|
||
console.error('Failed to delete pattern:', e)
|
||
alert('삭제에 실패했습니다.')
|
||
} finally {
|
||
deleting.value = false
|
||
}
|
||
}
|
||
|
||
// Test Modal
|
||
const openTestModal = (pattern) => {
|
||
testTarget.value = pattern
|
||
testSampleText.value = ''
|
||
testResult.value = null
|
||
showTestModal.value = true
|
||
}
|
||
|
||
const runPatternTest = async () => {
|
||
if (!testSampleText.value) return
|
||
|
||
testing.value = true
|
||
try {
|
||
testResult.value = await patternApi.test(testTarget.value.regex, testSampleText.value)
|
||
} catch (e) {
|
||
console.error('Failed to test pattern:', e)
|
||
alert('테스트 실행에 실패했습니다.')
|
||
} finally {
|
||
testing.value = false
|
||
}
|
||
}
|
||
|
||
// Utils
|
||
const getSeverityVariant = (severity) => {
|
||
const map = {
|
||
'CRITICAL': 'critical',
|
||
'ERROR': 'error',
|
||
'WARN': 'warn'
|
||
}
|
||
return map[severity] || 'default'
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadPatterns()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.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;
|
||
}
|
||
|
||
.pattern-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.form-group label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* 테스트 섹션 */
|
||
.test-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.test-pattern label {
|
||
display: block;
|
||
font-weight: 500;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.regex-display {
|
||
display: block;
|
||
font-family: monospace;
|
||
background: #f8f9fa;
|
||
padding: 12px;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.test-result {
|
||
padding: 16px;
|
||
border-radius: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.test-result.success {
|
||
background: #d4edda;
|
||
border: 1px solid #c3e6cb;
|
||
}
|
||
|
||
.test-result.fail {
|
||
background: #f8d7da;
|
||
border: 1px solid #f5c6cb;
|
||
}
|
||
|
||
.test-result h4 {
|
||
margin: 0 0 12px 0;
|
||
}
|
||
|
||
.test-result p {
|
||
margin: 0;
|
||
}
|
||
|
||
.match-info {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.match-info label {
|
||
font-weight: 500;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.match-info code {
|
||
background: rgba(0,0,0,0.1);
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.error-msg {
|
||
color: #721c24;
|
||
}
|
||
</style>
|