This commit is contained in:
2025-10-16 15:06:50 +09:00
parent 2fcca115d6
commit 230ea0890d
11 changed files with 1587 additions and 145 deletions

2
app.py
View File

@@ -102,4 +102,4 @@ if __name__ == "__main__":
host = os.getenv("FLASK_HOST", "0.0.0.0") host = os.getenv("FLASK_HOST", "0.0.0.0")
port = int(os.getenv("FLASK_PORT", 5000)) port = int(os.getenv("FLASK_PORT", 5000))
debug = os.getenv("FLASK_DEBUG", "true").lower() == "true" debug = os.getenv("FLASK_DEBUG", "true").lower() == "true"
socketio.run(app, host=host, port=port, debug=debug) socketio.run(app, host=host, port=port, debug=debug, allow_unsafe_werkzeug=True)

View File

@@ -7,7 +7,7 @@ from .main import register_main_routes
from .xml import register_xml_routes from .xml import register_xml_routes
from .utilities import register_util_routes from .utilities import register_util_routes
from .file_view import register_file_view from .file_view import register_file_view
from .jobs import register_jobs_routes
def register_routes(app: Flask, socketio=None) -> None: def register_routes(app: Flask, socketio=None) -> None:
"""블루프린트 일괄 등록. socketio는 main 라우트에서만 사용.""" """블루프린트 일괄 등록. socketio는 main 라우트에서만 사용."""
@@ -17,4 +17,5 @@ def register_routes(app: Flask, socketio=None) -> None:
register_main_routes(app, socketio) register_main_routes(app, socketio)
register_xml_routes(app) register_xml_routes(app)
register_util_routes(app) register_util_routes(app)
register_file_view(app) register_file_view(app)
register_jobs_routes(app)

279
backend/routes/jobs.py Normal file
View File

@@ -0,0 +1,279 @@
"""
Flask Blueprint for iDRAC Job Monitoring (Redfish 버전)
기존 routes/jobs.py 또는 backend/routes/jobs.py를 이 파일로 교체하세요.
"""
import time
import logging
from flask import Blueprint, render_template, jsonify, request
from flask_login import login_required
from backend.services.idrac_jobs import (
scan_all,
parse_ip_list,
load_ip_list,
LRUJobCache,
is_active_status,
is_done_status,
parse_iso_datetime,
iso_now
)
import os
logger = logging.getLogger(__name__)
# Blueprint 생성
jobs_bp = Blueprint("jobs", __name__, url_prefix="/jobs")
# Job 캐시 (전역)
MAX_CACHE_SIZE = int(os.getenv("MAX_CACHE_SIZE", "10000"))
CACHE_GC_INTERVAL = int(os.getenv("CACHE_GC_INTERVAL", "3600"))
JOB_GRACE_MINUTES = int(os.getenv("JOB_GRACE_MINUTES", "60"))
JOB_RECENCY_HOURS = int(os.getenv("JOB_RECENCY_HOURS", "24"))
JOB_CACHE = LRUJobCache(max_size=MAX_CACHE_SIZE)
# ────────────────────────────────────────────────────────────
# Routes
# ────────────────────────────────────────────────────────────
@jobs_bp.route("", methods=["GET"])
@login_required
def jobs_page():
"""메인 페이지"""
return render_template("jobs.html")
@jobs_bp.route("/config", methods=["GET"])
@login_required
def jobs_config():
"""프론트엔드 설정 제공"""
return jsonify({
"ok": True,
"config": {
"grace_minutes": JOB_GRACE_MINUTES,
"recency_hours": JOB_RECENCY_HOURS,
"poll_interval_ms": int(os.getenv("POLL_INTERVAL_MS", "10000")),
}
})
@jobs_bp.route("/iplist", methods=["GET"])
@login_required
def get_ip_list():
"""IP 목록 조회 (파일에서)"""
try:
ips = load_ip_list()
return jsonify({
"ok": True,
"ips": ips,
"count": len(ips)
})
except Exception as e:
logger.exception("Failed to load IP list")
return jsonify({
"ok": False,
"error": str(e)
}), 500
@jobs_bp.route("/scan", methods=["POST"])
@login_required
def scan_jobs():
"""
Job 스캔 및 모니터링
Request Body:
{
"ips": List[str] (optional),
"method": "redfish" (기본값),
"recency_hours": int (기본: 24),
"grace_minutes": int (기본: 60),
"include_tracked_done": bool (기본: True)
}
Response:
{
"ok": True,
"count": int,
"items": [
{
"ip": str,
"ok": bool,
"error": str (if not ok),
"jobs": List[Dict]
}
]
}
"""
data = request.get_json(silent=True) or {}
# IP 목록
ip_input = data.get("ips")
if ip_input:
ips = parse_ip_list("\n".join(ip_input) if isinstance(ip_input, list) else str(ip_input))
else:
ips = load_ip_list()
if not ips:
return jsonify({
"ok": False,
"error": "No IPs provided",
"items": []
}), 400
# 파라미터
method = data.get("method", "redfish") # redfish가 기본값
recency_hours = int(data.get("recency_hours", JOB_RECENCY_HOURS))
grace_minutes = int(data.get("grace_minutes", JOB_GRACE_MINUTES))
include_tracked_done = bool(data.get("include_tracked_done", True))
grace_sec = grace_minutes * 60
cutoff = time.time() - recency_hours * 3600
# 현재 IP 목록과 다른 캐시 항목 제거
JOB_CACHE.clear_for_ips(set(ips))
# 스캔 실행
try:
items = scan_all(ips, method=method)
except Exception as e:
logger.exception("Scan failed")
return jsonify({
"ok": False,
"error": str(e),
"items": []
}), 500
now = time.time()
# 캐시 업데이트
for item in items:
ip = item.get("ip", "")
if not item.get("ok") or not isinstance(item.get("jobs"), list):
continue
for job in item["jobs"]:
status = job.get("Status")
message = job.get("Message")
active_now = is_active_status(status, message)
done_now = is_done_status(status)
# 시작 시간 파싱
start_ts = parse_iso_datetime(job.get("StartTime"))
# 리센시 판정
if not active_now:
if start_ts is None or start_ts < cutoff:
continue
# 캐시 키 생성
key = _make_cache_key(ip, job)
entry = JOB_CACHE.get(key)
if entry is None:
JOB_CACHE.set(key, {
"record": dict(job),
"first_seen_active": (now if active_now else None),
"became_done_at": (now if done_now else None),
"first_seen": now,
"last_seen": now,
"start_ts": start_ts,
})
else:
entry["record"] = dict(job)
entry["last_seen"] = now
if active_now and not entry.get("first_seen_active"):
entry["first_seen_active"] = now
if done_now and not entry.get("became_done_at"):
entry["became_done_at"] = now
elif not done_now:
entry["became_done_at"] = None
if start_ts:
entry["start_ts"] = start_ts
JOB_CACHE.set(key, entry)
# 응답 생성
out_items = []
for item in items:
ip = item.get("ip", "")
shown_jobs = []
# 현재 Active Job
current_active = []
if item.get("ok") and isinstance(item.get("jobs"), list):
for job in item["jobs"]:
if is_active_status(job.get("Status"), job.get("Message")):
key = _make_cache_key(ip, job)
if key in JOB_CACHE.keys():
current_active.append(JOB_CACHE.get(key)["record"])
if current_active:
shown_jobs = current_active
else:
# Active가 없을 때: 추적된 최근 완료 Job 표시
if include_tracked_done:
for key in JOB_CACHE.keys():
if key[0] != ip:
continue
entry = JOB_CACHE.get(key)
if not entry:
continue
start_ok = (entry.get("start_ts") or 0) >= cutoff
done_at = entry.get("became_done_at")
done_ok = bool(done_at and now - done_at <= grace_sec)
still_active = entry.get("became_done_at") is None
if still_active and start_ok:
shown_jobs.append(entry["record"])
elif done_ok and start_ok:
rec = dict(entry["record"])
rec["RecentlyCompleted"] = True
rec["CompletedAt"] = iso_now()
shown_jobs.append(rec)
out_items.append({
"ip": ip,
"ok": item.get("ok"),
"error": item.get("error"),
"jobs": sorted(shown_jobs, key=lambda r: r.get("JID", ""))
})
# 캐시 GC (조건부)
if now - JOB_CACHE.last_gc >= CACHE_GC_INTERVAL:
JOB_CACHE.gc(max_age_seconds=24 * 3600)
return jsonify({
"ok": True,
"count": len(out_items),
"items": out_items
})
def _make_cache_key(ip: str, job: dict):
"""캐시 키 생성"""
jid = (job.get("JID") or "").strip()
if jid:
return (ip, jid)
name = (job.get("Name") or "").strip()
return (ip, f"NOJID::{name}")
# ────────────────────────────────────────────────────────────
# 기존 패턴에 맞는 register 함수 추가
# ────────────────────────────────────────────────────────────
def register_jobs_routes(app):
"""
iDRAC Job 모니터링 라우트 등록
기존 프로젝트 패턴에 맞춘 함수
"""
from flask import Flask
app.register_blueprint(jobs_bp)
logger.info("Jobs routes registered at /jobs")

View File

@@ -299,4 +299,4 @@ def download_excel():
return redirect(url_for("main.index")) return redirect(url_for("main.index"))
logging.info(f"엑셀 파일 다운로드: {path}") logging.info(f"엑셀 파일 다운로드: {path}")
return send_file(str(path), as_attachment=True, download_name="mac_info.xlsx") return send_file(str(path), as_attachment=True, download_name="mac_info.xlsx")

View File

@@ -0,0 +1,315 @@
"""
iDRAC Job Monitoring Service (Redfish 버전)
기존 Flask 앱의 backend/services/ 디렉토리에 추가하세요.
기존 idrac_jobs.py를 이 파일로 교체하거나 redfish_jobs.py로 저장하세요.
"""
import logging
logging.basicConfig(level=logging.DEBUG)
import ipaddress
import time
import logging
import os
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Tuple
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from .redfish_client import RedfishClient, AuthenticationError, NotSupportedError
logger = logging.getLogger(__name__)
# ────────────────────────────────────────────────────────────
# 설정 (환경변수 또는 기본값)
# ────────────────────────────────────────────────────────────
IDRAC_USER = os.getenv("IDRAC_USER", "root")
IDRAC_PASS = os.getenv("IDRAC_PASS", "calvin")
MAX_WORKERS = int(os.getenv("MAX_WORKERS", "32"))
REDFISH_TIMEOUT = int(os.getenv("REDFISH_TIMEOUT", "15"))
VERIFY_SSL = os.getenv("VERIFY_SSL", "False").lower() == "true"
IP_LIST_PATH = os.getenv("IDRAC_IP_LIST", "data/server_list/idrac_ip_list.txt")
# ────────────────────────────────────────────────────────────
# IP 유효성 검증
# ────────────────────────────────────────────────────────────
def validate_ip(ip: str) -> bool:
"""IP 주소 유효성 검증"""
try:
ipaddress.ip_address(ip.strip())
return True
except ValueError:
return False
def parse_ip_list(text: str) -> List[str]:
"""텍스트에서 IP 목록 파싱"""
if not text:
return []
raw = text.replace(",", "\n").replace(";", "\n")
ips = []
seen = set()
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
for part in line.split():
part = part.strip()
if not part or part.startswith("#"):
continue
if validate_ip(part) and part not in seen:
seen.add(part)
ips.append(part)
elif not validate_ip(part):
logger.warning(f"Invalid IP address: {part}")
return ips
def load_ip_list(path: str = IP_LIST_PATH) -> List[str]:
"""파일에서 IP 목록 로드"""
try:
file_path = Path(path)
if not file_path.exists():
logger.warning(f"IP list file not found: {path}")
return []
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
ips = parse_ip_list(content)
logger.info(f"Loaded {len(ips)} IPs from {path}")
return ips
except Exception as e:
logger.error(f"Failed to load IP list from {path}: {e}")
return []
# ────────────────────────────────────────────────────────────
# Job 상태 판별
# ────────────────────────────────────────────────────────────
ACTIVE_KEYWORDS = (
"running", "scheduled", "progress", "starting",
"queued", "pending", "preparing", "applying"
)
DONE_KEYWORDS = (
"completed", "success", "succeeded",
"failed", "error", "aborted",
"canceled", "cancelled"
)
def is_active_status(status: Optional[str], message: Optional[str] = None) -> bool:
"""Job이 활성 상태인지 확인"""
s = (status or "").strip().lower()
m = (message or "").strip().lower()
return any(k in s for k in ACTIVE_KEYWORDS) or any(k in m for k in ACTIVE_KEYWORDS)
def is_done_status(status: Optional[str]) -> bool:
"""Job이 완료 상태인지 확인"""
s = (status or "").strip().lower()
return any(k in s for k in DONE_KEYWORDS)
# ────────────────────────────────────────────────────────────
# 날짜/시간 파싱
# ────────────────────────────────────────────────────────────
def parse_iso_datetime(dt_str: Optional[str]) -> Optional[float]:
"""ISO 8601 날짜 문자열을 timestamp로 변환"""
if not dt_str:
return None
try:
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
return dt.timestamp()
except Exception as e:
logger.debug(f"Failed to parse datetime '{dt_str}': {e}")
return None
def iso_now() -> str:
"""현재 시간을 ISO 8601 포맷으로 반환"""
return datetime.now(timezone.utc).isoformat()
# ────────────────────────────────────────────────────────────
# LRU 캐시
# ────────────────────────────────────────────────────────────
class LRUJobCache:
"""Job 캐시 (LRU 방식)"""
def __init__(self, max_size: int = 10000):
self.cache: OrderedDict[Tuple[str, str], Dict[str, Any]] = OrderedDict()
self.max_size = max_size
self.last_gc = time.time()
def _make_key(self, ip: str, job: Dict[str, Any]) -> Tuple[str, str]:
"""캐시 키 생성"""
jid = (job.get("JID") or "").strip()
if jid:
return (ip, jid)
name = (job.get("Name") or "").strip()
return (ip, f"NOJID::{name}")
def get(self, key: Tuple[str, str]) -> Optional[Dict[str, Any]]:
"""캐시에서 조회"""
if key in self.cache:
self.cache.move_to_end(key)
return self.cache[key]
return None
def set(self, key: Tuple[str, str], value: Dict[str, Any]):
"""캐시에 저장"""
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.max_size:
self.cache.popitem(last=False)
def keys(self) -> List[Tuple[str, str]]:
"""모든 키 반환"""
return list(self.cache.keys())
def pop(self, key: Tuple[str, str], default=None):
"""캐시에서 제거"""
return self.cache.pop(key, default)
def clear_for_ips(self, current_ips: set):
"""현재 IP 목록에 없는 항목 제거"""
removed = 0
for key in list(self.cache.keys()):
if key[0] not in current_ips:
self.cache.pop(key)
removed += 1
if removed > 0:
logger.info(f"Cleared {removed} cache entries for removed IPs")
def gc(self, max_age_seconds: float):
"""오래된 캐시 항목 제거"""
now = time.time()
cutoff = now - max_age_seconds
removed = 0
for key in list(self.cache.keys()):
entry = self.cache[key]
if entry.get("last_seen", 0) < cutoff:
self.cache.pop(key)
removed += 1
if removed > 0:
logger.info(f"Cache GC: removed {removed} entries")
self.last_gc = now
# ────────────────────────────────────────────────────────────
# Job 스캐너
# ────────────────────────────────────────────────────────────
def scan_single_ip(ip: str) -> Dict[str, Any]:
"""
단일 IP에서 Job 조회
Returns:
{
"ip": str,
"ok": bool,
"error": str (실패 시),
"jobs": List[Dict]
}
"""
if not validate_ip(ip):
return {
"ip": ip,
"ok": False,
"error": "Invalid IP address",
"jobs": []
}
try:
with RedfishClient(ip, IDRAC_USER, IDRAC_PASS, REDFISH_TIMEOUT, VERIFY_SSL) as client:
jobs = client.get_jobs()
return {
"ip": ip,
"ok": True,
"jobs": jobs
}
except AuthenticationError:
return {
"ip": ip,
"ok": False,
"error": "Authentication failed",
"jobs": []
}
except NotSupportedError:
return {
"ip": ip,
"ok": False,
"error": "Redfish API not supported (old iDRAC?)",
"jobs": []
}
except TimeoutError as e:
return {
"ip": ip,
"ok": False,
"error": f"Timeout: {str(e)}",
"jobs": []
}
except ConnectionError as e:
return {
"ip": ip,
"ok": False,
"error": f"Connection failed: {str(e)}",
"jobs": []
}
except Exception as e:
logger.exception(f"Unexpected error for {ip}")
return {
"ip": ip,
"ok": False,
"error": f"Unexpected error: {str(e)[:100]}",
"jobs": []
}
def scan_all(ips: List[str], method: str = "redfish", max_workers: int = MAX_WORKERS) -> List[Dict[str, Any]]:
"""
여러 IP를 병렬로 스캔
Args:
ips: IP 목록
method: "redfish" (racadm은 하위 호환용)
max_workers: 병렬 워커 수
Returns:
IP별 결과 리스트 (정렬됨)
"""
if not ips:
return []
logger.info(f"Scanning {len(ips)} IPs with {max_workers} workers (method: {method})")
start_time = time.time()
results = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(scan_single_ip, ip): ip
for ip in ips
}
for future in as_completed(futures):
results.append(future.result())
elapsed = time.time() - start_time
logger.info(f"Scan completed in {elapsed:.2f}s")
return sorted(results, key=lambda x: x["ip"])

View File

@@ -0,0 +1,241 @@
"""
Dell iDRAC Redfish API Client (수정 버전)
절대 경로와 상대 경로 모두 처리
"""
import requests
import urllib3
from typing import Dict, Any, Optional, List
import logging
from functools import wraps
import time
import os
# SSL 경고 비활성화
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = logging.getLogger(__name__)
def retry_on_failure(max_attempts: int = 2, delay: float = 2.0):
"""재시도 데코레이터"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except (requests.Timeout, requests.ConnectionError) as e:
last_exception = e
if attempt < max_attempts - 1:
logger.warning(f"Attempt {attempt + 1} failed, retrying in {delay}s: {e}")
time.sleep(delay * (attempt + 1))
except Exception as e:
raise
raise last_exception
return wrapper
return decorator
class RedfishClient:
"""Dell iDRAC Redfish API 클라이언트"""
def __init__(
self,
ip: str,
username: str,
password: str,
timeout: int = 15,
verify_ssl: bool = False
):
self.ip = ip
self.base_url = f"https://{ip}/redfish/v1"
self.host_url = f"https://{ip}" # ← 추가: 호스트 URL
self.timeout = timeout
self.verify_ssl = verify_ssl
self.session = requests.Session()
self.session.auth = (username, password)
self.session.verify = verify_ssl
self.session.headers.update({
"Content-Type": "application/json",
"Accept": "application/json"
})
@retry_on_failure(max_attempts=2, delay=2.0)
def get(self, endpoint: str) -> Dict[str, Any]:
"""
GET 요청
절대 경로와 상대 경로 모두 처리
"""
# 절대 경로 처리 (이미 /redfish/v1로 시작하는 경우)
if endpoint.startswith('/redfish/v1'):
url = f"{self.host_url}{endpoint}"
# 상대 경로 처리
else:
url = f"{self.base_url}{endpoint}"
logger.debug(f"GET {url}")
response = self.session.get(url, timeout=self.timeout)
response.raise_for_status()
return response.json()
def get_jobs(self) -> List[Dict[str, Any]]:
"""
모든 Job 조회
표준 경로와 Dell OEM 경로 모두 시도
"""
jobs = []
# 1. 표준 Redfish Jobs 경로 시도
try:
standard_jobs = self._get_jobs_standard()
jobs.extend(standard_jobs)
except Exception as e:
logger.warning(f"{self.ip}: Standard Jobs endpoint failed: {e}")
# 2. Dell OEM Jobs 경로 시도
try:
oem_jobs = self._get_jobs_dell_oem()
jobs.extend(oem_jobs)
except Exception as e:
logger.warning(f"{self.ip}: Dell OEM Jobs endpoint failed: {e}")
if not jobs:
logger.info(f"{self.ip}: No jobs found")
return []
# 중복 제거 (JID 기준)
seen_jids = set()
unique_jobs = []
for job in jobs:
jid = job.get("JID", "")
if jid and jid not in seen_jids:
seen_jids.add(jid)
unique_jobs.append(job)
logger.info(f"{self.ip}: Retrieved {len(unique_jobs)} unique jobs")
return sorted(unique_jobs, key=lambda x: x.get("JID", ""))
def _get_jobs_standard(self) -> List[Dict[str, Any]]:
"""표준 Redfish Jobs 조회"""
jobs_endpoint = "/Managers/iDRAC.Embedded.1/Jobs"
jobs_collection = self.get(jobs_endpoint)
members = jobs_collection.get("Members", [])
if not members:
return []
jobs = []
for member in members:
job_path = member.get("@odata.id", "")
if not job_path:
continue
try:
job_data = self.get(job_path)
normalized_job = self._normalize_job(job_data)
jobs.append(normalized_job)
except Exception as e:
logger.warning(f"{self.ip}: Failed to get job {job_path}: {e}")
continue
return jobs
def _get_jobs_dell_oem(self) -> List[Dict[str, Any]]:
"""Dell OEM Jobs 조회"""
oem_endpoint = "/Managers/iDRAC.Embedded.1/Oem/Dell/Jobs"
try:
jobs_collection = self.get(oem_endpoint)
except requests.HTTPError as e:
if e.response.status_code == 404:
logger.debug(f"{self.ip}: Dell OEM endpoint not available")
return []
raise
members = jobs_collection.get("Members", [])
if not members:
return []
jobs = []
for member in members:
job_path = member.get("@odata.id", "")
if not job_path:
continue
try:
job_data = self.get(job_path)
normalized_job = self._normalize_job(job_data)
jobs.append(normalized_job)
except Exception as e:
logger.warning(f"{self.ip}: Failed to get Dell OEM job {job_path}: {e}")
continue
return jobs
def _normalize_job(self, job_data: Dict[str, Any]) -> Dict[str, Any]:
"""Redfish Job 데이터를 표준 포맷으로 변환"""
percent = job_data.get("PercentComplete", 0)
if percent is None:
percent = 0
# JobState 매핑
job_state = job_data.get("JobState", "Unknown")
status_map = {
"New": "Scheduled",
"Starting": "Starting",
"Running": "Running",
"Completed": "Completed",
"Failed": "Failed",
"CompletedWithErrors": "Completed with Errors",
"Pending": "Pending",
"Paused": "Paused",
"Stopping": "Stopping",
"Cancelled": "Cancelled",
"Cancelling": "Cancelling"
}
status = status_map.get(job_state, job_state)
# 메시지 처리
messages = job_data.get("Messages", [])
message_text = ""
if messages and isinstance(messages, list):
if messages[0] and isinstance(messages[0], dict):
message_text = messages[0].get("Message", "")
if not message_text:
message_text = job_data.get("Message", "")
return {
"JID": job_data.get("Id", ""),
"Name": job_data.get("Name", ""),
"Status": status,
"PercentComplete": str(percent),
"Message": message_text,
"ScheduledStartTime": job_data.get("ScheduledStartTime", ""),
"StartTime": job_data.get("StartTime", ""),
"EndTime": job_data.get("EndTime", ""),
"LastUpdateTime": job_data.get("EndTime") or job_data.get("StartTime", ""),
}
def close(self):
"""세션 종료"""
self.session.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
# 커스텀 예외
class AuthenticationError(Exception):
"""인증 실패"""
pass
class NotSupportedError(Exception):
"""지원하지 않는 기능"""
pass

View File

@@ -63,6 +63,11 @@
<i class="bi bi-file-code me-1"></i>XML Management <i class="bi bi-file-code me-1"></i>XML Management
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('jobs.jobs_page') }}">
<i class="bi bi-list-task me-1"></i>Job Monitor
</a>
</li>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.admin_panel') }}"> <a class="nav-link" href="{{ url_for('admin.admin_panel') }}">
@@ -118,4 +123,4 @@
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -116,11 +116,10 @@
class="btn btn-success"> class="btn btn-success">
GUID to Excel GUID to Excel
</button> </button>
<button type="submit" formaction="{{ url_for('utils.update_gpu_list') }}" <button type="submit" formaction="{{ url_for('utils.update_gpu_list') }}"
class="btn btn-success"> class="btn btn-success">
GPU to Excel GPU to Excel
</button> </button>
</div> </div>
</form> </form>
</div> </div>
@@ -148,79 +147,78 @@
</div> </div>
</div> </div>
{# 파일 관리 도구 #} {# 파일 관리 도구 #}
<div class="row mb-4"> <div class="row mb-4">
<div class="col"> <div class="col">
<div class="card border shadow-sm"> <div class="card border shadow-sm">
<div class="card-header bg-light border-0 py-2"> <div class="card-header bg-light border-0 py-2">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="bi bi-tools me-2"></i> <i class="bi bi-tools me-2"></i>
파일 관리 도구 파일 관리 도구
</h6> </h6>
</div> </div>
<div class="card-body p-4 file-tools"> <div class="card-body p-4 file-tools">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xxl-5 g-3 align-items-end">
<!-- 한 줄로 정렬: 화면이 넓을 때 5등분 / 좁아지면 자동 줄바꿈 --> <!-- ZIP 다운로드 -->
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xxl-5 g-3 align-items-end"> <div class="col">
<label class="form-label text-nowrap">ZIP 다운로드</label>
<form method="post" action="{{ url_for('main.download_zip') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="input-group">
<input type="text" class="form-control" name="zip_filename" placeholder="파일명" required>
<button class="btn btn-primary" type="submit">다운로드</button>
</div>
</form>
</div>
<!-- 파일 백업 -->
<div class="col">
<label class="form-label text-nowrap">파일 백업</label>
<form method="post" action="{{ url_for('main.backup_files') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="input-group">
<input type="text" class="form-control" name="backup_prefix" placeholder="PO로 시작">
<button class="btn btn-success" type="submit">백업</button>
</div>
</form>
</div>
<!-- MAC 파일 이동 -->
<div class="col">
<label class="form-label text-nowrap">MAC 파일 이동</label>
<form id="macMoveForm" method="post" action="{{ url_for('utils.move_mac_files') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-warning w-100" type="submit">MAC Move</button>
</form>
</div>
<!-- GUID 파일 이동 -->
<div class="col">
<label class="form-label text-nowrap">GUID 파일 이동</label>
<form id="guidMoveForm" method="post" action="{{ url_for('utils.move_guid_files') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-info w-100" type="submit">GUID Move</button>
</form>
</div>
<!-- GPU 파일 이동 -->
<div class="col">
<label class="form-label text-nowrap">GPU 파일 이동</label>
<form id="gpuMoveForm" method="post" action="{{ url_for('utils.move_gpu_files') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-info w-100" type="submit">GPU Move</button>
</form>
</div>
<!-- ZIP 다운로드 -->
<div class="col">
<label class="form-label text-nowrap">ZIP 다운로드</label>
<form method="post" action="{{ url_for('main.download_zip') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="input-group">
<input type="text" class="form-control" name="zip_filename" placeholder="파일명" required>
<button class="btn btn-primary" type="submit">다운로드</button>
</div>
</form>
</div> </div>
<!-- 파일 백업 -->
<div class="col">
<label class="form-label text-nowrap">파일 백업</label>
<form method="post" action="{{ url_for('main.backup_files') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="input-group">
<input type="text" class="form-control" name="backup_prefix" placeholder="PO로 시작">
<button class="btn btn-success" type="submit">백업</button>
</div>
</form>
</div>
<!-- MAC 파일 이동 -->
<div class="col">
<label class="form-label text-nowrap">MAC 파일 이동</label>
<form id="macMoveForm" method="post" action="{{ url_for('utils.move_mac_files') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-warning w-100" type="submit">MAC Move</button>
</form>
</div>
<!-- GUID 파일 이동 -->
<div class="col">
<label class="form-label text-nowrap">GUID 파일 이동</label>
<form id="guidMoveForm" method="post" action="{{ url_for('utils.move_guid_files') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-info w-100" type="submit">GUID Move</button>
</form>
</div>
<!-- GPU 파일 이동 -->
<div class="col">
<label class="form-label text-nowrap">GPU 파일 이동</label>
<form id="gpuMoveForm" method="post" action="{{ url_for('utils.move_gpu_files') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-info w-100" type="submit">GPU Move</button>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
{# 처리된 파일 목록 - 목록별 버튼 스타일 분리 (processed-list) #} {# 처리된 파일 목록 #}
<div class="row mb-4 processed-list"> <div class="row mb-4 processed-list">
<div class="col"> <div class="col">
<div class="card border shadow-sm"> <div class="card border shadow-sm">
@@ -272,7 +270,7 @@
</div> </div>
</div> </div>
{# 백업된 파일 목록 - 목록별 버튼 스타일 분리 (backup-list) #} {# 백업된 파일 목록 #}
<div class="row backup-list"> <div class="row backup-list">
<div class="col"> <div class="col">
<div class="card border shadow-sm"> <div class="card border shadow-sm">
@@ -305,8 +303,8 @@
{% for file in info.files %} {% for file in info.files %}
<div class="col-auto"> <div class="col-auto">
<div class="file-card-compact border rounded p-2 text-center"> <div class="file-card-compact border rounded p-2 text-center">
<a href="{{ url_for('main.download_backup_file', date=date, filename=file) }} <a href="{{ url_for('main.download_backup_file', date=date, filename=file) }}"
" class="text-decoration-none text-dark fw-semibold d-block mb-2 text-nowrap px-2" class="text-decoration-none text-dark fw-semibold d-block mb-2 text-nowrap px-2"
download title="{{ file }}"> download title="{{ file }}">
{{ file.rsplit('.', 1)[0] }} {{ file.rsplit('.', 1)[0] }}
</a> </a>
@@ -473,7 +471,10 @@
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// ─────────────────────────────────────────────────────────────
// 스크립트 선택 시 XML 드롭다운 토글 // 스크립트 선택 시 XML 드롭다운 토글
// ─────────────────────────────────────────────────────────────
const TARGET_SCRIPT = "02-set_config.py"; const TARGET_SCRIPT = "02-set_config.py";
const scriptSelect = document.getElementById('script'); const scriptSelect = document.getElementById('script');
const xmlGroup = document.getElementById('xmlFileGroup'); const xmlGroup = document.getElementById('xmlFileGroup');
@@ -493,7 +494,10 @@ document.addEventListener('DOMContentLoaded', () => {
scriptSelect.addEventListener('change', toggleXml); scriptSelect.addEventListener('change', toggleXml);
} }
// ─────────────────────────────────────────────────────────────
// 파일 보기 모달 // 파일 보기 모달
// ─────────────────────────────────────────────────────────────
const modalEl = document.getElementById('fileViewModal'); const modalEl = document.getElementById('fileViewModal');
const titleEl = document.getElementById('fileViewModalLabel'); const titleEl = document.getElementById('fileViewModalLabel');
const contentEl = document.getElementById('fileViewContent'); const contentEl = document.getElementById('fileViewContent');
@@ -525,7 +529,10 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
// ─────────────────────────────────────────────────────────────
// 진행바 업데이트 // 진행바 업데이트
// ─────────────────────────────────────────────────────────────
window.updateProgress = function (val) { window.updateProgress = function (val) {
const bar = document.getElementById('progressBar'); const bar = document.getElementById('progressBar');
if (!bar) return; if (!bar) return;
@@ -535,10 +542,16 @@ document.addEventListener('DOMContentLoaded', () => {
bar.innerHTML = `<span class="fw-semibold">${v}%</span>`; bar.innerHTML = `<span class="fw-semibold">${v}%</span>`;
}; };
// ─────────────────────────────────────────────────────────────
// CSRF 토큰 // CSRF 토큰
// ─────────────────────────────────────────────────────────────
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value || ''; const csrfToken = document.querySelector('input[name="csrf_token"]')?.value || '';
// ─────────────────────────────────────────────────────────────
// 공통 POST 함수 // 공통 POST 함수
// ─────────────────────────────────────────────────────────────
async function postFormAndHandle(url) { async function postFormAndHandle(url) {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
@@ -562,7 +575,10 @@ document.addEventListener('DOMContentLoaded', () => {
return { success: true, html: true }; return { success: true, html: true };
} }
// ─────────────────────────────────────────────────────────────
// MAC 파일 이동 // MAC 파일 이동
// ─────────────────────────────────────────────────────────────
const macForm = document.getElementById('macMoveForm'); const macForm = document.getElementById('macMoveForm');
if (macForm) { if (macForm) {
macForm.addEventListener('submit', async (e) => { macForm.addEventListener('submit', async (e) => {
@@ -582,7 +598,10 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
// ─────────────────────────────────────────────────────────────
// GUID 파일 이동 // GUID 파일 이동
// ─────────────────────────────────────────────────────────────
const guidForm = document.getElementById('guidMoveForm'); const guidForm = document.getElementById('guidMoveForm');
if (guidForm) { if (guidForm) {
guidForm.addEventListener('submit', async (e) => { guidForm.addEventListener('submit', async (e) => {
@@ -602,83 +621,20 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
// IP 폼 제출
const ipForm = document.getElementById("ipForm"); // ─────────────────────────────────────────────────────────────
if (ipForm) {
ipForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const formData = new FormData(ipForm);
const btn = ipForm.querySelector('button[type="submit"]');
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>처리 중...';
try {
const res = await fetch(ipForm.action, {
method: "POST",
body: formData
});
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
console.log("[DEBUG] process_ips 응답:", data);
if (data.job_id) {
pollProgress(data.job_id);
} else {
// job_id가 없으면 완료로 간주
window.updateProgress(100);
setTimeout(() => location.reload(), 1000);
}
} catch (err) {
console.error("처리 중 오류:", err);
alert("처리 중 오류 발생: " + err.message);
btn.disabled = false;
btn.innerHTML = originalHtml;
}
});
}
// 진행률 폴링 함수
function pollProgress(jobId) {
const interval = setInterval(async () => {
try {
const res = await fetch(`/progress_status/${jobId}`);
if (!res.ok) {
clearInterval(interval);
return;
}
const data = await res.json();
if (data.progress !== undefined) {
window.updateProgress(data.progress);
}
// 완료 시 (100%)
if (data.progress >= 100) {
clearInterval(interval);
window.updateProgress(100);
// 페이지 새로고침
setTimeout(() => location.reload(), 1500);
}
} catch (err) {
console.error('진행률 확인 중 오류:', err);
clearInterval(interval);
}
}, 500); // 0.5초마다 확인
}
// 알림 자동 닫기 // 알림 자동 닫기
// ─────────────────────────────────────────────────────────────
setTimeout(() => { setTimeout(() => {
document.querySelectorAll('.alert').forEach(alert => { document.querySelectorAll('.alert').forEach(alert => {
const bsAlert = new bootstrap.Alert(alert); const bsAlert = new bootstrap.Alert(alert);
bsAlert.close(); bsAlert.close();
}); });
}, 5000); }, 5000);
}); });
</script> </script>
<!-- 외부 script.js 파일만 로드 -->
<!-- 외부 script.js 파일 (IP 폼 처리 로직 포함) -->
<script src="{{ url_for('static', filename='script.js') }}"></script> <script src="{{ url_for('static', filename='script.js') }}"></script>
{% endblock %} {% endblock %}

643
backend/templates/jobs.html Normal file
View File

@@ -0,0 +1,643 @@
{% 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.10.0.12&#10;# 주석 가능"></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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
}[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 %}

View File

@@ -25,6 +25,7 @@ INSTANCE_DIR.mkdir(parents=True, exist_ok=True)
(DATA_DIR / "idrac_info").mkdir(parents=True, exist_ok=True) (DATA_DIR / "idrac_info").mkdir(parents=True, exist_ok=True)
(DATA_DIR / "mac").mkdir(parents=True, exist_ok=True) (DATA_DIR / "mac").mkdir(parents=True, exist_ok=True)
(DATA_DIR / "guid_file").mkdir(parents=True, exist_ok=True) (DATA_DIR / "guid_file").mkdir(parents=True, exist_ok=True)
(DATA_DIR / "gpu_serial").mkdir(parents=True, exist_ok=True)
(DATA_DIR / "mac_backup").mkdir(parents=True, exist_ok=True) (DATA_DIR / "mac_backup").mkdir(parents=True, exist_ok=True)
(DATA_DIR / "server_list").mkdir(parents=True, exist_ok=True) (DATA_DIR / "server_list").mkdir(parents=True, exist_ok=True)
(DATA_DIR / "temp_ip").mkdir(parents=True, exist_ok=True) (DATA_DIR / "temp_ip").mkdir(parents=True, exist_ok=True)
@@ -57,6 +58,7 @@ class Config:
IDRAC_INFO_FOLDER = (DATA_DIR / "idrac_info").as_posix() IDRAC_INFO_FOLDER = (DATA_DIR / "idrac_info").as_posix()
MAC_FOLDER = (DATA_DIR / "mac").as_posix() MAC_FOLDER = (DATA_DIR / "mac").as_posix()
GUID_FOLDER = (DATA_DIR / "guid_file").as_posix() GUID_FOLDER = (DATA_DIR / "guid_file").as_posix()
GPU_FOLDER = (DATA_DIR / "gpu_serial").as_posix()
MAC_BACKUP_FOLDER = (DATA_DIR / "mac_backup").as_posix() MAC_BACKUP_FOLDER = (DATA_DIR / "mac_backup").as_posix()
SERVER_LIST_FOLDER = (DATA_DIR / "server_list").as_posix() SERVER_LIST_FOLDER = (DATA_DIR / "server_list").as_posix()
LOG_FOLDER = (DATA_DIR / "logs").as_posix() LOG_FOLDER = (DATA_DIR / "logs").as_posix()

Binary file not shown.