644 lines
20 KiB
HTML
644 lines
20 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}iDRAC Job Queue 모니터링 (Redfish){% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-3">
|
|
<!-- 헤더 -->
|
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<h4 class="mb-0">
|
|
<i class="bi bi-list-task"></i> iDRAC Job Queue 모니터링
|
|
</h4>
|
|
<span class="badge bg-success">Redfish API</span>
|
|
<span id="last-updated" class="text-muted small"></span>
|
|
<span id="loading-indicator" class="d-none">
|
|
<span class="spinner-border spinner-border-sm" role="status"></span>
|
|
</span>
|
|
</div>
|
|
<div id="monitor-status" class="d-flex align-items-center gap-2">
|
|
<span class="status-dot" id="status-dot"></span>
|
|
<span class="small fw-semibold" id="status-text">모니터링 꺼짐</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- IP 입력 -->
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<div class="fw-semibold">
|
|
<i class="bi bi-hdd-network"></i> 모니터링 IP 목록
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button id="btn-load-file" class="btn btn-outline-secondary btn-sm">
|
|
<i class="bi bi-file-earmark-text"></i> 파일에서 불러오기
|
|
</button>
|
|
<button id="btn-apply" class="btn btn-success btn-sm">
|
|
<i class="bi bi-check-circle"></i> 적용
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<small class="text-muted d-block mb-2">
|
|
한 줄에 하나씩 입력 (쉼표/세미콜론/공백 구분 가능, # 주석 지원)
|
|
</small>
|
|
<textarea id="ipInput" class="form-control font-monospace" rows="4"
|
|
placeholder="10.10.0.11 10.10.0.12 # 주석 가능"></textarea>
|
|
<div class="mt-2">
|
|
<small class="text-muted">총 <strong id="ip-count">0</strong>개 IP</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 컨트롤 -->
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<div class="row g-3 align-items-center">
|
|
<div class="col-auto">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="monitorSwitch">
|
|
<label class="form-check-label" for="monitorSwitch">
|
|
<strong>모니터링 켜기</strong>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-auto">
|
|
<button id="btn-refresh" class="btn btn-primary btn-sm" disabled>
|
|
<i class="bi bi-arrow-clockwise"></i> 지금 새로고침
|
|
</button>
|
|
</div>
|
|
|
|
<div class="col-auto">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch">
|
|
<label class="form-check-label" for="autoRefreshSwitch">
|
|
자동 새로고침 (<span id="poll-interval">10</span>초)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-auto">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="showCompletedSwitch" checked>
|
|
<label class="form-check-label" for="showCompletedSwitch">
|
|
최근 완료 Job 표시
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 통계 -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-3">
|
|
<div class="card text-center">
|
|
<div class="card-body py-2">
|
|
<h6 class="card-title text-muted mb-1 small">총 서버</h6>
|
|
<h3 class="mb-0" id="stat-total">0</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card text-center border-info">
|
|
<div class="card-body py-2">
|
|
<h6 class="card-title text-info mb-1 small">진행 중</h6>
|
|
<h3 class="mb-0 text-info" id="stat-running">0</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card text-center border-success">
|
|
<div class="card-body py-2">
|
|
<h6 class="card-title text-success mb-1 small">완료</h6>
|
|
<h3 class="mb-0 text-success" id="stat-completed">0</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card text-center border-danger">
|
|
<div class="card-body py-2">
|
|
<h6 class="card-title text-danger mb-1 small">에러</h6>
|
|
<h3 class="mb-0 text-danger" id="stat-error">0</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 테이블 -->
|
|
<div class="table-responsive">
|
|
<table class="table table-hover table-sm align-middle" id="jobs-table">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width:140px;">IP</th>
|
|
<th>JID</th>
|
|
<th>작업명</th>
|
|
<th style="width:160px;">상태</th>
|
|
<th style="width:140px;">진행률</th>
|
|
<th>메시지</th>
|
|
<th style="width:240px;">마지막 업데이트</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="jobs-body">
|
|
<tr>
|
|
<td colspan="7" class="text-center text-muted py-4">
|
|
<i class="bi bi-power fs-2 d-block mb-2"></i>
|
|
모니터링이 꺼져 있습니다. 상단 스위치를 켜면 조회가 시작됩니다.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.status-dot {
|
|
display: inline-block;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
background-color: #6c757d;
|
|
}
|
|
.status-dot.active {
|
|
background-color: #198754;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
/* ↓↓↓ 여기부터 추가 ↓↓↓ */
|
|
|
|
/* 테이블 텍스트 한 줄 처리 */
|
|
#jobs-table {
|
|
table-layout: fixed;
|
|
width: 100%;
|
|
}
|
|
|
|
#jobs-table td {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 300px;
|
|
}
|
|
|
|
/* 열별 너비 고정 */
|
|
#jobs-table td:nth-child(1) { max-width: 110px; } /* IP */
|
|
#jobs-table td:nth-child(2) { max-width: 160px; font-size: 0.85rem; } /* JID */
|
|
#jobs-table td:nth-child(3) { max-width: 200px; } /* 작업명 */
|
|
#jobs-table td:nth-child(4) { max-width: 180px; } /* 상태 */
|
|
#jobs-table td:nth-child(5) { max-width: 120px; } /* 진행률 */
|
|
#jobs-table td:nth-child(6) { max-width: 300px; } /* 메시지 */
|
|
#jobs-table td:nth-child(7) { max-width: 150px; } /* 시간 */
|
|
|
|
/* 마우스 올리면 전체 텍스트 보기 */
|
|
#jobs-table td:hover {
|
|
white-space: normal;
|
|
overflow: visible;
|
|
position: relative;
|
|
z-index: 1000;
|
|
background-color: #fff;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
}
|
|
|
|
/* ↑↑↑ 여기까지 추가 ↑↑↑ */
|
|
|
|
.status-dot {
|
|
display: inline-block;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
background-color: #6c757d;
|
|
}
|
|
.status-dot.active {
|
|
background-color: #198754;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
const csrfToken = "{{ csrf_token() }}";
|
|
|
|
// ========== 전역 변수 ==========
|
|
let CONFIG = {
|
|
grace_minutes: 60,
|
|
recency_hours: 24,
|
|
poll_interval_ms: 10000
|
|
};
|
|
|
|
let monitoringOn = false;
|
|
let pollTimer = null;
|
|
let lastRenderHash = "";
|
|
|
|
// ========== Elements ==========
|
|
const $ = id => document.getElementById(id);
|
|
const $body = $('jobs-body');
|
|
const $last = $('last-updated');
|
|
const $loading = $('loading-indicator');
|
|
const $btn = $('btn-refresh');
|
|
const $auto = $('autoRefreshSwitch');
|
|
const $ipInput = $('ipInput');
|
|
const $ipCount = $('ip-count');
|
|
const $btnLoad = $('btn-load-file');
|
|
const $btnApply = $('btn-apply');
|
|
const $monSw = $('monitorSwitch');
|
|
const $statusDot = $('status-dot');
|
|
const $statusText = $('status-text');
|
|
const $showCompleted = $('showCompletedSwitch');
|
|
const $pollInterval = $('poll-interval');
|
|
|
|
// Stats
|
|
const $statTotal = $('stat-total');
|
|
const $statRunning = $('stat-running');
|
|
const $statCompleted = $('stat-completed');
|
|
const $statError = $('stat-error');
|
|
|
|
// ========== LocalStorage Keys ==========
|
|
const LS_IPS = 'idrac_job_ips';
|
|
const LS_MON = 'idrac_monitor_on';
|
|
const LS_AUTO = 'idrac_monitor_auto';
|
|
const LS_SHOW_COMPLETED = 'idrac_show_completed';
|
|
|
|
// ========== 유틸리티 ==========
|
|
function parseIps(text) {
|
|
if (!text) return [];
|
|
const raw = text.replace(/[,;]+/g, '\n');
|
|
const out = [], seen = new Set();
|
|
raw.split('\n').forEach(line => {
|
|
const parts = line.trim().split(/\s+/);
|
|
parts.forEach(p => {
|
|
if (!p || p.startsWith('#')) return;
|
|
if (!seen.has(p)) {
|
|
seen.add(p);
|
|
out.push(p);
|
|
}
|
|
});
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function getIpsFromUI() { return parseIps($ipInput.value); }
|
|
function setIpsToUI(ips) {
|
|
$ipInput.value = (ips || []).join('\n');
|
|
updateIpCount();
|
|
}
|
|
function updateIpCount() {
|
|
const ips = getIpsFromUI();
|
|
$ipCount.textContent = ips.length;
|
|
}
|
|
function saveIps() {
|
|
localStorage.setItem(LS_IPS, JSON.stringify(getIpsFromUI()));
|
|
updateIpCount();
|
|
}
|
|
function loadIps() {
|
|
try {
|
|
const v = JSON.parse(localStorage.getItem(LS_IPS) || "[]");
|
|
return Array.isArray(v) ? v : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, m => ({
|
|
'&': '&', '<': '<', '>': '>',
|
|
'"': '"', "'": '''
|
|
}[m]));
|
|
}
|
|
|
|
function progressBar(pc) {
|
|
const n = parseInt(String(pc ?? "").toString().replace('%', '').trim(), 10);
|
|
if (isNaN(n)) return `<span class="text-muted small">${escapeHtml(pc ?? "")}</span>`;
|
|
|
|
let bgClass = 'bg-info';
|
|
if (n === 100) bgClass = 'bg-success';
|
|
else if (n < 30) bgClass = 'bg-warning';
|
|
|
|
return `<div class="progress" style="height:8px;">
|
|
<div class="progress-bar ${bgClass}" role="progressbar"
|
|
style="width:${n}%;" aria-valuenow="${n}" aria-valuemin="0" aria-valuemax="100"></div>
|
|
</div>
|
|
<small class="text-muted">${n}%</small>`;
|
|
}
|
|
|
|
function badgeStatus(status, pc, recently = false) {
|
|
const raw = String(status || "");
|
|
const s = raw.toLowerCase();
|
|
let cls = "bg-secondary";
|
|
let icon = "info-circle";
|
|
|
|
if (recently) {
|
|
cls = "bg-success";
|
|
icon = "check-circle";
|
|
} else if (s.includes("completed")) {
|
|
cls = "bg-success";
|
|
icon = "check-circle";
|
|
} else if (s.includes("running") || s.includes("progress")) {
|
|
cls = "bg-info";
|
|
icon = "arrow-repeat";
|
|
} else if (s.includes("scheduled") || s.includes("pending")) {
|
|
cls = "bg-warning text-dark";
|
|
icon = "clock";
|
|
} else if (s.includes("failed") || s.includes("error")) {
|
|
cls = "bg-danger";
|
|
icon = "x-circle";
|
|
}
|
|
|
|
const pct = parseInt(String(pc ?? "").toString().replace('%', '').trim(), 10);
|
|
const pctText = isNaN(pct) ? "" : ` (${pct}%)`;
|
|
const text = recently ? `${raw || "Completed"} (최근${pctText})` : `${raw || "-"}${pctText}`;
|
|
|
|
return `<span class="badge ${cls}">
|
|
<i class="bi bi-${icon}"></i> ${escapeHtml(text)}
|
|
</span>`;
|
|
}
|
|
|
|
function updateStats(items) {
|
|
let total = 0, running = 0, completed = 0, error = 0;
|
|
|
|
items.forEach(it => {
|
|
if (!it.ok) {
|
|
error++;
|
|
return;
|
|
}
|
|
if (it.jobs && it.jobs.length) {
|
|
total++;
|
|
it.jobs.forEach(j => {
|
|
const s = (j.Status || "").toLowerCase();
|
|
if (s.includes("running") || s.includes("progress") || s.includes("starting")) {
|
|
running++;
|
|
} else if (s.includes("completed") || s.includes("success")) {
|
|
completed++;
|
|
} else if (s.includes("failed") || s.includes("error")) {
|
|
error++;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
$statTotal.textContent = items.length;
|
|
$statRunning.textContent = running;
|
|
$statCompleted.textContent = completed;
|
|
$statError.textContent = error;
|
|
}
|
|
|
|
// ========== 렌더링 ==========
|
|
function renderTable(items) {
|
|
if (!items) return;
|
|
|
|
const hash = JSON.stringify(items);
|
|
if (hash === lastRenderHash) return;
|
|
lastRenderHash = hash;
|
|
|
|
if (!items.length) {
|
|
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
|
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
|
|
현재 모니터링 중인 Job이 없습니다.
|
|
</td></tr>`;
|
|
updateStats([]);
|
|
return;
|
|
}
|
|
|
|
const rows = [];
|
|
for (const it of items) {
|
|
if (!it.ok) {
|
|
rows.push(`<tr class="table-danger">
|
|
<td><code>${escapeHtml(it.ip)}</code></td>
|
|
<td colspan="6">
|
|
<i class="bi bi-exclamation-triangle"></i>
|
|
오류: ${escapeHtml(it.error || "Unknown")}
|
|
</td>
|
|
</tr>`);
|
|
continue;
|
|
}
|
|
|
|
if (!it.jobs || !it.jobs.length) continue;
|
|
|
|
for (const j of it.jobs) {
|
|
const recent = !!j.RecentlyCompleted;
|
|
const timeText = j.CompletedAt
|
|
? `완료: ${escapeHtml(j.CompletedAt.split('T')[1]?.split('.')[0] || j.CompletedAt)}`
|
|
: escapeHtml(j.LastUpdateTime || "");
|
|
|
|
rows.push(`<tr ${recent ? 'class="table-success"' : ''}>
|
|
<td><code>${escapeHtml(it.ip)}</code></td>
|
|
<td><small class="font-monospace">${escapeHtml(j.JID || "")}</small></td>
|
|
<td><strong>${escapeHtml(j.Name || "")}</strong></td>
|
|
<td>${badgeStatus(j.Status || "", j.PercentComplete || "", recent)}</td>
|
|
<td>${progressBar(j.PercentComplete || "0")}</td>
|
|
<td><small>${escapeHtml(j.Message || "")}</small></td>
|
|
<td><small class="text-muted">${timeText}</small></td>
|
|
</tr>`);
|
|
}
|
|
}
|
|
|
|
$body.innerHTML = rows.length
|
|
? rows.join("")
|
|
: `<tr><td colspan="7" class="text-center text-success py-4">
|
|
<i class="bi bi-check-circle fs-2 d-block mb-2"></i>
|
|
현재 진행 중인 Job이 없습니다. ✅
|
|
</td></tr>`;
|
|
|
|
updateStats(items);
|
|
}
|
|
|
|
// ========== 서버 요청 ==========
|
|
async function fetchJobs(auto = false) {
|
|
if (!monitoringOn) {
|
|
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
|
<i class="bi bi-power fs-2 d-block mb-2"></i>
|
|
모니터링이 꺼져 있습니다. 상단 스위치를 켜면 조회가 시작됩니다.
|
|
</td></tr>`;
|
|
$last.textContent = "";
|
|
updateStats([]);
|
|
return;
|
|
}
|
|
|
|
const ips = getIpsFromUI();
|
|
if (!ips.length) {
|
|
$body.innerHTML = `<tr><td colspan="7" class="text-center text-warning py-4">
|
|
<i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>
|
|
IP 목록이 비어 있습니다.
|
|
</td></tr>`;
|
|
$last.textContent = "";
|
|
updateStats([]);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$loading.classList.remove('d-none');
|
|
$last.textContent = "조회 중… " + new Date().toLocaleTimeString();
|
|
|
|
const res = await fetch("/jobs/scan", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-CSRFToken": csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
ips,
|
|
method: "redfish", // Redfish 사용
|
|
recency_hours: CONFIG.recency_hours,
|
|
grace_minutes: CONFIG.grace_minutes,
|
|
include_tracked_done: $showCompleted.checked
|
|
})
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (!data.ok) throw new Error(data.error || "Scan failed");
|
|
|
|
renderTable(data.items);
|
|
$last.textContent = "업데이트: " + new Date().toLocaleString();
|
|
} catch (e) {
|
|
$body.innerHTML = `<tr><td colspan="7" class="text-danger text-center py-4">
|
|
<i class="bi bi-exclamation-circle fs-2 d-block mb-2"></i>
|
|
로드 실패: ${escapeHtml(e.message)}
|
|
<br><button class="btn btn-sm btn-outline-primary mt-2" onclick="fetchJobs(false)">
|
|
<i class="bi bi-arrow-clockwise"></i> 재시도
|
|
</button>
|
|
</td></tr>`;
|
|
$last.textContent = "에러: " + new Date().toLocaleString();
|
|
console.error(e);
|
|
} finally {
|
|
$loading.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
// ========== 모니터링 제어 ==========
|
|
function startAuto() {
|
|
stopAuto();
|
|
pollTimer = setInterval(() => fetchJobs(true), CONFIG.poll_interval_ms);
|
|
}
|
|
|
|
function stopAuto() {
|
|
if (pollTimer) {
|
|
clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
}
|
|
|
|
function updateMonitorUI() {
|
|
$btn.disabled = !monitoringOn;
|
|
$auto.disabled = !monitoringOn;
|
|
|
|
if (monitoringOn) {
|
|
$statusDot.classList.add('active');
|
|
$statusText.textContent = '모니터링 중';
|
|
} else {
|
|
$statusDot.classList.remove('active');
|
|
$statusText.textContent = '모니터링 꺼짐';
|
|
}
|
|
}
|
|
|
|
async function setMonitoring(on) {
|
|
monitoringOn = !!on;
|
|
localStorage.setItem(LS_MON, monitoringOn ? "1" : "0");
|
|
updateMonitorUI();
|
|
|
|
if (!monitoringOn) {
|
|
stopAuto();
|
|
$last.textContent = "";
|
|
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
|
<i class="bi bi-power fs-2 d-block mb-2"></i>
|
|
모니터링이 꺼져 있습니다.
|
|
</td></tr>`;
|
|
lastRenderHash = "";
|
|
updateStats([]);
|
|
return;
|
|
}
|
|
|
|
await fetchJobs(false);
|
|
if ($auto.checked) startAuto();
|
|
}
|
|
|
|
// ========== 초기화 ==========
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// 설정 로드
|
|
try {
|
|
const res = await fetch('/jobs/config');
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
CONFIG = data.config;
|
|
$pollInterval.textContent = Math.round(CONFIG.poll_interval_ms / 1000);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load config:", e);
|
|
}
|
|
|
|
// IP 복원
|
|
const savedIps = loadIps();
|
|
if (savedIps.length) {
|
|
setIpsToUI(savedIps);
|
|
} else {
|
|
try {
|
|
const res = await fetch('/jobs/iplist', {
|
|
headers: { 'X-CSRFToken': csrfToken }
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok && data.ips) {
|
|
setIpsToUI(data.ips);
|
|
saveIps();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
// 설정 복원
|
|
const savedMon = localStorage.getItem(LS_MON);
|
|
const savedAuto = localStorage.getItem(LS_AUTO);
|
|
const savedShowCompleted = localStorage.getItem(LS_SHOW_COMPLETED);
|
|
|
|
$monSw.checked = savedMon === "1";
|
|
$auto.checked = savedAuto === "1";
|
|
$showCompleted.checked = savedShowCompleted !== "0";
|
|
|
|
// 이벤트
|
|
$ipInput.addEventListener('input', updateIpCount);
|
|
$btn.addEventListener('click', () => { if (monitoringOn) fetchJobs(false); });
|
|
$auto.addEventListener('change', e => {
|
|
localStorage.setItem(LS_AUTO, e.target.checked ? "1" : "0");
|
|
if (!monitoringOn) return;
|
|
if (e.target.checked) startAuto();
|
|
else stopAuto();
|
|
});
|
|
$monSw.addEventListener('click', e => setMonitoring(e.target.checked));
|
|
$showCompleted.addEventListener('change', e => {
|
|
localStorage.setItem(LS_SHOW_COMPLETED, e.target.checked ? "1" : "0");
|
|
if (monitoringOn) fetchJobs(false);
|
|
});
|
|
$btnLoad.addEventListener('click', async () => {
|
|
try {
|
|
const res = await fetch('/jobs/iplist', {
|
|
headers: { 'X-CSRFToken': csrfToken }
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
setIpsToUI(data.ips || []);
|
|
saveIps();
|
|
if (monitoringOn) await fetchJobs(false);
|
|
}
|
|
} catch (e) {
|
|
alert('IP 목록 불러오기 실패: ' + e.message);
|
|
}
|
|
});
|
|
$btnApply.addEventListener('click', () => {
|
|
saveIps();
|
|
if (monitoringOn) fetchJobs(false);
|
|
});
|
|
|
|
// 초기 상태
|
|
updateMonitorUI();
|
|
updateIpCount();
|
|
|
|
if ($monSw.checked) {
|
|
setMonitoring(true);
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|