update
This commit is contained in:
2
app.py
2
app.py
@@ -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)
|
||||||
|
|||||||
@@ -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
279
backend/routes/jobs.py
Normal 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")
|
||||||
@@ -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")
|
||||||
315
backend/services/idrac_jobs.py
Normal file
315
backend/services/idrac_jobs.py
Normal 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"])
|
||||||
241
backend/services/redfish_client.py
Normal file
241
backend/services/redfish_client.py
Normal 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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
643
backend/templates/jobs.html
Normal 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.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 %}
|
||||||
@@ -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()
|
||||||
|
|||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
Reference in New Issue
Block a user