Files
system-monitor/frontend/network/privnet.vue
2025-12-28 12:03:48 +09:00

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>