Update 2025-12-19 16:23:03
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -237,3 +237,55 @@ def test_bot(bot_id):
|
||||
flash(f"테스트 실패: {e}", "danger")
|
||||
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
|
||||
# ▼▼▼ 시스템 로그 뷰어 ▼▼▼
|
||||
@admin_bp.route("/admin/logs", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def view_logs():
|
||||
import os
|
||||
import re
|
||||
from collections import deque
|
||||
|
||||
log_folder = current_app.config.get('LOG_FOLDER')
|
||||
log_file = os.path.join(log_folder, 'app.log') if log_folder else None
|
||||
|
||||
# 1. 실제 ANSI 이스케이프 코드 (\x1B로 시작)
|
||||
ansi_escape = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]')
|
||||
|
||||
# 2. 텍스트로 찍힌 ANSI 코드 패턴 (예: [36m, [0m 등) - Werkzeug가 이스케이프 된 상태로 로그에 남길 경우 대비
|
||||
literal_ansi = re.compile(r'\[[0-9;]+m')
|
||||
|
||||
# 3. 제어 문자 제거
|
||||
control_char_re = re.compile(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]')
|
||||
|
||||
logs = []
|
||||
if log_file and os.path.exists(log_file):
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as f:
|
||||
raw_lines = deque(f, 1000)
|
||||
|
||||
for line in raw_lines:
|
||||
# A. 실제 ANSI 코드 제거
|
||||
clean_line = ansi_escape.sub('', line)
|
||||
|
||||
# B. 리터럴 ANSI 패턴 제거 (사용자가 [36m 등을 텍스트로 보고 있다면 이것이 원인)
|
||||
clean_line = literal_ansi.sub('', clean_line)
|
||||
|
||||
# C. 제어 문자 제거
|
||||
clean_line = control_char_re.sub('', clean_line)
|
||||
|
||||
# D. 앞뒤 공백 제거
|
||||
clean_line = clean_line.strip()
|
||||
|
||||
# E. 빈 줄 제외
|
||||
if clean_line:
|
||||
logs.append(clean_line)
|
||||
|
||||
except Exception as e:
|
||||
logs = [f"Error reading log file: {str(e)}"]
|
||||
else:
|
||||
logs = [f"Log file not found at: {log_file}"]
|
||||
|
||||
return render_template("admin_logs.html", logs=logs)
|
||||
|
||||
@@ -293,6 +293,13 @@ def register():
|
||||
return redirect(url_for("auth.login"))
|
||||
else:
|
||||
if request.method == "POST":
|
||||
# 폼 검증 실패 에러를 Flash 메시지로 출력
|
||||
for field_name, errors in form.errors.items():
|
||||
for error in errors:
|
||||
# 필드 객체 가져오기 (라벨 텍스트 확인용)
|
||||
field = getattr(form, field_name, None)
|
||||
label = field.label.text if field else field_name
|
||||
flash(f"{label}: {error}", "warning")
|
||||
current_app.logger.info("REGISTER: form errors=%s", form.errors)
|
||||
|
||||
return render_template("register.html", form=form)
|
||||
|
||||
@@ -47,11 +47,43 @@ def index():
|
||||
info_dir = Path(Config.IDRAC_INFO_FOLDER)
|
||||
backup_dir = Path(Config.BACKUP_FOLDER)
|
||||
|
||||
scripts = [f.name for f in script_dir.glob("*") if f.is_file() and f.name != ".env"]
|
||||
scripts = natsorted(scripts)
|
||||
# 1. 스크립트 목록 조회 및 카테고리 분류
|
||||
all_scripts = [f.name for f in script_dir.glob("*") if f.is_file() and f.name != ".env"]
|
||||
all_scripts = natsorted(all_scripts)
|
||||
|
||||
grouped_scripts = {}
|
||||
for script in all_scripts:
|
||||
upper = script.upper()
|
||||
category = "General"
|
||||
if upper.startswith("GPU"):
|
||||
category = "GPU"
|
||||
elif upper.startswith("LOM"):
|
||||
category = "LOM"
|
||||
elif upper.startswith("TYPE") or upper.startswith("XE"):
|
||||
category = "Server Models"
|
||||
elif "MAC" in upper:
|
||||
category = "MAC Info"
|
||||
elif "GUID" in upper:
|
||||
category = "GUID Info"
|
||||
elif "SET_" in upper or "CONFIG" in upper:
|
||||
category = "Configuration"
|
||||
|
||||
if category not in grouped_scripts:
|
||||
grouped_scripts[category] = []
|
||||
grouped_scripts[category].append(script)
|
||||
|
||||
# 카테고리 정렬 (General은 마지막에)
|
||||
sorted_categories = sorted(grouped_scripts.keys())
|
||||
if "General" in sorted_categories:
|
||||
sorted_categories.remove("General")
|
||||
sorted_categories.append("General")
|
||||
|
||||
grouped_scripts_sorted = {k: grouped_scripts[k] for k in sorted_categories}
|
||||
|
||||
# 2. XML 파일 목록
|
||||
xml_files = [f.name for f in xml_dir.glob("*.xml")]
|
||||
|
||||
# 페이지네이션
|
||||
# 3. 페이지네이션 및 파일 목록
|
||||
page = int(request.args.get("page", 1))
|
||||
info_files = [f.name for f in info_dir.glob("*") if f.is_file()]
|
||||
info_files = natsorted(info_files)
|
||||
@@ -62,11 +94,10 @@ def index():
|
||||
|
||||
total_pages = (len(info_files) + Config.FILES_PER_PAGE - 1) // Config.FILES_PER_PAGE
|
||||
|
||||
# ✅ 추가: 10개 단위로 표시될 페이지 범위 계산
|
||||
start_page = ((page - 1) // 10) * 10 + 1
|
||||
end_page = min(start_page + 9, total_pages)
|
||||
|
||||
# 백업 폴더 목록 (디렉터리만)
|
||||
# 4. 백업 폴더 목록
|
||||
backup_dirs = [d for d in backup_dir.iterdir() if d.is_dir()]
|
||||
backup_dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
@@ -91,7 +122,8 @@ def index():
|
||||
backup_files=backup_files,
|
||||
total_backup_pages=total_backup_pages,
|
||||
backup_page=backup_page,
|
||||
scripts=scripts,
|
||||
scripts=all_scripts, # 기존 리스트 호환
|
||||
grouped_scripts=grouped_scripts_sorted, # 카테고리별 분류
|
||||
xml_files=xml_files,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,26 +32,48 @@ def diff_scp():
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
# 파일 내용 읽기 (LF로 통일)
|
||||
content1 = file1_path.read_text(encoding="utf-8").replace("\r\n", "\n").splitlines()
|
||||
content2 = file2_path.read_text(encoding="utf-8").replace("\r\n", "\n").splitlines()
|
||||
|
||||
# Diff 생성
|
||||
diff = difflib.unified_diff(
|
||||
content1, content2,
|
||||
fromfile=file1_name,
|
||||
tofile=file2_name,
|
||||
lineterm=""
|
||||
)
|
||||
# 파일 내용 읽기 (LF로 통일)
|
||||
# Monaco Editor에 원본 텍스트를 그대로 전달하기 위해 splitlines() 제거
|
||||
# 파일 내용 읽기 (LF로 통일)
|
||||
logger.info(f"Reading file1: {file1_path}")
|
||||
content1 = file1_path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||
|
||||
diff_content = "\n".join(diff)
|
||||
logger.info(f"Reading file2: {file2_path}")
|
||||
content2 = file2_path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||
|
||||
return render_template("scp_diff.html", file1=file1_name, file2=file2_name, diff_content=diff_content)
|
||||
logger.info(f"Content1 length: {len(content1)}, Content2 length: {len(content2)}")
|
||||
|
||||
return render_template("scp_diff.html",
|
||||
file1=file1_name,
|
||||
file2=file2_name,
|
||||
content1=content1,
|
||||
content2=content2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Diff error: {e}")
|
||||
flash(f"비교 중 오류가 발생했습니다: {str(e)}", "danger")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
@scp_bp.route("/scp/content/<path:filename>")
|
||||
@login_required
|
||||
def get_scp_content(filename):
|
||||
"""
|
||||
XML 파일 내용을 반환하는 API (Monaco Editor용)
|
||||
"""
|
||||
try:
|
||||
safe_name = sanitize_preserve_unicode(filename)
|
||||
path = Path(Config.XML_FOLDER) / safe_name
|
||||
|
||||
if not path.exists():
|
||||
return "File not found", 404
|
||||
|
||||
# 텍스트로 읽어서 반환
|
||||
content = path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||
return content, 200, {'Content-Type': 'text/plain; charset=utf-8'}
|
||||
except Exception as e:
|
||||
logger.error(f"Content read error: {e}")
|
||||
return str(e), 500
|
||||
|
||||
@scp_bp.route("/scp/export", methods=["POST"])
|
||||
@login_required
|
||||
def export_scp():
|
||||
|
||||
@@ -290,13 +290,79 @@ def update_gpu_list():
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
@utils_bp.route("/download_excel")
|
||||
@login_required
|
||||
def download_excel():
|
||||
path = Path(Config.SERVER_LIST_FOLDER) / "mac_info.xlsx"
|
||||
if not path.is_file():
|
||||
flash("엑셀 파일을 찾을 수 없습니다.", "danger")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@utils_bp.route("/scan_network", methods=["POST"])
|
||||
@login_required
|
||||
def scan_network():
|
||||
"""
|
||||
지정된 IP 범위(Start ~ End)에 대해 Ping 테스트를 수행하고
|
||||
응답이 있는 IP 목록을 반환합니다.
|
||||
"""
|
||||
import ipaddress
|
||||
import platform
|
||||
import concurrent.futures
|
||||
|
||||
data = request.get_json()
|
||||
start_ip_str = data.get('start_ip')
|
||||
end_ip_str = data.get('end_ip')
|
||||
|
||||
if not start_ip_str or not end_ip_str:
|
||||
return jsonify({"success": False, "error": "시작 IP와 종료 IP를 모두 입력해주세요."}), 400
|
||||
|
||||
try:
|
||||
start_ip = ipaddress.IPv4Address(start_ip_str)
|
||||
end_ip = ipaddress.IPv4Address(end_ip_str)
|
||||
|
||||
if start_ip > end_ip:
|
||||
return jsonify({"success": False, "error": "시작 IP가 종료 IP보다 큽니다."}), 400
|
||||
|
||||
# IP 개수 제한 (너무 많은 스캔 방지, 예: C클래스 2개 분량 512개)
|
||||
if int(end_ip) - int(start_ip) > 512:
|
||||
return jsonify({"success": False, "error": "스캔 범위가 너무 넓습니다. (최대 512개)"}), 400
|
||||
|
||||
except ValueError:
|
||||
return jsonify({"success": False, "error": "유효하지 않은 IP 주소 형식입니다."}), 400
|
||||
|
||||
# Ping 함수 정의
|
||||
def ping_ip(ip_obj):
|
||||
ip = str(ip_obj)
|
||||
param = '-n' if platform.system().lower() == 'windows' else '-c'
|
||||
timeout_param = '-w' if platform.system().lower() == 'windows' else '-W'
|
||||
# Windows: -w 200 (ms), Linux: -W 1 (s)
|
||||
timeout_val = '200' if platform.system().lower() == 'windows' else '1'
|
||||
|
||||
command = ['ping', param, '1', timeout_param, timeout_val, ip]
|
||||
|
||||
try:
|
||||
# shell=False로 보안 강화, stdout/stderr 무시
|
||||
res = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return ip if res.returncode == 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
active_ips = []
|
||||
|
||||
# IP 리스트 생성
|
||||
# ipaddress 모듈을 사용하여 범위 내 IP 생성
|
||||
target_ips = []
|
||||
temp_ip = start_ip
|
||||
while temp_ip <= end_ip:
|
||||
target_ips.append(temp_ip)
|
||||
temp_ip += 1
|
||||
|
||||
# 병렬 처리 (최대 50 쓰레드)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
|
||||
results = executor.map(ping_ip, target_ips)
|
||||
|
||||
# 결과 수집 (None 제외)
|
||||
active_ips = [ip for ip in results if ip is not None]
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"active_ips": active_ips,
|
||||
"count": len(active_ips),
|
||||
"message": f"스캔 완료: {len(active_ips)}개의 활성 IP 발견"
|
||||
})
|
||||
Binary file not shown.
@@ -60,6 +60,19 @@ def setup_logging(app: Optional[object] = None) -> logging.Logger:
|
||||
# Flask 앱 로거에도 동일 핸들러 바인딩
|
||||
app.logger.handlers = root.handlers
|
||||
app.logger.setLevel(root.level)
|
||||
# 루트 로거로 전파되면 메시지가 두 번 출력되므로 방지
|
||||
app.logger.propagate = False
|
||||
|
||||
# 제3자 라이브러리 로그 레벨 조정 (너무 시끄러운 경우)
|
||||
# werkzeug: 기본적인 HTTP 요청 로그(GET/POST 등)를 숨김 (WARNING 이상만 표시)
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
logging.getLogger("socketio").setLevel(logging.WARNING)
|
||||
logging.getLogger("engineio").setLevel(logging.WARNING)
|
||||
|
||||
# httpx, telegram 라이브러리의 HTTP 요청 로그 숨기기
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logging.getLogger("telegram").setLevel(logging.WARNING)
|
||||
|
||||
root.info("Logger initialized | level=%s | file=%s", _DEF_LEVEL, log_path)
|
||||
return root
|
||||
@@ -1,5 +1,7 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 스크립트 선택 시 XML 드롭다운 토글
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -77,6 +79,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value || '';
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// IP 입력 데이터 보존 (Local Storage)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const ipTextarea = document.getElementById('ips');
|
||||
const ipForm = document.getElementById('ipForm');
|
||||
const STORAGE_KEY_IP = 'ip_input_draft';
|
||||
|
||||
if (ipTextarea) {
|
||||
// 1. 페이지 로드 시 저장된 값 복원
|
||||
const savedIps = localStorage.getItem(STORAGE_KEY_IP);
|
||||
if (savedIps) {
|
||||
ipTextarea.value = savedIps;
|
||||
// 라인 수 업데이트 트리거
|
||||
if (window.updateIpCount) window.updateIpCount();
|
||||
}
|
||||
|
||||
// 2. 입력 시마다 저장
|
||||
ipTextarea.addEventListener('input', () => {
|
||||
localStorage.setItem(STORAGE_KEY_IP, ipTextarea.value);
|
||||
// script.js에 있는 updateIpCount 호출 (있다면)
|
||||
if (window.updateIpCount) window.updateIpCount();
|
||||
});
|
||||
|
||||
// 3. 폼 제출 성공 시 초기화?
|
||||
// 사용자의 의도에 따라 다름: "변경이 되지 않는 이상 계속 가지고 있게"
|
||||
// -> 제출 후에도 유지하는 것이 요청 사항에 부합함.
|
||||
// 만약 '성공적으로 작업이 끝나면 지워달라'는 요청이 있으면 여기를 수정.
|
||||
// 현재 요청: "페이지가 리셋이되도 변경이 되지 않는이상 계속 가지고있게" -> 유지.
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 공통 POST 함수
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -160,4 +193,75 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// IP 스캔 로직 (Modal)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const btnScan = document.getElementById('btnStartScan');
|
||||
if (btnScan) {
|
||||
btnScan.addEventListener('click', async () => {
|
||||
const startIp = '10.10.0.1';
|
||||
const endIp = '10.10.0.255';
|
||||
const ipsTextarea = document.getElementById('ips');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
|
||||
// UI 상태 변경 (로딩 중)
|
||||
const originalIcon = btnScan.innerHTML;
|
||||
btnScan.disabled = true;
|
||||
btnScan.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
|
||||
|
||||
// 메인 진행바 활용
|
||||
if (progressBar) {
|
||||
const progressContainer = progressBar.closest('.progress');
|
||||
if (progressContainer) {
|
||||
progressContainer.parentElement.classList.remove('d-none');
|
||||
}
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
progressBar.textContent = '네트워크 스캔 중... (10.10.0.1 ~ 255)';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/utils/scan_network', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ start_ip: startIp, end_ip: endIp })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
if (data.active_ips && data.active_ips.length > 0) {
|
||||
ipsTextarea.value = data.active_ips.join('\n');
|
||||
// 이벤트 트리거
|
||||
ipsTextarea.dispatchEvent(new Event('input'));
|
||||
|
||||
alert(`스캔 완료: ${data.active_ips.length}개의 활성 IP를 찾았습니다.`);
|
||||
} else {
|
||||
alert('활성 IP를 발견하지 못했습니다.');
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error || 'Unknown error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('오류 발생: ' + (err.message || err));
|
||||
} finally {
|
||||
// 상태 복구
|
||||
btnScan.disabled = false;
|
||||
btnScan.innerHTML = originalIcon;
|
||||
|
||||
if (progressBar) {
|
||||
// 진행바 초기화
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.textContent = '0%';
|
||||
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -1,72 +1,151 @@
|
||||
{# backend/templates/admin.html #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}관리자 패널 - Dell Server Info{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="card-title mb-0">Admin Page</h3>
|
||||
<a href="{{ url_for('admin.settings') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-gear-fill me-1"></i>시스템 설정
|
||||
</a>
|
||||
</div>
|
||||
<div class="container py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">
|
||||
<i class="bi bi-shield-lock text-primary me-2"></i>관리자 패널
|
||||
</h2>
|
||||
<p class="text-muted mb-0">사용자 관리 및 시스템 설정을 수행합니다.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('admin.view_logs') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-journal-text me-1"></i>로그 보기
|
||||
</a>
|
||||
<a href="{{ url_for('admin.settings') }}" class="btn btn-primary">
|
||||
<i class="bi bi-gear-fill me-1"></i>시스템 설정
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mt-2">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
<!-- Dashboard Stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100 bg-primary bg-opacity-10">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<div class="rounded-circle bg-primary text-white p-3 me-3">
|
||||
<i class="bi bi-people-fill fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="text-primary fw-bold mb-1">총 사용자</h6>
|
||||
<h3 class="mb-0 fw-bold">{{ users|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100 bg-success bg-opacity-10">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<div class="rounded-circle bg-success text-white p-3 me-3">
|
||||
<i class="bi bi-person-check-fill fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="text-success fw-bold mb-1">활성 사용자</h6>
|
||||
{% set active_users = users | selectattr("is_active") | list %}
|
||||
<h3 class="mb-0 fw-bold">{{ active_users|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100 bg-warning bg-opacity-10">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<div class="rounded-circle bg-warning text-white p-3 me-3">
|
||||
<i class="bi bi-person-dash-fill fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="text-warning fw-bold mb-1">승인 대기</h6>
|
||||
<h3 class="mb-0 fw-bold">{{ (users|length) - (active_users|length) }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management Table -->
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white border-bottom py-3">
|
||||
<h5 class="mb-0 fw-bold">
|
||||
<i class="bi bi-person-lines-fill text-primary me-2"></i>사용자 목록
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th style="width:60px">ID</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th style="width:80px">Active</th>
|
||||
<th style="width:260px">Action</th>
|
||||
<th class="ps-4 py-3 text-secondary text-uppercase small fw-bold" style="width: 60px;">NO</th>
|
||||
<th class="py-3 text-secondary text-uppercase small fw-bold">이름</th>
|
||||
<th class="py-3 text-secondary text-uppercase small fw-bold">ID (Email)</th>
|
||||
<th class="py-3 text-secondary text-uppercase small fw-bold">상태</th>
|
||||
<th class="py-3 text-secondary text-uppercase small fw-bold text-end pe-4">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td class="ps-4 fw-bold text-secondary">{{ loop.index }}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div
|
||||
class="avatar-initial rounded-circle bg-light text-primary fw-bold me-2 d-flex align-items-center justify-content-center border"
|
||||
style="width: 32px; height: 32px; font-size: 0.9rem;">
|
||||
{{ user.username[:1] | upper }}
|
||||
</div>
|
||||
<span class="fw-bold text-dark">{{ user.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-secondary small font-monospace">{{ user.email }}</td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="badge bg-success">Yes</span>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-pill px-3">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>Active
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">No</span>
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle rounded-pill px-3">
|
||||
<i class="bi bi-hourglass-split me-1"></i>Pending
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_admin %}
|
||||
<span
|
||||
class="badge bg-primary-subtle text-primary border border-primary-subtle rounded-pill px-2 ms-1">Admin</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not user.is_active %}
|
||||
<a href="{{ url_for('admin.approve_user', user_id=user.id) }}"
|
||||
class="btn btn-success btn-sm me-1">Approve</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.delete_user', user_id=user.id) }}" class="btn btn-danger btn-sm me-1"
|
||||
onclick="return confirm('사용자 {{ user.username }} (ID={{ user.id }}) 를 삭제하시겠습니까?');">
|
||||
Delete
|
||||
</a>
|
||||
<td class="text-end pe-4">
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
{% if not user.is_active %}
|
||||
<a href="{{ url_for('admin.approve_user', user_id=user.id) }}"
|
||||
class="btn btn-sm btn-success text-white d-flex align-items-center gap-1" title="가입 승인">
|
||||
<i class="bi bi-check-lg"></i>승인
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Change Password 버튼: 모달 오픈 -->
|
||||
<button type="button" class="btn btn-primary btn-sm" data-user-id="{{ user.id }}"
|
||||
data-username="{{ user.username | e }}" data-bs-toggle="modal" data-bs-target="#changePasswordModal">
|
||||
Change Password
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary d-flex align-items-center gap-1"
|
||||
data-user-id="{{ user.id }}" data-username="{{ user.username | e }}" data-bs-toggle="modal"
|
||||
data-bs-target="#changePasswordModal">
|
||||
<i class="bi bi-key"></i>비밀번호
|
||||
</button>
|
||||
|
||||
<a href="{{ url_for('admin.delete_user', user_id=user.id) }}"
|
||||
class="btn btn-sm btn-outline-danger d-flex align-items-center gap-1"
|
||||
onclick="return confirm('⚠️ 경고: 사용자 [{{ user.username }}]님을 정말 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.');">
|
||||
<i class="bi bi-trash"></i>삭제
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not users %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-5 text-muted">사용자가 없습니다.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -75,41 +154,44 @@
|
||||
</div>
|
||||
|
||||
{# ========== Change Password Modal ========== #}
|
||||
<div class="modal fade" id="changePasswordModal" tabindex="-1" aria-labelledby="changePasswordModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal fade" id="changePasswordModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<form id="changePasswordForm" method="post" action="">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="changePasswordModalLabel">Change Password</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
<div class="modal-header bg-light">
|
||||
<h5 class="modal-title fw-bold">
|
||||
<i class="bi bi-key-fill me-2"></i>비밀번호 변경
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">User:</small>
|
||||
<div id="modalUserInfo" class="fw-bold"></div>
|
||||
<div class="modal-body p-4">
|
||||
<div class="alert alert-light border mb-4 d-flex align-items-center">
|
||||
<i class="bi bi-person-circle fs-4 me-3 text-secondary"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">대상 사용자</small>
|
||||
<span id="modalUserInfo" class="fw-bold text-dark fs-5"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="newPasswordInput" class="form-label">New password</label>
|
||||
<label for="newPasswordInput" class="form-label fw-semibold">새 비밀번호</label>
|
||||
<input id="newPasswordInput" name="new_password" type="password" class="form-control" required minlength="8"
|
||||
placeholder="Enter new password">
|
||||
<div class="form-text">최소 8자 이상을 권장합니다.</div>
|
||||
placeholder="최소 8자 이상">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="confirmPasswordInput" class="form-label">Confirm password</label>
|
||||
<label for="confirmPasswordInput" class="form-label fw-semibold">비밀번호 확인</label>
|
||||
<input id="confirmPasswordInput" name="confirm_password" type="password" class="form-control" required
|
||||
minlength="8" placeholder="Confirm new password">
|
||||
minlength="8" placeholder="비밀번호 재입력">
|
||||
<div id="pwMismatch" class="invalid-feedback">비밀번호가 일치하지 않습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button id="modalSubmitBtn" type="submit" class="btn btn-primary">Change Password</button>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button id="modalSubmitBtn" type="submit" class="btn btn-primary px-4">변경 저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
245
backend/templates/admin_logs.html
Normal file
245
backend/templates/admin_logs.html
Normal file
@@ -0,0 +1,245 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}시스템 로그 - Dell Server Info{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* 전체 레이아웃 */
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 600px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 툴바 (헤더) */
|
||||
.editor-toolbar {
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 에디터 본문 */
|
||||
#monaco-editor-root {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 로딩 인디케이터 */
|
||||
.editor-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #d4d4d4;
|
||||
font-size: 1.1rem;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">
|
||||
<i class="bi bi-terminal text-dark me-2"></i>시스템 로그
|
||||
</h2>
|
||||
<p class="text-muted mb-0 small">최근 생성된 1000줄의 시스템 로그를 실시간으로 확인합니다.</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>돌아가기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<!-- Toolbar -->
|
||||
<div class="editor-toolbar">
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<div class="input-group input-group-sm" style="width: 250px;">
|
||||
<span class="input-group-text bg-dark border-secondary text-light"><i
|
||||
class="bi bi-search"></i></span>
|
||||
<input type="text" id="logSearch" class="form-control bg-dark border-secondary text-light"
|
||||
placeholder="검색어 입력...">
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<input type="checkbox" class="btn-check" id="checkInfo" checked autocomplete="off">
|
||||
<label class="btn btn-outline-secondary text-light" for="checkInfo">INFO</label>
|
||||
|
||||
<input type="checkbox" class="btn-check" id="checkWarn" checked autocomplete="off">
|
||||
<label class="btn btn-outline-warning" for="checkWarn">WARN</label>
|
||||
|
||||
<input type="checkbox" class="btn-check" id="checkError" checked autocomplete="off">
|
||||
<label class="btn btn-outline-danger" for="checkError">ERROR</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-light" id="btnScrollBottom">
|
||||
<i class="bi bi-arrow-down-circle me-1"></i>맨 아래로
|
||||
</button>
|
||||
<a href="{{ url_for('admin.view_logs') }}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>새로고침
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Area -->
|
||||
<div id="monaco-editor-root">
|
||||
<div class="editor-loading">
|
||||
<div class="spinner-border text-light me-3" role="status"></div>
|
||||
<div>로그 뷰어를 불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- Monaco Editor Loader -->
|
||||
<script src="https://unpkg.com/monaco-editor@0.45.0/min/vs/loader.js"></script>
|
||||
|
||||
<script>
|
||||
// 서버에서 전달된 로그 데이터 (Python list -> JS array)
|
||||
// tojson safe 필터 사용
|
||||
const allLogs = {{ logs | tojson | safe }};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof require === 'undefined') {
|
||||
document.querySelector('.editor-loading').innerHTML =
|
||||
'<div class="text-danger"><i class="bi bi-exclamation-triangle me-2"></i>Monaco Editor를 로드할 수 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
require.config({ paths: { 'vs': 'https://unpkg.com/monaco-editor@0.45.0/min/vs' } });
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
var container = document.getElementById('monaco-editor-root');
|
||||
container.innerHTML = ''; // 로딩 제거
|
||||
|
||||
// 1. 커스텀 로그 언어 정의 (간단한 하이라이팅)
|
||||
monaco.languages.register({ id: 'simpleLog' });
|
||||
monaco.languages.setMonarchTokensProvider('simpleLog', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/\[INFO\]|INFO:/, 'info-token'],
|
||||
[/\[WARNING\]|\[WARN\]|WARNING:|WARN:/, 'warn-token'],
|
||||
[/\[ERROR\]|ERROR:|Traceback/, 'error-token'],
|
||||
[/\[DEBUG\]|DEBUG:/, 'debug-token'],
|
||||
[/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}/, 'date-token'],
|
||||
[/".*?"/, 'string']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 테마 정의
|
||||
monaco.editor.defineTheme('logTheme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'info-token', foreground: '4ec9b0' },
|
||||
{ token: 'warn-token', foreground: 'cca700', fontStyle: 'bold' },
|
||||
{ token: 'error-token', foreground: 'f44747', fontStyle: 'bold' },
|
||||
{ token: 'debug-token', foreground: '808080' },
|
||||
{ token: 'date-token', foreground: '569cd6' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#1e1e1e'
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 에디터 생성
|
||||
var editor = monaco.editor.create(container, {
|
||||
value: allLogs.join('\n'),
|
||||
language: 'simpleLog',
|
||||
theme: 'logTheme',
|
||||
readOnly: true,
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 13,
|
||||
lineHeight: 19, // 밀도 조절
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
renderLineHighlight: 'all',
|
||||
contextmenu: false,
|
||||
padding: { top: 10, bottom: 10 }
|
||||
});
|
||||
|
||||
// 4. 필터링 로직
|
||||
function updateLogs() {
|
||||
const query = document.getElementById('logSearch').value.toLowerCase();
|
||||
const showInfo = document.getElementById('checkInfo').checked;
|
||||
const showWarn = document.getElementById('checkWarn').checked;
|
||||
const showError = document.getElementById('checkError').checked;
|
||||
|
||||
const filtered = allLogs.filter(line => {
|
||||
const lower = line.toLowerCase();
|
||||
|
||||
// 레벨 체크 (매우 단순화)
|
||||
let levelMatch = false;
|
||||
|
||||
const isError = lower.includes('[error]') || lower.includes('error:') || lower.includes('traceback');
|
||||
const isWarn = lower.includes('[warning]') || lower.includes('[warn]') || lower.includes('warn:');
|
||||
const isInfo = lower.includes('[info]') || lower.includes('info:');
|
||||
|
||||
if (isError) {
|
||||
if (showError) levelMatch = true;
|
||||
} else if (isWarn) {
|
||||
if (showWarn) levelMatch = true;
|
||||
} else if (isInfo) {
|
||||
if (showInfo) levelMatch = true;
|
||||
} else {
|
||||
// 레벨 키워드가 없는 줄은 기본적으로 표시 (맥락 유지)
|
||||
levelMatch = true;
|
||||
}
|
||||
|
||||
if (!levelMatch) return false;
|
||||
|
||||
// 검색어 체크
|
||||
if (query && !lower.includes(query)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 현재 스크롤 위치 저장? 아니면 항상 아래로? -> 보통 필터링하면 아래로 가는게 편함
|
||||
const currentModel = editor.getModel();
|
||||
if (currentModel) {
|
||||
currentModel.setValue(filtered.join('\n'));
|
||||
}
|
||||
// editor.revealLine(editor.getModel().getLineCount());
|
||||
}
|
||||
|
||||
// 이벤트 연결
|
||||
document.getElementById('logSearch').addEventListener('keyup', updateLogs);
|
||||
document.getElementById('checkInfo').addEventListener('change', updateLogs);
|
||||
document.getElementById('checkWarn').addEventListener('change', updateLogs);
|
||||
document.getElementById('checkError').addEventListener('change', updateLogs);
|
||||
|
||||
// 맨 아래로 버튼
|
||||
document.getElementById('btnScrollBottom').addEventListener('click', function () {
|
||||
editor.revealLine(editor.getModel().getLineCount());
|
||||
});
|
||||
|
||||
// 초기 스크롤 (약간의 지연 후)
|
||||
setTimeout(() => {
|
||||
editor.revealLine(editor.getModel().getLineCount());
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -29,21 +29,30 @@
|
||||
<!-- Skip to main content (접근성) -->
|
||||
<a href="#main-content" class="visually-hidden-focusable">본문으로 건너뛰기</a>
|
||||
|
||||
{# 플래시 메시지 (전역) #}
|
||||
{# 플래시 메시지 (좌측 상단 토스트 스타일) #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="position-fixed end-0 p-3" style="z-index: 2000; top: 70px;">
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 2000; margin-top: 60px;">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show shadow-lg" role="alert">
|
||||
<i class="bi bi-{{ 'check-circle' if cat == 'success' else 'exclamation-triangle' }} me-2"></i>
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
<div class="toast align-items-center text-white bg-{{ 'success' if cat == 'success' else 'danger' if cat == 'error' else 'primary' }} border-0 fade show"
|
||||
role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="3000">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body d-flex align-items-center">
|
||||
<i
|
||||
class="bi bi-{{ 'check-circle-fill' if cat == 'success' else 'exclamation-diamond-fill' }} me-2 fs-5"></i>
|
||||
<div>{{ msg }}</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
||||
<div class="container-fluid">
|
||||
@@ -110,6 +119,10 @@
|
||||
<!-- Main Content -->
|
||||
<main id="main-content"
|
||||
class="{% if request.endpoint in ['auth.login', 'auth.register', 'auth.reset_password'] %}container mt-5{% else %}container mt-4 container-card{% endif %}">
|
||||
|
||||
{# 플래시 메시지 (컨텐츠 상단 표시) #}
|
||||
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -133,6 +146,24 @@
|
||||
{% endif %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
<!-- Auto-hide Toasts -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var toastElList = [].slice.call(document.querySelectorAll('.toast'));
|
||||
var toastList = toastElList.map(function (toastEl) {
|
||||
// 부트스트랩 토스트 인스턴스 생성 (autohide: true 기본값)
|
||||
var toast = new bootstrap.Toast(toastEl, { delay: 3000 });
|
||||
toast.show();
|
||||
|
||||
// 3초 후 자동으로 DOM에서 제거하고 싶다면 이벤트 리스너 추가 가능
|
||||
toastEl.addEventListener('hidden.bs.toast', function () {
|
||||
// toastEl.remove(); // 필요시 제거
|
||||
});
|
||||
return toast;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,26 +1,170 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit XML File - Dell Server Info{% endblock %}
|
||||
{% block title %}XML 편집: {{ filename }} - Dell Server Info{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/edit_xml.css') }}">
|
||||
<style>
|
||||
/* 전체 레이아웃 */
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 160px);
|
||||
/* 헤더/푸터 제외 높이 (조정 가능) */
|
||||
min-height: 600px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 툴바 (헤더) */
|
||||
.editor-toolbar {
|
||||
background-color: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 에디터 본문 */
|
||||
#monaco-editor-root {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 로딩 인디케이터 */
|
||||
.editor-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #64748b;
|
||||
font-size: 1.2rem;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3>Edit XML File: <strong>{{ filename }}</strong></h3>
|
||||
<div class="container-fluid py-4 h-100">
|
||||
<!-- Breadcrumb / Navigation -->
|
||||
<div class="mb-3 d-flex align-items-center">
|
||||
<a href="{{ url_for('xml.xml_management') }}" class="text-decoration-none text-muted small fw-bold">
|
||||
<i class="bi bi-arrow-left me-1"></i>목록으로 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="form-group">
|
||||
<label for="xmlContent">XML Content</label>
|
||||
<textarea id="xmlContent" name="content" class="form-control" rows="20">{{ content }}</textarea>
|
||||
|
||||
<form id="editorForm" method="post" style="height: 100%;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<!-- Monaco Editor의 내용은 submit 시 이 textarea에 동기화됨 -->
|
||||
<textarea name="content" id="hiddenContent" style="display:none;">{{ content }}</textarea>
|
||||
|
||||
<div class="editor-container">
|
||||
<!-- Toolbar -->
|
||||
<div class="editor-toolbar">
|
||||
<div class="editor-title">
|
||||
<i class="bi bi-filetype-xml text-primary fs-4"></i>
|
||||
<span>{{ filename }}</span>
|
||||
<span class="badge bg-light text-secondary border ms-2">XML</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<!-- 포맷팅 버튼 (Monaco 기능 호출) -->
|
||||
<button type="button" class="btn btn-white border text-dark btn-sm fw-bold" id="btnFormat">
|
||||
<i class="bi bi-magic me-1 text-info"></i> 자동 정렬
|
||||
</button>
|
||||
<!-- 저장 버튼 -->
|
||||
<button type="submit" class="btn btn-primary btn-sm fw-bold px-4">
|
||||
<i class="bi bi-save me-1"></i> 저장하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success mt-3">Save Changes</button>
|
||||
<a href="{{ url_for('xml.xml_management') }}" class="btn btn-secondary mt-3">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Editor Area -->
|
||||
<div id="monaco-editor-root">
|
||||
<div class="editor-loading">
|
||||
<div class="spinner-border text-primary me-3" role="status"></div>
|
||||
<div>에디터를 불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- Monaco Editor Loader -->
|
||||
<script src="https://unpkg.com/monaco-editor@0.45.0/min/vs/loader.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof require === 'undefined') {
|
||||
document.querySelector('.editor-loading').innerHTML =
|
||||
'<div class="text-danger"><i class="bi bi-exclamation-triangle me-2"></i>Monaco Editor를 로드할 수 없습니다. 인터넷 연결을 확인하거나 CDN 차단을 확인하세요.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
require.config({ paths: { 'vs': 'https://unpkg.com/monaco-editor@0.45.0/min/vs' } });
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
// 초기 컨텐츠 가져오기
|
||||
var initialContent = document.getElementById('hiddenContent').value;
|
||||
var container = document.getElementById('monaco-editor-root');
|
||||
|
||||
// 기존 로딩 메시지 제거
|
||||
container.innerHTML = '';
|
||||
|
||||
// 에디터 생성
|
||||
var editor = monaco.editor.create(container, {
|
||||
value: initialContent,
|
||||
language: 'xml',
|
||||
theme: 'vs', // or 'vs-dark'
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: 'on',
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
wordWrap: 'on'
|
||||
});
|
||||
|
||||
// 1. 폼 제출 시 에디터 내용을 textarea에 동기화
|
||||
document.getElementById('editorForm').addEventListener('submit', function () {
|
||||
document.getElementById('hiddenContent').value = editor.getValue();
|
||||
});
|
||||
|
||||
// 2. 자동 정렬(Format) 버튼 기능 연결
|
||||
document.getElementById('btnFormat').addEventListener('click', function () {
|
||||
editor.getAction('editor.action.formatDocument').run();
|
||||
});
|
||||
|
||||
// 3. Ctrl+S 저장 단축키 지원
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function () {
|
||||
document.getElementById('editorForm').requestSubmit();
|
||||
});
|
||||
|
||||
}, function (err) {
|
||||
// 로드 실패 시 에러 표시
|
||||
document.querySelector('.editor-loading').innerHTML =
|
||||
'<div class="text-danger"><i class="bi bi-exclamation-triangle me-2"></i>에디터 리소스 로드 실패: ' + err.message + '</div>';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -21,30 +21,39 @@
|
||||
{# IP 처리 카드 #}
|
||||
<div class="col-lg-6">
|
||||
<div class="card border shadow-sm h-100">
|
||||
<div class="card-header bg-primary text-white border-0 py-2">
|
||||
<h6 class="mb-0 fw-semibold">
|
||||
<div class="card-header bg-light border-0 py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-hdd-network me-2"></i>
|
||||
IP 처리
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form id="ipForm" method="post" action="{{ url_for('main.process_ips') }}">
|
||||
<div class="card-body p-4 h-100 d-flex flex-column">
|
||||
<form id="ipForm" method="post" action="{{ url_for('main.process_ips') }}" class="h-100 d-flex flex-column">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{# 스크립트 선택 #}
|
||||
<div class="mb-3">
|
||||
<label for="script" class="form-label">스크립트 선택</label>
|
||||
<select id="script" name="script" class="form-select" required>
|
||||
<select id="script" name="script" class="form-select" required autocomplete="off">
|
||||
<option value="">스크립트를 선택하세요</option>
|
||||
{% if grouped_scripts %}
|
||||
{% for category, s_list in grouped_scripts.items() %}
|
||||
<optgroup label="{{ category }}">
|
||||
{% for script in s_list %}
|
||||
<option value="{{ script }}">{{ script }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# 만약 grouped_scripts가 없는 경우(하위 호환) #}
|
||||
{% for script in scripts %}
|
||||
<option value="{{ script }}">{{ script }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# XML 파일 선택 (조건부) #}
|
||||
<div class="mb-3" id="xmlFileGroup" style="display:none;">
|
||||
<label for="xmlFile" class="form-label">XML 파일 선택</label>
|
||||
<select id="xmlFile" name="xmlFile" class="form-select">
|
||||
<option value="">XML 파일 선택</option>
|
||||
{% for xml_file in xml_files %}
|
||||
@@ -54,18 +63,32 @@
|
||||
</div>
|
||||
|
||||
{# IP 주소 입력 #}
|
||||
<div class="mb-3">
|
||||
<label for="ips" class="form-label">
|
||||
IP 주소 (각 줄에 하나)
|
||||
<span class="badge bg-secondary ms-2" id="ipLineCount">0 대설정</span>
|
||||
<div class="mb-3 flex-grow-1 d-flex flex-column">
|
||||
<label for="ips" class="form-label w-100 d-flex justify-content-between align-items-end mb-2">
|
||||
<span class="mb-1">
|
||||
IP 주소
|
||||
<span class="badge bg-secondary ms-1" id="ipLineCount">0</span>
|
||||
</span>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="btnStartScan"
|
||||
title="10.10.0.1 ~ 255 자동 스캔">
|
||||
<i class="bi bi-search me-1"></i>IP 자동 스캔 (10.10.0.x)
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<textarea id="ips" name="ips" rows="4" class="form-control font-monospace"
|
||||
placeholder="예: 192.168.1.1 192.168.1.2 192.168.1.3" required></textarea>
|
||||
<textarea id="ips" name="ips" class="form-control font-monospace flex-grow-1"
|
||||
placeholder="예: 192.168.1.1 192.168.1.2" required style="resize: none;"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
처리
|
||||
</button>
|
||||
<div class="mt-auto">
|
||||
<button type="submit"
|
||||
class="btn btn-white bg-white border shadow-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move">
|
||||
<div class="rounded-circle bg-primary bg-opacity-10 text-primary p-1">
|
||||
<i class="bi bi-play-circle-fill fs-5"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.8rem;">처리 시작</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,8 +97,8 @@
|
||||
{# 공유 작업 카드 #}
|
||||
<div class="col-lg-6">
|
||||
<div class="card border shadow-sm h-100">
|
||||
<div class="card-header bg-success text-white border-0 py-2">
|
||||
<h6 class="mb-0 fw-semibold">
|
||||
<div class="card-header bg-light border-0 py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-share me-2"></i>
|
||||
공유 작업
|
||||
</h6>
|
||||
@@ -93,16 +116,34 @@
|
||||
style="font-size: 0.95rem;" placeholder="서버 리스트를 입력하세요..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_server_list') }}" class="btn btn-secondary">
|
||||
MAC to Excel
|
||||
</button>
|
||||
<button type="submit" formaction="{{ url_for('utils.update_guid_list') }}" class="btn btn-success">
|
||||
GUID to Excel
|
||||
</button>
|
||||
<button type="submit" formaction="{{ url_for('utils.update_gpu_list') }}" class="btn btn-warning">
|
||||
GPU to Excel
|
||||
</button>
|
||||
<div class="row g-2">
|
||||
<div class="col-4">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_server_list') }}"
|
||||
class="btn btn-white bg-white border shadow-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move h-100">
|
||||
<div class="rounded-circle bg-primary bg-opacity-10 text-primary p-1">
|
||||
<i class="bi bi-file-earmark-spreadsheet fs-5"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.8rem;">MAC to Excel</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_guid_list') }}"
|
||||
class="btn btn-white bg-white border shadow-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move h-100">
|
||||
<div class="rounded-circle bg-success bg-opacity-10 text-success p-1">
|
||||
<i class="bi bi-file-earmark-excel fs-5"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.8rem;">GUID to Excel</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_gpu_list') }}"
|
||||
class="btn btn-white bg-white border shadow-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move h-100">
|
||||
<div class="rounded-circle bg-danger bg-opacity-10 text-danger p-1">
|
||||
<i class="bi bi-gpu-card fs-5"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.8rem;">GPU to Excel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -142,59 +183,107 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
|
||||
<!-- 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 class="row g-2">
|
||||
<!-- ZIP 다운로드 -->
|
||||
<div class="col-6">
|
||||
<div class="card h-100 border-primary-subtle bg-primary-subtle bg-opacity-10">
|
||||
<div class="card-body p-2 d-flex flex-column justify-content-center">
|
||||
<h6 class="card-title fw-bold text-primary mb-1 small" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-file-earmark-zip me-1"></i>ZIP
|
||||
</h6>
|
||||
<form method="post" action="{{ url_for('main.download_zip') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control border-primary-subtle form-control-sm"
|
||||
name="zip_filename" placeholder="파일명" required
|
||||
style="font-size: 0.75rem; padding: 0.2rem 0.5rem;">
|
||||
<button class="btn btn-primary btn-sm px-2" type="submit">
|
||||
<i class="bi bi-download" style="font-size: 0.75rem;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</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 class="col-6">
|
||||
<div class="card h-100 border-success-subtle bg-success-subtle bg-opacity-10">
|
||||
<div class="card-body p-2 d-flex flex-column justify-content-center">
|
||||
<h6 class="card-title fw-bold text-success mb-1 small" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-hdd-network me-1"></i>백업
|
||||
</h6>
|
||||
<form method="post" action="{{ url_for('main.backup_files') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control border-success-subtle form-control-sm"
|
||||
name="backup_prefix" placeholder="Prefix" style="font-size: 0.75rem; padding: 0.2rem 0.5rem;">
|
||||
<button class="btn btn-success btn-sm px-2" type="submit">
|
||||
<i class="bi bi-save" style="font-size: 0.75rem;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</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>
|
||||
<!-- 하단: 원클릭 액션 (파일 정리) -->
|
||||
<div class="card bg-light border-0">
|
||||
<div class="card-body p-3">
|
||||
<small class="text-muted fw-bold text-uppercase mb-2 d-block">
|
||||
<i class="bi bi-folder-symlink me-1"></i>파일 정리 (Quick Move)
|
||||
</small>
|
||||
<div class="row g-2">
|
||||
<!-- MAC Move -->
|
||||
<div class="col-4">
|
||||
<form id="macMoveForm" method="post" action="{{ url_for('utils.move_mac_files') }}" class="h-100">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button
|
||||
class="btn btn-white bg-white border shadow-sm w-100 h-100 py-1 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move"
|
||||
type="submit">
|
||||
<div class="rounded-circle bg-primary bg-opacity-10 text-primary p-1">
|
||||
<i class="bi bi-cpu fs-6"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.75rem;">MAC</span>
|
||||
</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>
|
||||
<!-- GUID Move -->
|
||||
<div class="col-4">
|
||||
<form id="guidMoveForm" method="post" action="{{ url_for('utils.move_guid_files') }}" class="h-100">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button
|
||||
class="btn btn-white bg-white border shadow-sm w-100 h-100 py-1 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move"
|
||||
type="submit">
|
||||
<div class="rounded-circle bg-success bg-opacity-10 text-success p-1">
|
||||
<i class="bi bi-fingerprint fs-6"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.75rem;">GUID</span>
|
||||
</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-secondary w-100" type="submit">GPU Move</button>
|
||||
</form>
|
||||
<div class="col-4">
|
||||
<form id="gpuMoveForm" method="post" action="{{ url_for('utils.move_gpu_files') }}" class="h-100">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button
|
||||
class="btn btn-white bg-white border shadow-sm w-100 h-100 py-1 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move"
|
||||
type="submit">
|
||||
<div class="rounded-circle bg-danger bg-opacity-10 text-danger p-1">
|
||||
<i class="bi bi-gpu-card fs-6"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.75rem;">GPU</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -386,15 +475,74 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||
<!-- Tom Select CSS (Bootstrap 5 theme) -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.bootstrap5.min.css" rel="stylesheet">
|
||||
<style>
|
||||
/* Tom Select 미세 조정 */
|
||||
.ts-wrapper.form-select {
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ts-control {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
.ts-wrapper.focus .ts-control {
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
/* Quick Move 버튼 호버 효과 */
|
||||
.btn-quick-move {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-quick-move:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .1) !important;
|
||||
background-color: #f8f9fa !important;
|
||||
border-color: #dee2e6 !important;
|
||||
}
|
||||
|
||||
.btn-quick-move:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
|
||||
<!-- Tom Select JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Tom Select 초기화
|
||||
// 모바일 등 환경 고려, 검색 가능하게 설정
|
||||
if (document.getElementById('script')) {
|
||||
new TomSelect("#script", {
|
||||
create: false,
|
||||
sortField: {
|
||||
field: "text",
|
||||
direction: "asc"
|
||||
},
|
||||
placeholder: "스크립트를 검색하거나 선택하세요...",
|
||||
plugins: ['clear_button'],
|
||||
allowEmptyOption: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/index.js') }}"></script>
|
||||
|
||||
<!-- 외부 script.js 파일 (IP 폼 처리 로직 포함) -->
|
||||
|
||||
@@ -1,215 +1,454 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}XML 파일 관리 & 배포 - Dell Server Info{% endblock %}
|
||||
{% block title %}XML 설정 관리 & 배포 - Dell Server Info{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<!-- Existing SCP CSS for legacy support or specific components -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/scp.css') }}">
|
||||
<!-- Overriding/New Styles for Modern Look -->
|
||||
<style>
|
||||
/* 드래그 앤 드롭 영역 스타일 */
|
||||
.drop-zone {
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
background-color: #f8fafc;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone.dragover {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.drop-zone-icon {
|
||||
font-size: 2.5rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.drop-zone-text {
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.drop-zone-hint {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.drop-zone input[type="file"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 카드 그리드 스타일 (index.html과 유사) */
|
||||
.xml-file-card {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.xml-file-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.file-icon-wrapper {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
padding: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="main-title">설정 파일 관리 (SCP)</h1>
|
||||
<p class="subtitle">iDRAC 서버 설정(XML)을 내보내거나 가져오고, 버전을 비교할 수 있습니다.</p>
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<div class="row">
|
||||
<!-- 왼쪽: 파일 업로드 및 내보내기 -->
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header-custom">
|
||||
<span><i class="fas fa-cloud-upload-alt me-2"></i>파일 등록</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 1. PC에서 업로드 -->
|
||||
<h6 class="mb-3"><i class="fas fa-laptop me-2"></i>PC에서 업로드</h6>
|
||||
<form action="{{ url_for('xml.upload_xml') }}" method="POST" enctype="multipart/form-data" class="mb-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="upload-section">
|
||||
<div class="mb-2">
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="xmlFile" name="xmlFile" accept=".xml"
|
||||
onchange="updateFileName(this)">
|
||||
<label class="custom-file-label" for="xmlFile" id="fileLabel">파일 선택</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-upload me-1"></i>업로드
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- 2. iDRAC에서 내보내기 -->
|
||||
<h6 class="mb-3"><i class="fas fa-server me-2"></i>iDRAC에서 추출 (Export)</h6>
|
||||
<button type="button" class="btn btn-outline-primary w-100" data-bs-toggle="modal"
|
||||
data-bs-target="#exportModal">
|
||||
<i class="fas fa-download me-1"></i>설정 추출하기
|
||||
</button>
|
||||
</div>
|
||||
<!-- Header Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2 class="fw-bold mb-1">
|
||||
<i class="bi bi-file-earmark-code text-primary me-2"></i>
|
||||
설정 파일 관리
|
||||
</h2>
|
||||
<p class="text-muted mb-0">서버 설정(XML) 파일을 업로드, 관리 및 배포합니다.</p>
|
||||
</div>
|
||||
<div class="col-auto align-self-end">
|
||||
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#exportModal">
|
||||
<i class="bi bi-server me-2"></i>iDRAC에서 추출
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 파일 목록 및 작업 -->
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header-custom">
|
||||
<span><i class="fas fa-list me-2"></i>파일 목록</span>
|
||||
<button class="btn btn-light btn-sm text-primary" id="compareBtn"
|
||||
data-url="{{ url_for('scp.diff_scp') }}" onclick="compareSelected()">
|
||||
<i class="fas fa-exchange-alt me-1"></i>선택 비교
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if xml_files %}
|
||||
<div class="file-list">
|
||||
{% for xml_file in xml_files %}
|
||||
<div class="icon-badge-item">
|
||||
<div class="icon-badge-left">
|
||||
<input type="checkbox" class="select-checkbox file-selector" value="{{ xml_file }}">
|
||||
<div class="file-icon-small">
|
||||
<i class="fas fa-file-code"></i>
|
||||
<div class="row g-4">
|
||||
<!-- Left: Upload Section (30% on large screens) -->
|
||||
<div class="col-lg-4 col-xl-3">
|
||||
<div class="card border shadow-sm h-100">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||
<h6 class="fw-bold mb-0 text-dark">
|
||||
<i class="bi bi-cloud-upload me-2 text-primary"></i>파일 업로드
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('xml.upload_xml') }}" method="POST" enctype="multipart/form-data"
|
||||
id="uploadForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<input type="file" name="xmlFile" id="xmlFile" accept=".xml"
|
||||
onchange="handleFileSelect(this)">
|
||||
<div class="drop-zone-icon">
|
||||
<i class="bi bi-file-earmark-arrow-up"></i>
|
||||
</div>
|
||||
<div class="file-name-section">
|
||||
<span class="file-name-badge" title="{{ xml_file }}">{{ xml_file }}</span>
|
||||
<span class="badge-custom">XML</span>
|
||||
<div class="drop-zone-text" id="dropZoneText">
|
||||
클릭하여 파일 선택<br>또는 파일을 여기로 드래그
|
||||
</div>
|
||||
<div class="drop-zone-hint">XML 파일만 지원됩니다.</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<!-- 배포 버튼 -->
|
||||
<button type="button" class="btn btn-info btn-sm text-white"
|
||||
onclick="openDeployModal('{{ xml_file }}')">
|
||||
<i class="fas fa-plane-departure"></i> <span>배포</span>
|
||||
</button>
|
||||
<!-- 편집 버튼 -->
|
||||
<a href="{{ url_for('xml.edit_xml', filename=xml_file) }}" class="btn btn-success btn-sm">
|
||||
<i class="fas fa-edit"></i> <span>편집</span>
|
||||
</a>
|
||||
<!-- 삭제 버튼 -->
|
||||
<form action="{{ url_for('xml.delete_xml', filename=xml_file) }}" method="POST"
|
||||
style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<button type="submit" class="btn btn-danger btn-sm"
|
||||
onclick="return confirm('정말 삭제하시겠습니까?')">
|
||||
<i class="fas fa-trash"></i> <span>삭제</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 mt-3 shadow-sm">
|
||||
<i class="bi bi-upload me-2"></i>업로드 시작
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="alert alert-light mt-4 border" role="alert">
|
||||
<h6 class="alert-heading fs-6 fw-bold"><i class="bi bi-info-circle me-2"></i>도움말</h6>
|
||||
<p class="mb-0 fs-small text-muted" style="font-size: 0.85rem;">
|
||||
업로드된 XML 파일을 사용하여 여러 서버에 동일한 설정을 일괄 배포할 수 있습니다.
|
||||
'비교' 기능을 사용하여 버전 간 차이를 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-message">
|
||||
<i class="fas fa-folder-open" style="font-size: 2rem; color: #ddd;"></i>
|
||||
<p class="mt-2 mb-0">파일이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: File List (70%) -->
|
||||
<div class="col-lg-8 col-xl-9">
|
||||
<div class="card border shadow-sm h-100">
|
||||
<div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<h6 class="fw-bold mb-0 text-dark me-3">
|
||||
<i class="bi bi-list-check me-2 text-success"></i>파일 목록
|
||||
</h6>
|
||||
<span class="badge bg-light text-dark border">{{ xml_files|length }}개 파일</span>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="compareBtn"
|
||||
data-url="{{ url_for('scp.diff_scp') }}" onclick="compareSelected()">
|
||||
<i class="bi bi-arrow-left-right me-1"></i>선택 비교
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body bg-light">
|
||||
{% if xml_files %}
|
||||
<!-- 카드 크기 조정: 한 줄에 4개(xxl), 3개(xl) 등으로 조금 더 키움 -->
|
||||
<div class="row row-cols-1 row-cols-lg-2 row-cols-xl-3 row-cols-xxl-4 g-3">
|
||||
{% for xml_file in xml_files %}
|
||||
<div class="col">
|
||||
<div class="xml-file-card position-relative p-3 h-100 d-flex flex-column">
|
||||
<div class="position-absolute top-0 end-0 p-2 me-1">
|
||||
<input type="checkbox" class="form-check-input file-selector border-secondary"
|
||||
value="{{ xml_file }}" style="cursor: pointer;">
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="file-icon-wrapper me-3 mb-0 shadow-sm"
|
||||
style="width: 42px; height: 42px; font-size: 1.4rem;">
|
||||
<i class="bi bi-filetype-xml"></i>
|
||||
</div>
|
||||
<div class="file-name text-truncate fw-bold mb-0 text-dark"
|
||||
style="max-width: 140px; font-size: 0.95rem;" title="{{ xml_file }}">
|
||||
{{ xml_file }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-3 border-top">
|
||||
<div class="d-flex gap-2">
|
||||
<!-- 배포 버튼 -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-primary flex-fill d-flex align-items-center justify-content-center gap-1"
|
||||
onclick="openDeployModal('{{ xml_file }}')" title="배포">
|
||||
<i class="bi bi-send-fill"></i> <span class="small fw-bold">배포</span>
|
||||
</button>
|
||||
|
||||
<!-- 편집 버튼 -->
|
||||
<a href="{{ url_for('xml.edit_xml', filename=xml_file) }}"
|
||||
class="btn btn-sm btn-white border flex-fill d-flex align-items-center justify-content-center gap-1 text-dark bg-white"
|
||||
title="편집">
|
||||
<i class="bi bi-pencil-fill text-secondary"></i> <span
|
||||
class="small fw-bold">편집</span>
|
||||
</a>
|
||||
|
||||
<!-- 삭제 버튼 -->
|
||||
<form action="{{ url_for('xml.delete_xml', filename=xml_file) }}" method="POST"
|
||||
class="d-flex flex-fill m-0" style="min-width: 0;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-white border w-100 d-flex align-items-center justify-content-center gap-1 text-danger bg-white"
|
||||
onclick="return confirm('정말 삭제하시겠습니까?')" title="삭제">
|
||||
<i class="bi bi-trash-fill"></i> <span class="small fw-bold">삭제</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5 my-5">
|
||||
<div class="mb-3 text-secondary" style="font-size: 3rem; opacity: 0.3;">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
</div>
|
||||
<h5 class="text-secondary fw-normal">등록된 파일이 없습니다.</h5>
|
||||
<p class="text-muted">좌측 패널에서 XML 파일을 업로드해주세요.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Modal -->
|
||||
<!-- Export Modal (Include existing modal logic but restyled) -->
|
||||
<div class="modal fade" id="exportModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg">
|
||||
<form action="{{ url_for('scp.export_scp') }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">iDRAC 설정 내보내기</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title fs-6 fw-bold"><i class="bi bi-download me-2"></i>iDRAC 설정 내보내기</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info py-2" style="font-size: 0.9rem;">
|
||||
<i class="fas fa-info-circle me-1"></i> 네트워크 공유 폴더(CIFS)가 필요합니다.
|
||||
<div class="modal-body p-4">
|
||||
<div class="alert alert-info py-2 small mb-4">
|
||||
<i class="bi bi-info-circle-fill me-2"></i> CIFS 네트워크 공유 폴더가 필요합니다.
|
||||
</div>
|
||||
|
||||
<h6>대상 iDRAC</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="target_ip" placeholder="iDRAC IP"
|
||||
required></div>
|
||||
<div class="row mb-3">
|
||||
<div class="col"><input type="text" class="form-control" name="username" placeholder="User"
|
||||
required></div>
|
||||
<div class="col"><input type="password" class="form-control" name="password"
|
||||
placeholder="Password" required></div>
|
||||
<h6 class="text-primary fw-bold mb-3 small text-uppercase">대상 iDRAC</h6>
|
||||
<div class="form-floating mb-2">
|
||||
<input type="text" class="form-control" id="targetIp" name="target_ip" placeholder="IP"
|
||||
required>
|
||||
<label for="targetIp">iDRAC IP Address</label>
|
||||
</div>
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" id="targetUser" name="username"
|
||||
placeholder="User" required>
|
||||
<label for="targetUser">Username</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="password" class="form-control" id="targetPwd" name="password"
|
||||
placeholder="Pwd" required>
|
||||
<label for="targetPwd">Password</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h6>네트워크 공유 (저장소)</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_ip"
|
||||
placeholder="Share Server IP" required></div>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_name"
|
||||
placeholder="Share Name (e.g. public)" required></div>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="filename"
|
||||
placeholder="Save Filename (e.g. backup.xml)" required></div>
|
||||
<div class="row">
|
||||
<div class="col"><input type="text" class="form-control" name="share_user"
|
||||
placeholder="Share User"></div>
|
||||
<div class="col"><input type="password" class="form-control" name="share_pwd"
|
||||
placeholder="Share Password"></div>
|
||||
<h6 class="text-success fw-bold mb-3 small text-uppercase">저장소 (CIFS Share)</h6>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-8">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_ip" placeholder="IP" required>
|
||||
<label>Share IP</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_name" placeholder="Name" required>
|
||||
<label>Share Name</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-floating mb-2">
|
||||
<input type="text" class="form-control" name="filename" placeholder="Filename" required>
|
||||
<label>저장할 파일명 (예: backup.xml)</label>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_user" placeholder="User">
|
||||
<label>Share User</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="password" class="form-control" name="share_pwd" placeholder="Pwd">
|
||||
<label>Share Password</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-primary">내보내기 시작</button>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-primary px-4">내보내기 실행</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy (Import) Modal -->
|
||||
<!-- Deploy Modal -->
|
||||
<div class="modal fade" id="deployModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg">
|
||||
<form action="{{ url_for('scp.import_scp') }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">설정 배포 (Import)</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title fs-6 fw-bold"><i class="bi bi-send-fill me-2"></i>설정 배포 (Import)</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning py-2" style="font-size: 0.9rem;">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i> 적용 후 서버가 재부팅될 수 있습니다.
|
||||
<div class="modal-body p-4">
|
||||
<div class="alert alert-warning py-2 small mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i> 적용 후 서버가 재부팅될 수 있습니다.
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">배포할 파일</label>
|
||||
<input type="text" class="form-control" id="deployFilename" name="filename" readonly>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold small text-muted">배포 파일</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light"><i class="bi bi-file-code"></i></span>
|
||||
<input type="text" class="form-control fw-bold text-primary" id="deployFilename"
|
||||
name="filename" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>대상 iDRAC</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="target_ip" placeholder="iDRAC IP"
|
||||
required></div>
|
||||
<div class="row mb-3">
|
||||
<div class="col"><input type="text" class="form-control" name="username" placeholder="User"
|
||||
required></div>
|
||||
<div class="col"><input type="password" class="form-control" name="password"
|
||||
placeholder="Password" required></div>
|
||||
<h6 class="text-primary fw-bold mb-3 small text-uppercase">대상 iDRAC</h6>
|
||||
<div class="form-floating mb-2">
|
||||
<input type="text" class="form-control" name="target_ip" placeholder="IP" required>
|
||||
<label>iDRAC IP</label>
|
||||
</div>
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="username" placeholder="User" required>
|
||||
<label>Username</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="password" class="form-control" name="password" placeholder="Pwd" required>
|
||||
<label>Password</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h6>네트워크 공유 (소스 위치)</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_ip"
|
||||
placeholder="Share Server IP" required></div>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_name" placeholder="Share Name"
|
||||
required></div>
|
||||
<div class="row mb-3">
|
||||
<div class="col"><input type="text" class="form-control" name="share_user"
|
||||
placeholder="Share User"></div>
|
||||
<div class="col"><input type="password" class="form-control" name="share_pwd"
|
||||
placeholder="Share Password"></div>
|
||||
<h6 class="text-success fw-bold mb-3 small text-uppercase">소스 위치 (CIFS Share)</h6>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-8">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_ip" placeholder="IP" required>
|
||||
<label>Share IP</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_name" placeholder="Name" required>
|
||||
<label>Share Name</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_user" placeholder="User">
|
||||
<label>Share User</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="password" class="form-control" name="share_pwd" placeholder="Pwd">
|
||||
<label>Share Password</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">적용 모드</label>
|
||||
<select class="form-select" name="import_mode">
|
||||
<div class="form-floating">
|
||||
<select class="form-select" name="import_mode" id="importMode">
|
||||
<option value="Replace">전체 교체 (Replace)</option>
|
||||
<option value="Append">변경분만 적용 (Append)</option>
|
||||
</select>
|
||||
<label for="importMode">적용 모드</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-danger">배포 시작</button>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-danger px-4">배포 시작</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -220,4 +459,67 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/scp.js') }}"></script>
|
||||
{% endblock %}
|
||||
<script>
|
||||
// 드래그 앤 드롭 파일 처리
|
||||
function handleFileSelect(input) {
|
||||
const fileName = input.files[0]?.name;
|
||||
const dropZoneText = document.getElementById('dropZoneText');
|
||||
if (fileName) {
|
||||
dropZoneText.innerHTML = `<span class="text-primary fw-bold">${fileName}</span><br><span class="text-muted small">파일이 선택되었습니다.</span>`;
|
||||
document.getElementById('dropZone').classList.add('border-primary', 'bg-light');
|
||||
} else {
|
||||
dropZoneText.innerHTML = '클릭하여 파일 선택<br>또는 파일을 여기로 드래그';
|
||||
document.getElementById('dropZone').classList.remove('border-primary', 'bg-light');
|
||||
}
|
||||
}
|
||||
|
||||
// 드래그 효과
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, highlight, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, unhighlight, false);
|
||||
});
|
||||
|
||||
function highlight(e) {
|
||||
dropZone.classList.add('dragover');
|
||||
}
|
||||
|
||||
function unhighlight(e) {
|
||||
dropZone.classList.remove('dragover');
|
||||
}
|
||||
|
||||
dropZone.addEventListener('drop', handleDrop, false);
|
||||
|
||||
function handleDrop(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
const input = document.getElementById('xmlFile');
|
||||
|
||||
if (files.length > 0) {
|
||||
input.files = files; // input에 파일 할당
|
||||
handleFileSelect(input);
|
||||
}
|
||||
}
|
||||
|
||||
// 툴팁 초기화 및 자동 닫기
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
```
|
||||
@@ -1,58 +1,173 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}설정 파일 비교 - Dell Server Info{% endblock %}
|
||||
{% block title %}설정 파일 비교: {{ file1 }} vs {{ file2 }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/scp.css') }}">
|
||||
<!-- Monaco Diff Editor Styles -->
|
||||
<style>
|
||||
.diff-page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 140px);
|
||||
min-height: 600px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diff-toolbar {
|
||||
background-color: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.diff-files {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.file-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.diff-arrow {
|
||||
color: #94a3b8;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
#monaco-diff-root {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>설정 파일 비교</h2>
|
||||
<a href="{{ url_for('xml.xml_management') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> 목록으로
|
||||
<div class="container-fluid py-4 h-100">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('xml.xml_management') }}" class="text-decoration-none text-muted small fw-bold">
|
||||
<i class="bi bi-arrow-left me-1"></i>목록으로 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<i class="fas fa-exchange-alt me-2"></i>비교 대상
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-5">
|
||||
<h5>{{ file1 }}</h5>
|
||||
<div class="diff-page-container">
|
||||
<!-- Toolbar -->
|
||||
<div class="diff-toolbar">
|
||||
<div class="diff-files">
|
||||
<div class="file-badge text-danger border-danger-subtle bg-danger-subtle">
|
||||
<i class="bi bi-file-earmark-minus"></i>
|
||||
<span>{{ file1 }}</span> <!-- Original -->
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<i class="fas fa-arrow-right text-muted"></i>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<h5>{{ file2 }}</h5>
|
||||
<i class="bi bi-arrow-right diff-arrow"></i>
|
||||
<div class="file-badge text-success border-success-subtle bg-success-subtle">
|
||||
<i class="bi bi-file-earmark-plus"></i>
|
||||
<span>{{ file2 }}</span> <!-- Modified -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary" onclick="toggleInlineDiff()" id="viewToggleBtn">
|
||||
<i class="bi bi-layout-split me-1"></i>Inline View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monaco Diff Editor -->
|
||||
<div id="monaco-diff-root"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-code me-2"></i>Diff 결과
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="diff-container">
|
||||
{% for line in diff_content.splitlines() %}
|
||||
{% if line.startswith('+++') or line.startswith('---') %}
|
||||
<span class="diff-line diff-header">{{ line }}</span>
|
||||
{% elif line.startswith('+') %}
|
||||
<span class="diff-line diff-add">{{ line }}</span>
|
||||
{% elif line.startswith('-') %}
|
||||
<span class="diff-line diff-del">{{ line }}</span>
|
||||
{% else %}
|
||||
<span class="diff-line">{{ line }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Raw Content Hidden Inputs (Jinja2 will escape HTML entities automatically) -->
|
||||
<textarea id="hidden_content1" style="display:none;">{{ content1 }}</textarea>
|
||||
<textarea id="hidden_content2" style="display:none;">{{ content2 }}</textarea>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://unpkg.com/monaco-editor@0.45.0/min/vs/loader.js"></script>
|
||||
<script>
|
||||
let diffEditor = null;
|
||||
let isInline = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// 1. Check for loader failure
|
||||
if (typeof require === 'undefined') {
|
||||
document.getElementById('monaco-diff-root').innerHTML =
|
||||
'<div class="d-flex justify-content-center align-items-center h-100 text-danger">' +
|
||||
'<i class="bi bi-exclamation-triangle me-2"></i>Monaco Editor 리소스를 불러올 수 없습니다. 인터넷 연결을 확인하세요.' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
require.config({ paths: { 'vs': 'https://unpkg.com/monaco-editor@0.45.0/min/vs' } });
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
// 2. Read content from hidden textareas
|
||||
// Jinja2가 HTML escaping을 처리하므로, .value를 통해 원본 XML을 얻을 수 있습니다.
|
||||
const content1 = document.getElementById('hidden_content1').value;
|
||||
const content2 = document.getElementById('hidden_content2').value;
|
||||
|
||||
const originalModel = monaco.editor.createModel(content1, 'xml');
|
||||
const modifiedModel = monaco.editor.createModel(content2, 'xml');
|
||||
|
||||
const container = document.getElementById('monaco-diff-root');
|
||||
|
||||
// 3. Create Diff Editor
|
||||
diffEditor = monaco.editor.createDiffEditor(container, {
|
||||
theme: 'vs',
|
||||
originalEditable: false,
|
||||
readOnly: true,
|
||||
renderSideBySide: true, // Default: Split View
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: true },
|
||||
diffWordWrap: 'off'
|
||||
});
|
||||
|
||||
diffEditor.setModel({
|
||||
original: originalModel,
|
||||
modified: modifiedModel
|
||||
});
|
||||
|
||||
// 네비게이션 기능 추가
|
||||
diffEditor.getNavigator();
|
||||
|
||||
}, function (err) {
|
||||
document.getElementById('monaco-diff-root').innerHTML =
|
||||
'<div class="d-flex justify-content-center align-items-center h-100 text-danger">' +
|
||||
'<i class="bi bi-exclamation-triangle me-2"></i>에디터 로드 실패: ' + err.message + '</div>';
|
||||
});
|
||||
});
|
||||
|
||||
function toggleInlineDiff() {
|
||||
if (!diffEditor) return;
|
||||
|
||||
isInline = !isInline;
|
||||
diffEditor.updateOptions({
|
||||
renderSideBySide: !isInline
|
||||
});
|
||||
|
||||
const btn = document.getElementById('viewToggleBtn');
|
||||
if (isInline) {
|
||||
btn.innerHTML = '<i class="bi bi-layout-sidebar me-1"></i>Split View';
|
||||
} else {
|
||||
btn.innerHTML = '<i class="bi bi-layout-split me-1"></i>Inline View';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user