Update 2025-12-19 16:23:03

This commit is contained in:
unknown
2025-12-19 16:23:03 +09:00
parent 804204ab97
commit b18412ecb2
30 changed files with 6607 additions and 1165 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+52
View File
@@ -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)
+7
View File
@@ -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)
+38 -6
View File
@@ -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,
)
+34 -12
View File
@@ -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():
+75 -9
View File
@@ -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.
+13
View File
@@ -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
+104
View File
@@ -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');
}
}
});
}
});
+145 -63
View File
@@ -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
View 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 %}
+37 -6
View File
@@ -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>
+159 -15
View File
@@ -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 %}
+220 -72
View File
@@ -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="예:&#10;192.168.1.1&#10;192.168.1.2&#10;192.168.1.3" required></textarea>
<textarea id="ips" name="ips" class="form-control font-monospace flex-grow-1"
placeholder="예:&#10;192.168.1.1&#10;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 폼 처리 로직 포함) -->
+459 -157
View File
@@ -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 %}
```
+155 -40
View File
@@ -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 %}