516 lines
13 KiB
Vue
516 lines
13 KiB
Vue
<template>
|
||
<div class="app-layout">
|
||
<SidebarNav />
|
||
|
||
<div class="main-content">
|
||
<header class="main-header">
|
||
<h1 class="page-title">🖥️ Server Targets</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="scheduler-card">
|
||
<div class="status-item">
|
||
<span class="label">상태</span>
|
||
<span :class="['value', schedulerStatus.is_running ? 'active' : 'inactive']">
|
||
{{ schedulerStatus.is_running ? '실행 중' : '중지됨' }}
|
||
</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="label">활성 타이머</span>
|
||
<span class="value">{{ schedulerStatus.active_timers }} / {{ schedulerStatus.total_targets }}</span>
|
||
</div>
|
||
<div class="scheduler-controls">
|
||
<button class="btn btn-start" @click="startScheduler" :disabled="schedulerStatus.is_running">
|
||
▶ 시작
|
||
</button>
|
||
<button class="btn btn-stop" @click="stopScheduler" :disabled="!schedulerStatus.is_running">
|
||
⏹ 중지
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 서버 목록 관리 -->
|
||
<section class="section">
|
||
<h2>서버 목록 관리</h2>
|
||
|
||
<!-- 추가 폼 -->
|
||
<div class="add-form">
|
||
<input
|
||
v-model="newTarget.server_name"
|
||
type="text"
|
||
placeholder="서버명"
|
||
class="input-field"
|
||
style="width: 120px;"
|
||
/>
|
||
<input
|
||
v-model="newTarget.server_ip"
|
||
type="text"
|
||
placeholder="IP"
|
||
class="input-field"
|
||
style="width: 130px;"
|
||
/>
|
||
<input
|
||
v-model="newTarget.glances_url"
|
||
type="text"
|
||
placeholder="Glances URL"
|
||
class="input-field url-input"
|
||
/>
|
||
<input
|
||
v-model.number="newTarget.collect_interval"
|
||
type="number"
|
||
placeholder="주기(초)"
|
||
class="input-field"
|
||
style="width: 80px;"
|
||
min="10"
|
||
/>
|
||
<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>
|
||
<div v-else>
|
||
<p class="total-count">총 {{ targets.length }}개</p>
|
||
<table class="target-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 50px;">ID</th>
|
||
<th style="width: 100px;">서버명</th>
|
||
<th style="width: 120px;">IP</th>
|
||
<th>Glances URL</th>
|
||
<th style="width: 70px;">주기(초)</th>
|
||
<th style="width: 50px;">상태</th>
|
||
<th style="width: 120px;">관리</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="target in targets" :key="target.target_id">
|
||
<td>{{ target.target_id }}</td>
|
||
<td>
|
||
<input v-if="editingId === target.target_id" v-model="editTarget.server_name" type="text" class="edit-input" />
|
||
<span v-else>{{ target.server_name }}</span>
|
||
</td>
|
||
<td>
|
||
<input v-if="editingId === target.target_id" v-model="editTarget.server_ip" type="text" class="edit-input" />
|
||
<span v-else>{{ target.server_ip }}</span>
|
||
</td>
|
||
<td>
|
||
<input v-if="editingId === target.target_id" v-model="editTarget.glances_url" type="text" class="edit-input" />
|
||
<span v-else class="url-cell">{{ target.glances_url }}</span>
|
||
</td>
|
||
<td>
|
||
<input v-if="editingId === target.target_id" v-model.number="editTarget.collect_interval" type="number" class="edit-input" min="10" />
|
||
<span v-else>{{ target.collect_interval }}</span>
|
||
</td>
|
||
<td>
|
||
<label v-if="editingId === target.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.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.target_id)">삭제</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
interface ServerTarget {
|
||
target_id: number
|
||
server_name: string
|
||
server_ip: string
|
||
glances_url: string
|
||
is_active: number
|
||
collect_interval: number
|
||
}
|
||
|
||
interface SchedulerStatus {
|
||
is_running: boolean
|
||
active_timers: number
|
||
total_targets: number
|
||
}
|
||
|
||
const targets = ref<ServerTarget[]>([])
|
||
const currentTime = ref('')
|
||
const editingId = ref<number | null>(null)
|
||
const schedulerStatus = ref<SchedulerStatus>({
|
||
is_running: false,
|
||
active_timers: 0,
|
||
total_targets: 0
|
||
})
|
||
|
||
const newTarget = ref({
|
||
server_name: '',
|
||
server_ip: '',
|
||
glances_url: '',
|
||
is_active: true,
|
||
collect_interval: 60
|
||
})
|
||
|
||
const editTarget = ref({
|
||
server_name: '',
|
||
server_ip: '',
|
||
glances_url: '',
|
||
is_active: true,
|
||
collect_interval: 60
|
||
})
|
||
|
||
const canAdd = computed(() =>
|
||
newTarget.value.server_name.trim() &&
|
||
newTarget.value.server_ip.trim() &&
|
||
newTarget.value.glances_url.trim()
|
||
)
|
||
|
||
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 fetchSchedulerStatus() {
|
||
try {
|
||
const res = await $fetch('/api/server/status')
|
||
schedulerStatus.value = res as SchedulerStatus
|
||
} catch (err) {
|
||
console.error('Failed to fetch scheduler status:', err)
|
||
}
|
||
}
|
||
|
||
async function startScheduler() {
|
||
try {
|
||
await $fetch('/api/server/scheduler/start', { method: 'POST' })
|
||
await fetchSchedulerStatus()
|
||
} catch (err) {
|
||
console.error('Failed to start scheduler:', err)
|
||
}
|
||
}
|
||
|
||
async function stopScheduler() {
|
||
try {
|
||
await $fetch('/api/server/scheduler/stop', { method: 'POST' })
|
||
await fetchSchedulerStatus()
|
||
} catch (err) {
|
||
console.error('Failed to stop scheduler:', err)
|
||
}
|
||
}
|
||
|
||
async function fetchTargets() {
|
||
try {
|
||
const res = await $fetch('/api/server/targets')
|
||
targets.value = (res as ServerTarget[]) || []
|
||
} catch (err) {
|
||
console.error('Failed to fetch targets:', err)
|
||
}
|
||
}
|
||
|
||
async function addTarget() {
|
||
if (!canAdd.value) return
|
||
try {
|
||
await $fetch('/api/server/targets', {
|
||
method: 'POST',
|
||
body: newTarget.value
|
||
})
|
||
newTarget.value = { server_name: '', server_ip: '', glances_url: '', is_active: true, collect_interval: 60 }
|
||
await fetchTargets()
|
||
await fetchSchedulerStatus()
|
||
} catch (err) {
|
||
console.error('Failed to add target:', err)
|
||
}
|
||
}
|
||
|
||
function startEdit(target: ServerTarget) {
|
||
editingId.value = target.target_id
|
||
editTarget.value = {
|
||
server_name: target.server_name,
|
||
server_ip: target.server_ip,
|
||
glances_url: target.glances_url,
|
||
is_active: !!target.is_active,
|
||
collect_interval: target.collect_interval || 60
|
||
}
|
||
}
|
||
|
||
function cancelEdit() {
|
||
editingId.value = null
|
||
}
|
||
|
||
async function saveEdit() {
|
||
if (!editingId.value) return
|
||
try {
|
||
await $fetch(`/api/server/targets/${editingId.value}`, {
|
||
method: 'PUT',
|
||
body: editTarget.value
|
||
})
|
||
editingId.value = null
|
||
await fetchTargets()
|
||
await fetchSchedulerStatus()
|
||
} catch (err) {
|
||
console.error('Failed to save target:', err)
|
||
}
|
||
}
|
||
|
||
async function deleteTarget(id: number) {
|
||
if (!confirm('정말 삭제하시겠습니까?')) return
|
||
try {
|
||
await $fetch(`/api/server/targets/${id}`, {
|
||
method: 'DELETE'
|
||
})
|
||
await fetchTargets()
|
||
await fetchSchedulerStatus()
|
||
} catch (err) {
|
||
console.error('Failed to delete target:', err)
|
||
}
|
||
}
|
||
|
||
let timeInterval: ReturnType<typeof setInterval> | null = null
|
||
|
||
onMounted(() => {
|
||
currentTime.value = formatTime(new Date())
|
||
timeInterval = setInterval(() => {
|
||
currentTime.value = formatTime(new Date())
|
||
}, 1000)
|
||
|
||
fetchTargets()
|
||
fetchSchedulerStatus()
|
||
|
||
// 스케줄러 상태 주기적 갱신
|
||
setInterval(fetchSchedulerStatus, 5000)
|
||
})
|
||
|
||
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);
|
||
}
|
||
|
||
.scheduler-card {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 24px;
|
||
align-items: center;
|
||
}
|
||
|
||
.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: #22c55e; }
|
||
.status-item .value.inactive { color: #ef4444; }
|
||
|
||
.scheduler-controls {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.no-data {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.total-count {
|
||
margin-bottom: 10px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
.url-cell {
|
||
word-break: break-all;
|
||
}
|
||
|
||
.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: 6px;
|
||
}
|
||
|
||
.btn {
|
||
padding: 6px 12px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-add { background: var(--btn-primary-bg); color: #fff; }
|
||
.btn-add:hover:not(:disabled) { background: var(--btn-primary-hover); }
|
||
.btn-start { background: #22c55e; color: #fff; }
|
||
.btn-stop { background: #ef4444; color: #fff; }
|
||
.btn-edit { background: #3b82f6; color: #fff; }
|
||
.btn-save { background: #22c55e; color: #fff; }
|
||
.btn-cancel { background: #6b7280; color: #fff; }
|
||
.btn-delete { background: #ef4444; color: #fff; }
|
||
</style>
|