1070 lines
28 KiB
Vue
1070 lines
28 KiB
Vue
<template>
|
|
<div class="app-layout">
|
|
<SidebarNav />
|
|
|
|
<div class="main-content">
|
|
<header class="main-header">
|
|
<h1 class="page-title">🔒 Private Network</h1>
|
|
<div class="header-info">
|
|
<span class="current-time">{{ currentTime }}</span>
|
|
<ThemeToggle />
|
|
</div>
|
|
</header>
|
|
|
|
<main class="main-body">
|
|
<!-- 현재 상태 -->
|
|
<section class="section">
|
|
<h2>현재 상태</h2>
|
|
<div class="status-card" v-if="status">
|
|
<div class="status-item">
|
|
<span class="label">스케줄러</span>
|
|
<span :class="['value', status.scheduler_running ? 'active' : 'inactive']">
|
|
{{ status.scheduler_running ? '실행 중' : '중지됨' }}
|
|
</span>
|
|
</div>
|
|
<div class="status-item">
|
|
<span class="label">네트워크 상태</span>
|
|
<span :class="['value', status.is_healthy ? 'healthy' : 'unhealthy']">
|
|
{{ status.is_healthy ? '✅ 정상' : '❌ 비정상' }}
|
|
</span>
|
|
</div>
|
|
<div class="status-item">
|
|
<span class="label">마지막 체크</span>
|
|
<span class="value">{{ status.last_checked_at || '-' }}</span>
|
|
</div>
|
|
<div class="status-item">
|
|
<span class="label">마지막 타겟</span>
|
|
<span class="value">{{ status.last_target_name || '-' }}</span>
|
|
</div>
|
|
<div class="status-item">
|
|
<span class="label">현재 인덱스</span>
|
|
<span class="value">{{ status.current_index }} / {{ targetCount }}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 탭 섹션 -->
|
|
<section class="section">
|
|
<div class="tab-header">
|
|
<button
|
|
:class="['tab-btn', { active: activeTab === 'heatmap' }]"
|
|
@click="switchTab('heatmap')"
|
|
>
|
|
📊 성공률 히트맵
|
|
</button>
|
|
<button
|
|
:class="['tab-btn', { active: activeTab === 'targets' }]"
|
|
@click="switchTab('targets')"
|
|
>
|
|
🎯 타겟 관리
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 히트맵 탭 -->
|
|
<div v-if="activeTab === 'heatmap'" class="tab-content">
|
|
<!-- 조회조건 + 범례 -->
|
|
<div class="heatmap-toolbar">
|
|
<!-- 기간 선택 (왼쪽) -->
|
|
<div class="period-selector">
|
|
<button class="nav-btn" @click="moveMonth(-1)">◀ 1개월</button>
|
|
<button class="nav-btn" @click="moveWeek(-1)">◀ 1주</button>
|
|
|
|
<select v-model="selectedYear" class="select-input" @change="onYearMonthChange">
|
|
<option v-for="y in availableYears" :key="y" :value="y">{{ y }}년</option>
|
|
</select>
|
|
<select v-model="selectedMonth" class="select-input" @change="onYearMonthChange">
|
|
<option v-for="m in 12" :key="m" :value="m">{{ m }}월</option>
|
|
</select>
|
|
<select v-model="selectedWeek" class="select-input" @change="fetchChartData">
|
|
<option v-for="w in totalWeeks" :key="w" :value="w">{{ w }}주차</option>
|
|
</select>
|
|
|
|
<button class="nav-btn" @click="moveWeek(1)">1주 ▶</button>
|
|
<button class="nav-btn" @click="moveMonth(1)">1개월 ▶</button>
|
|
</div>
|
|
|
|
<!-- 범례 (오른쪽) -->
|
|
<div class="legend">
|
|
<span class="legend-label">Less</span>
|
|
<div class="legend-item"><span class="legend-color" style="background: #f6f8fa;"></span></div>
|
|
<div class="legend-item"><span class="legend-color" style="background: #ef4444;"></span></div>
|
|
<div class="legend-item"><span class="legend-color" style="background: #f97316;"></span></div>
|
|
<div class="legend-item"><span class="legend-color" style="background: #eab308;"></span></div>
|
|
<div class="legend-item"><span class="legend-color" style="background: #22c55e;"></span></div>
|
|
<div class="legend-item"><span class="legend-color" style="background: #3b82f6;"></span></div>
|
|
<span class="legend-label">More</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 히트맵 -->
|
|
<div class="heatmap-container">
|
|
<div v-if="chartLoading" class="chart-loading">로딩 중...</div>
|
|
<div v-else class="heatmap-wrapper">
|
|
<!-- Y축 라벨 + 히트맵 바디 -->
|
|
<div class="heatmap-body">
|
|
<div v-for="(dateStr, idx) in weekDates" :key="dateStr" class="heatmap-row">
|
|
<div class="y-label">{{ formatDateLabel(dateStr) }}</div>
|
|
<div class="heatmap-cells">
|
|
<div
|
|
v-for="hour in 24"
|
|
:key="`${dateStr}-${hour - 1}`"
|
|
class="heatmap-cell-wrapper"
|
|
@click="selectCell(dateStr, hour - 1)"
|
|
>
|
|
<div
|
|
class="heatmap-cell"
|
|
:class="{ selected: selectedDate === dateStr && selectedHour === (hour - 1) }"
|
|
:style="{ background: getCellColor(dateStr, hour - 1) }"
|
|
:title="getCellTooltip(dateStr, hour - 1)"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- X축 푸터 (시간) -->
|
|
<div class="heatmap-footer">
|
|
<div class="heatmap-corner"></div>
|
|
<div class="heatmap-x-labels">
|
|
<span v-for="h in 24" :key="h" class="x-label">{{ h - 1 }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 선택한 시간대 로그 -->
|
|
<div class="selected-logs">
|
|
<div class="logs-header">
|
|
<h3>📋 {{ selectedDate }} {{ String(selectedHour).padStart(2, '0') }}시 로그</h3>
|
|
<span v-if="selectedLogs.length > 0" class="logs-summary">
|
|
테스트 결과 <strong>{{ successCount }}/{{ selectedLogs.length }}</strong> 성공률 <strong :class="successRateClass">{{ successRate }}%</strong>
|
|
</span>
|
|
</div>
|
|
<div v-if="logsLoading" class="logs-loading">로딩 중...</div>
|
|
<div v-else-if="selectedLogs.length === 0" class="no-data">해당 시간대에 로그가 없습니다.</div>
|
|
<table v-else>
|
|
<thead>
|
|
<tr>
|
|
<th>시간</th>
|
|
<th>상태</th>
|
|
<th>타겟</th>
|
|
<th>URL</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="log in selectedLogs" :key="log.id">
|
|
<td>{{ log.checked_at }}</td>
|
|
<td>{{ log.is_success ? '✅' : '❌' }}</td>
|
|
<td>{{ log.target_name }}</td>
|
|
<td class="url-cell">{{ log.target_url }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 타겟 관리 탭 -->
|
|
<div v-if="activeTab === 'targets'" class="tab-content">
|
|
<div class="add-form">
|
|
<input
|
|
v-model="newTarget.name"
|
|
type="text"
|
|
placeholder="이름 (예: Gateway)"
|
|
class="input-field"
|
|
/>
|
|
<input
|
|
v-model="newTarget.url"
|
|
type="text"
|
|
placeholder="URL (예: http://192.168.1.1)"
|
|
class="input-field url-input"
|
|
/>
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" v-model="newTarget.is_active" />
|
|
활성
|
|
</label>
|
|
<button class="btn btn-add" @click="addTarget" :disabled="!canAdd">
|
|
추가
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="targets.length === 0" class="no-data">
|
|
등록된 타겟이 없습니다.
|
|
</div>
|
|
<table v-else class="target-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 60px;">ID</th>
|
|
<th style="width: 120px;">이름</th>
|
|
<th>URL</th>
|
|
<th style="width: 60px;">상태</th>
|
|
<th style="width: 120px;">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(target, index) in targets" :key="target.id">
|
|
<td>{{ target.id }}</td>
|
|
<td>
|
|
<input v-if="editingId === target.id" v-model="editTarget.name" type="text" class="edit-input" />
|
|
<span v-else>{{ target.name }}</span>
|
|
</td>
|
|
<td>
|
|
<input v-if="editingId === target.id" v-model="editTarget.url" type="text" class="edit-input" />
|
|
<span v-else class="url-cell">{{ target.url }}</span>
|
|
</td>
|
|
<td>
|
|
<label v-if="editingId === target.id" class="checkbox-label">
|
|
<input type="checkbox" v-model="editTarget.is_active" />
|
|
</label>
|
|
<span v-else>{{ target.is_active ? '✅' : '❌' }}</span>
|
|
</td>
|
|
<td>
|
|
<div v-if="editingId === target.id" class="action-buttons">
|
|
<button class="btn btn-save" @click="saveEdit">저장</button>
|
|
<button class="btn btn-cancel" @click="cancelEdit">취소</button>
|
|
</div>
|
|
<div v-else class="action-buttons">
|
|
<button class="btn btn-edit" @click="startEdit(target)">수정</button>
|
|
<button class="btn btn-delete" @click="deleteTarget(target.id)">삭제</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
interface Target {
|
|
id: number
|
|
name: string
|
|
url: string
|
|
is_active: number
|
|
}
|
|
|
|
interface HeatmapData {
|
|
date: string
|
|
time_slot: string
|
|
total_count: number
|
|
success_count: number
|
|
success_rate: number
|
|
}
|
|
|
|
const status = ref<any>(null)
|
|
const targets = ref<Target[]>([])
|
|
const targetCount = ref(0)
|
|
const currentTime = ref('')
|
|
const activeTab = ref<'heatmap' | 'targets'>('heatmap')
|
|
|
|
// 타겟 관리
|
|
const editingId = ref<number | null>(null)
|
|
const newTarget = ref({ name: '', url: '', is_active: true })
|
|
const editTarget = ref({ name: '', url: '', is_active: true })
|
|
const canAdd = computed(() => newTarget.value.name.trim() && newTarget.value.url.trim())
|
|
|
|
// 성공 건수 및 성공률 계산
|
|
const successCount = computed(() => selectedLogs.value.filter(log => log.is_success).length)
|
|
const successRate = computed(() => {
|
|
if (selectedLogs.value.length === 0) return 0
|
|
return Math.round((successCount.value / selectedLogs.value.length) * 100)
|
|
})
|
|
const successRateClass = computed(() => {
|
|
const rate = successRate.value
|
|
if (rate >= 90) return 'rate-excellent'
|
|
if (rate >= 80) return 'rate-good'
|
|
if (rate >= 70) return 'rate-warning'
|
|
return 'rate-danger'
|
|
})
|
|
|
|
// 탭 전환
|
|
function switchTab(tab: 'heatmap' | 'targets') {
|
|
activeTab.value = tab
|
|
if (tab === 'targets') {
|
|
fetchTargets()
|
|
} else {
|
|
fetchChartData()
|
|
fetchSelectedLogs()
|
|
}
|
|
}
|
|
|
|
// 차트 관련
|
|
const now = new Date()
|
|
const selectedYear = ref(now.getFullYear())
|
|
const selectedMonth = ref(now.getMonth() + 1)
|
|
const selectedWeek = ref(1)
|
|
const totalWeeks = ref(5)
|
|
const weekDates = ref<string[]>([])
|
|
const heatmapData = ref<HeatmapData[]>([])
|
|
const chartLoading = ref(false)
|
|
|
|
// 선택한 셀 (기본값: 오늘 날짜/현재 시간)
|
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
const selectedDate = ref<string>(todayStr)
|
|
const selectedHour = ref<number>(now.getHours())
|
|
const selectedLogs = ref<any[]>([])
|
|
const logsLoading = ref(false)
|
|
|
|
// 년도 목록 (최근 3년)
|
|
const availableYears = computed(() => {
|
|
const current = new Date().getFullYear()
|
|
return [current - 2, current - 1, current]
|
|
})
|
|
|
|
// 요일 이름
|
|
const dayNames = ['일', '월', '화', '수', '목', '금', '토']
|
|
|
|
// 날짜 라벨 포맷 (yyyy-mm-dd(요일))
|
|
function formatDateLabel(dateStr: string): string {
|
|
const d = new Date(dateStr)
|
|
const dayName = dayNames[d.getDay()]
|
|
return `${dateStr}(${dayName})`
|
|
}
|
|
|
|
// 히트맵 데이터를 Map으로 변환 (빠른 조회용)
|
|
const heatmapMap = computed(() => {
|
|
const map = new Map<string, number>()
|
|
for (const d of heatmapData.value) {
|
|
const hourStr = d.time_slot.split(':')[0]
|
|
map.set(`${d.date}-${parseInt(hourStr)}`, d.success_rate)
|
|
}
|
|
return map
|
|
})
|
|
|
|
// 셀 색상 결정
|
|
function getCellColor(dateStr: string, hour: number): string {
|
|
const rate = heatmapMap.value.get(`${dateStr}-${hour}`)
|
|
if (rate === undefined) return '#f6f8fa'
|
|
if (rate >= 90) return '#3b82f6'
|
|
if (rate >= 80) return '#22c55e'
|
|
if (rate >= 70) return '#eab308'
|
|
if (rate >= 60) return '#f97316'
|
|
return '#ef4444'
|
|
}
|
|
|
|
// 셀 툴팁
|
|
function getCellTooltip(dateStr: string, hour: number): string {
|
|
const rate = heatmapMap.value.get(`${dateStr}-${hour}`)
|
|
if (rate === undefined) return `${dateStr} ${String(hour).padStart(2, '0')}시 - 데이터 없음`
|
|
return `${dateStr} ${String(hour).padStart(2, '0')}시 - 성공률: ${rate}%`
|
|
}
|
|
|
|
// 셀 클릭
|
|
async function selectCell(dateStr: string, hour: number) {
|
|
selectedDate.value = dateStr
|
|
selectedHour.value = hour
|
|
await fetchSelectedLogs()
|
|
}
|
|
|
|
// 오늘이 포함된 주차 계산
|
|
function getWeekOfDate(year: number, month: number, date: Date): number {
|
|
const firstDayOfMonth = new Date(year, month - 1, 1)
|
|
const firstDayWeekday = firstDayOfMonth.getDay()
|
|
const mondayOffset = firstDayWeekday === 0 ? -6 : 1 - firstDayWeekday
|
|
const firstMondayOfMonth = new Date(year, month - 1, 1 + mondayOffset)
|
|
|
|
const diffDays = Math.floor((date.getTime() - firstMondayOfMonth.getTime()) / (1000 * 60 * 60 * 24))
|
|
return Math.floor(diffDays / 7) + 1
|
|
}
|
|
|
|
// 년월 변경 시
|
|
function onYearMonthChange() {
|
|
// 현재 월에 해당하는 주차 수 계산
|
|
const y = selectedYear.value
|
|
const m = selectedMonth.value
|
|
|
|
const firstDayOfMonth = new Date(y, m - 1, 1)
|
|
const firstDayWeekday = firstDayOfMonth.getDay()
|
|
const mondayOffset = firstDayWeekday === 0 ? -6 : 1 - firstDayWeekday
|
|
const firstMondayOfMonth = new Date(y, m - 1, 1 + mondayOffset)
|
|
|
|
const lastDayOfMonth = new Date(y, m, 0)
|
|
const lastDayFromFirstMonday = Math.floor((lastDayOfMonth.getTime() - firstMondayOfMonth.getTime()) / (1000 * 60 * 60 * 24))
|
|
totalWeeks.value = Math.max(Math.ceil((lastDayFromFirstMonday + 1) / 7), 1)
|
|
|
|
// 주차가 범위를 벗어나면 조정
|
|
if (selectedWeek.value > totalWeeks.value) {
|
|
selectedWeek.value = totalWeeks.value
|
|
}
|
|
|
|
fetchChartData()
|
|
}
|
|
|
|
// 주 이동
|
|
function moveWeek(delta: number) {
|
|
let newWeek = selectedWeek.value + delta
|
|
let newMonth = selectedMonth.value
|
|
let newYear = selectedYear.value
|
|
|
|
if (newWeek < 1) {
|
|
// 이전 달로
|
|
newMonth--
|
|
if (newMonth < 1) {
|
|
newMonth = 12
|
|
newYear--
|
|
}
|
|
selectedYear.value = newYear
|
|
selectedMonth.value = newMonth
|
|
onYearMonthChange()
|
|
selectedWeek.value = totalWeeks.value
|
|
} else if (newWeek > totalWeeks.value) {
|
|
// 다음 달로
|
|
newMonth++
|
|
if (newMonth > 12) {
|
|
newMonth = 1
|
|
newYear++
|
|
}
|
|
selectedYear.value = newYear
|
|
selectedMonth.value = newMonth
|
|
onYearMonthChange()
|
|
selectedWeek.value = 1
|
|
} else {
|
|
selectedWeek.value = newWeek
|
|
}
|
|
|
|
fetchChartData()
|
|
}
|
|
|
|
// 월 이동
|
|
function moveMonth(delta: number) {
|
|
let newMonth = selectedMonth.value + delta
|
|
let newYear = selectedYear.value
|
|
|
|
if (newMonth < 1) {
|
|
newMonth = 12
|
|
newYear--
|
|
} else if (newMonth > 12) {
|
|
newMonth = 1
|
|
newYear++
|
|
}
|
|
|
|
selectedYear.value = newYear
|
|
selectedMonth.value = newMonth
|
|
selectedWeek.value = 1
|
|
onYearMonthChange()
|
|
}
|
|
|
|
// 선택한 시간대 로그 조회
|
|
async function fetchSelectedLogs() {
|
|
logsLoading.value = true
|
|
try {
|
|
const [year, month, day] = selectedDate.value.split('-')
|
|
const res = await $fetch('/api/network/privnet/logs', {
|
|
query: {
|
|
year,
|
|
month,
|
|
day,
|
|
hour: String(selectedHour.value)
|
|
}
|
|
})
|
|
selectedLogs.value = (res as any).logs || []
|
|
} catch (err) {
|
|
console.error('Failed to fetch logs:', err)
|
|
selectedLogs.value = []
|
|
} finally {
|
|
logsLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function fetchChartData() {
|
|
chartLoading.value = true
|
|
|
|
try {
|
|
const res = await $fetch('/api/network/privnet/chart', {
|
|
query: {
|
|
year: String(selectedYear.value),
|
|
month: String(selectedMonth.value),
|
|
week: String(selectedWeek.value)
|
|
}
|
|
})
|
|
heatmapData.value = (res as any).heatmapData || []
|
|
weekDates.value = (res as any).weekDates || []
|
|
totalWeeks.value = (res as any).totalWeeks || 5
|
|
} catch (err) {
|
|
console.error('Failed to fetch chart data:', err)
|
|
heatmapData.value = []
|
|
weekDates.value = []
|
|
} finally {
|
|
chartLoading.value = false
|
|
}
|
|
}
|
|
|
|
let timeInterval: ReturnType<typeof setInterval> | null = null
|
|
|
|
function formatTime(date: Date): string {
|
|
const y = date.getFullYear()
|
|
const M = String(date.getMonth() + 1).padStart(2, '0')
|
|
const d = String(date.getDate()).padStart(2, '0')
|
|
const h = String(date.getHours()).padStart(2, '0')
|
|
const m = String(date.getMinutes()).padStart(2, '0')
|
|
const s = String(date.getSeconds()).padStart(2, '0')
|
|
return `${y}-${M}-${d} ${h}:${m}:${s}`
|
|
}
|
|
|
|
async function fetchStatus() {
|
|
try {
|
|
const res = await $fetch('/api/network/privnet/status')
|
|
status.value = (res as any).status
|
|
targetCount.value = (res as any).targetCount || 0
|
|
} catch (err) {
|
|
console.error('Failed to fetch status:', err)
|
|
}
|
|
}
|
|
|
|
async function fetchTargets() {
|
|
try {
|
|
const res = await $fetch('/api/network/privnet/targets')
|
|
targets.value = (res as Target[]) || []
|
|
} catch (err) {
|
|
console.error('Failed to fetch targets:', err)
|
|
}
|
|
}
|
|
|
|
async function addTarget() {
|
|
if (!canAdd.value) return
|
|
try {
|
|
await $fetch('/api/network/privnet/targets', {
|
|
method: 'POST',
|
|
body: newTarget.value
|
|
})
|
|
newTarget.value = { name: '', url: '', is_active: true }
|
|
await fetchTargets()
|
|
} catch (err) {
|
|
console.error('Failed to add target:', err)
|
|
}
|
|
}
|
|
|
|
function startEdit(target: Target) {
|
|
editingId.value = target.id
|
|
editTarget.value = {
|
|
name: target.name,
|
|
url: target.url,
|
|
is_active: !!target.is_active
|
|
}
|
|
}
|
|
|
|
function cancelEdit() {
|
|
editingId.value = null
|
|
}
|
|
|
|
async function saveEdit() {
|
|
if (!editingId.value) return
|
|
try {
|
|
await $fetch('/api/network/privnet/targets', {
|
|
method: 'PUT',
|
|
body: { id: editingId.value, ...editTarget.value }
|
|
})
|
|
editingId.value = null
|
|
await fetchTargets()
|
|
} catch (err) {
|
|
console.error('Failed to save target:', err)
|
|
}
|
|
}
|
|
|
|
async function deleteTarget(id: number) {
|
|
if (!confirm('정말 삭제하시겠습니까?')) return
|
|
try {
|
|
await $fetch('/api/network/privnet/targets', {
|
|
method: 'DELETE',
|
|
body: { id }
|
|
})
|
|
await fetchTargets()
|
|
} catch (err) {
|
|
console.error('Failed to delete target:', err)
|
|
}
|
|
}
|
|
|
|
// 초기화: 오늘이 포함된 주차 설정
|
|
function initWeek() {
|
|
const today = new Date()
|
|
selectedYear.value = today.getFullYear()
|
|
selectedMonth.value = today.getMonth() + 1
|
|
|
|
// 주차 수 계산
|
|
const y = selectedYear.value
|
|
const m = selectedMonth.value
|
|
const firstDayOfMonth = new Date(y, m - 1, 1)
|
|
const firstDayWeekday = firstDayOfMonth.getDay()
|
|
const mondayOffset = firstDayWeekday === 0 ? -6 : 1 - firstDayWeekday
|
|
const firstMondayOfMonth = new Date(y, m - 1, 1 + mondayOffset)
|
|
|
|
const lastDayOfMonth = new Date(y, m, 0)
|
|
const lastDayFromFirstMonday = Math.floor((lastDayOfMonth.getTime() - firstMondayOfMonth.getTime()) / (1000 * 60 * 60 * 24))
|
|
totalWeeks.value = Math.max(Math.ceil((lastDayFromFirstMonday + 1) / 7), 1)
|
|
|
|
// 오늘이 포함된 주차
|
|
selectedWeek.value = getWeekOfDate(y, m, today)
|
|
if (selectedWeek.value < 1) selectedWeek.value = 1
|
|
if (selectedWeek.value > totalWeeks.value) selectedWeek.value = totalWeeks.value
|
|
}
|
|
|
|
onMounted(() => {
|
|
currentTime.value = formatTime(new Date())
|
|
timeInterval = setInterval(() => {
|
|
currentTime.value = formatTime(new Date())
|
|
}, 1000)
|
|
|
|
initWeek()
|
|
fetchStatus()
|
|
fetchTargets()
|
|
fetchChartData()
|
|
fetchSelectedLogs() // 오늘 날짜/시간 로그 로드
|
|
|
|
setInterval(fetchStatus, 60000)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (timeInterval) clearInterval(timeInterval)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.app-layout {
|
|
display: flex;
|
|
min-height: 100vh;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.main-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.main-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 16px 24px;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.page-title {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
}
|
|
|
|
.header-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.current-time {
|
|
font-family: monospace;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.main-body {
|
|
flex: 1;
|
|
padding: 24px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.section {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.section h2 {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
margin: 0 0 16px 0;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Status Card */
|
|
.status-card {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 24px;
|
|
}
|
|
|
|
.status-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.status-item .label {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.status-item .value {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.status-item .value.active { color: var(--success-border); }
|
|
.status-item .value.inactive { color: var(--fail-border); }
|
|
.status-item .value.healthy { color: var(--success-border); }
|
|
.status-item .value.unhealthy { color: var(--fail-border); }
|
|
|
|
/* Tab */
|
|
.tab-header {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 16px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding-bottom: 8px;
|
|
}
|
|
|
|
.tab-btn {
|
|
padding: 8px 16px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.tab-btn.active {
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.tab-content {
|
|
min-height: 200px;
|
|
}
|
|
|
|
/* Heatmap Toolbar */
|
|
.heatmap-toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
}
|
|
|
|
/* Period Selector */
|
|
.period-selector {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
|
|
.select-input {
|
|
padding: 6px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
height: 32px;
|
|
}
|
|
|
|
.nav-btn {
|
|
padding: 6px 14px;
|
|
border: 1px solid var(--btn-primary-border);
|
|
border-radius: 4px;
|
|
background: var(--btn-primary-bg);
|
|
color: #fff;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
height: 32px;
|
|
}
|
|
|
|
.nav-btn:hover {
|
|
background: var(--btn-primary-hover);
|
|
}
|
|
|
|
/* Legend */
|
|
.legend {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.legend-label {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
margin: 0 4px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.legend-color {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
/* Heatmap */
|
|
.heatmap-container {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.heatmap-wrapper {
|
|
min-width: 700px;
|
|
}
|
|
|
|
.heatmap-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.heatmap-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
height: 26px;
|
|
}
|
|
|
|
.y-label {
|
|
width: 120px;
|
|
flex-shrink: 0;
|
|
font-size: 12px;
|
|
color: var(--text-primary);
|
|
text-align: right;
|
|
padding-right: 8px;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.heatmap-cells {
|
|
display: flex;
|
|
flex: 1;
|
|
}
|
|
|
|
.heatmap-cell-wrapper {
|
|
flex: 1;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 2px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.heatmap-cell {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 3px;
|
|
transition: transform 0.1s, box-shadow 0.1s;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.heatmap-cell:hover {
|
|
transform: scale(1.15);
|
|
box-shadow: 0 0 0 1px var(--text-primary);
|
|
z-index: 1;
|
|
}
|
|
|
|
.heatmap-cell.selected {
|
|
outline: 2px solid var(--text-primary);
|
|
outline-offset: 1px;
|
|
}
|
|
|
|
.heatmap-footer {
|
|
display: flex;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.heatmap-corner {
|
|
width: 120px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.heatmap-x-labels {
|
|
display: flex;
|
|
flex: 1;
|
|
}
|
|
|
|
.x-label {
|
|
flex: 1;
|
|
text-align: center;
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
padding: 4px 0;
|
|
}
|
|
|
|
.chart-loading,
|
|
.logs-loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Selected Logs */
|
|
.selected-logs {
|
|
margin-top: 24px;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.logs-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.selected-logs h3 {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.logs-summary {
|
|
font-size: 13px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.logs-summary strong {
|
|
color: var(--text-primary);
|
|
margin: 0 2px;
|
|
}
|
|
|
|
.rate-excellent { color: #3b82f6; }
|
|
.rate-good { color: #22c55e; }
|
|
.rate-warning { color: #eab308; }
|
|
.rate-danger { color: #ef4444; }
|
|
|
|
/* Table */
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 13px;
|
|
}
|
|
|
|
th, td {
|
|
padding: 10px 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
th {
|
|
background: var(--bg-primary);
|
|
color: var(--text-muted);
|
|
font-weight: 500;
|
|
}
|
|
|
|
td {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Form */
|
|
.add-form {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.input-field {
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.input-field.url-input {
|
|
flex: 1;
|
|
min-width: 200px;
|
|
}
|
|
|
|
.checkbox-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
color: var(--text-primary);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.btn {
|
|
padding: 8px 16px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-add { background: #3b82f6; color: white; }
|
|
.btn-edit { background: #6b7280; color: white; padding: 4px 8px; font-size: 12px; }
|
|
.btn-save { background: #22c55e; color: white; padding: 4px 8px; font-size: 12px; }
|
|
.btn-cancel { background: #6b7280; color: white; padding: 4px 8px; font-size: 12px; }
|
|
.btn-delete { background: #ef4444; color: white; padding: 4px 8px; font-size: 12px; }
|
|
|
|
/* Target Table */
|
|
.target-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.target-table th,
|
|
.target-table td {
|
|
padding: 10px 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.target-table th {
|
|
background: var(--bg-primary);
|
|
color: var(--text-muted);
|
|
font-weight: 500;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.target-table td {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.url-cell {
|
|
max-width: 300px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--text-muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.edit-input {
|
|
width: 100%;
|
|
padding: 4px 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.no-data {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: var(--text-muted);
|
|
}
|
|
</style>
|