Update 2026-01-20 20:47:44

This commit is contained in:
unknown
2026-01-20 20:47:45 +09:00
parent 9d5d2b8d99
commit c9db82d33e
193 changed files with 33876 additions and 5798 deletions

View File

@@ -244,4 +244,40 @@ def download_zip():
@login_required
def download_backup_file(date: str, filename: str):
backup_path = Path(Config.BACKUP_FOLDER) / date
return send_from_directory(str(backup_path), filename, as_attachment=True)
return send_from_directory(str(backup_path), filename, as_attachment=True)
@main_bp.route("/move_backup_files", methods=["POST"])
@login_required
def move_backup_files():
data = request.get_json()
filename = data.get("filename")
source_folder = data.get("source_folder")
target_folder = data.get("target_folder")
if not all([filename, source_folder, target_folder]):
return jsonify({"success": False, "message": "필수 파라미터가 누락되었습니다."}), 400
base_backup = Path(Config.BACKUP_FOLDER)
src_path = base_backup / source_folder / filename
dst_path = base_backup / target_folder / filename
# 경로 보안 검사 및 파일 존재 확인
try:
if not src_path.exists():
return jsonify({"success": False, "message": "원본 파일이 존재하지 않습니다."}), 404
# 상위 경로 탈출 방지 확인 (간단 검증)
if ".." in source_folder or ".." in target_folder:
return jsonify({"success": False, "message": "잘못된 경로입니다."}), 400
if not (base_backup / target_folder).exists():
return jsonify({"success": False, "message": "대상 폴더가 존재하지 않습니다."}), 404
shutil.move(str(src_path), str(dst_path))
logging.info(f"파일 이동 성공: {filename} from {source_folder} to {target_folder}")
return jsonify({"success": True, "message": "파일이 이동되었습니다."})
except Exception as e:
logging.error(f"파일 이동 실패: {e}")
return jsonify({"success": False, "message": f"이동 중 오류 발생: {str(e)}"}), 500

View File

@@ -24,51 +24,122 @@ def move_mac_files():
dst.mkdir(parents=True, exist_ok=True)
moved = 0
skipped = 0
missing = 0
errors = []
# JSON 요청 파싱 (overwrite 플래그 확인)
data = request.get_json(silent=True) or {}
overwrite = data.get("overwrite", False)
# 1. 대상 파일 스냅샷 (이동 시도할, 또는 해야할 파일들)
try:
for file in src.iterdir():
if not file.is_file():
continue
try:
# 파일이 존재하는지 확인
if not file.exists():
errors.append(f"{file.name}: 파일이 존재하지 않음")
continue
# 대상 파일이 이미 존재하는 경우 건너뛰기
target = dst / file.name
if target.exists():
logging.warning(f"⚠️ 파일이 이미 존재하여 건너뜀: {file.name}")
continue
shutil.move(str(file), str(target))
moved += 1
except Exception as e:
error_msg = f"{file.name}: {str(e)}"
errors.append(error_msg)
logging.error(f"❌ 파일 이동 실패: {error_msg}")
# 결과 로깅
if moved > 0:
logging.info(f"✅ MAC 파일 이동 완료 ({moved}개)")
if errors:
logging.warning(f"⚠️ 일부 파일 이동 실패: {errors}")
# 하나라도 성공하면 success: true 반환
return jsonify({
"success": True,
"moved": moved,
"errors": errors if errors else None
})
current_files = [f for f in src.iterdir() if f.is_file()]
except Exception as e:
logging.error(f"❌ MAC 이동 중 치명적 오류: {e}")
logging.error(f"파일 목록 조회 실패: {e}")
return jsonify({"success": False, "error": str(e)})
# [중복 체크 로직] 덮어쓰기 모드가 아닐 때, 미리 중복 검사
if not overwrite:
duplicates = []
for file in current_files:
target = dst / file.name
if target.exists():
duplicates.append(file.name)
if duplicates:
return jsonify({
"success": False,
"requires_confirmation": True,
"duplicates": duplicates,
"duplicate_count": len(duplicates)
})
else:
logging.warning(f"⚠️ [MAC] 덮어쓰기 모드 활성화됨 - 중복 파일을 덮어씁니다.")
total_target_count = len(current_files)
# 카운터
moved_count = 0 # 내가 직접 옮김 (또는 덮어씀)
verified_count = 0 # 최종적으로 목적지에 있음을 확인 (성공)
lost_count = 0 # 소스에도 없고 목적지에도 없음 (진짜 유실?)
errors = []
for file in current_files:
target = dst / file.name
# [Step 1] 이미 목적지에 있는지 확인
if target.exists():
if overwrite:
# 덮어쓰기 모드: 기존 파일 삭제 후 이동 진행 (또는 바로 move로 덮어쓰기)
# shutil.move는 대상이 존재하면 에러가 날 수 있으므로(버전/OS따라 다름), 안전하게 삭제 시도
try:
# Windows에서는 사용 중인 파일 삭제 시 에러 발생 가능
# shutil.move(src, dst)는 dst가 존재하면 덮어쓰기 시도함 (Python 3.x)
pass
except Exception:
pass
else:
# (중복 체크를 통과했거나 Race Condition으로 생성된 경우) -> 이미 완료된 것으로 간주
verified_count += 1
logging.info(f"⏭️ 파일 이미 존재 (Skipped): {file.name}")
continue
# [Step 2] 소스에 있는지 확인 (Race Condition)
if not file.exists():
if target.exists():
verified_count += 1
continue
else:
lost_count += 1
logging.warning(f"❓ 이동 중 사라짐: {file.name}")
continue
# [Step 3] 이동 시도 (덮어쓰기 포함)
try:
shutil.move(str(file), str(target))
moved_count += 1
verified_count += 1
except shutil.Error as e:
# shutil.move might raise Error if destination exists depending on implementation,
# but standard behavior overwrites if not same file.
# If exact same file, verified.
if target.exists():
verified_count += 1
else:
errors.append(f"{file.name}: {str(e)}")
except FileNotFoundError:
# 옮기려는 찰나에 사라짐 -> 목적지 재확인
if target.exists():
verified_count += 1
logging.info(f"⏭️ 동시 처리됨 (완료): {file.name}")
else:
lost_count += 1
except Exception as e:
# 권한 에러 등 진짜 실패
error_msg = f"{file.name}: {str(e)}"
errors.append(error_msg)
logging.error(f"❌ 이동 에러: {error_msg}")
# 결과 요약
msg = f"{total_target_count}건 중 {verified_count}건 처리 완료"
if moved_count < verified_count:
msg += f" (이동: {moved_count}, 이미 완료: {verified_count - moved_count})"
if lost_count > 0:
msg += f", 확인 불가: {lost_count}"
logging.info(f"✅ MAC 처리 결과: {msg}")
flash(msg, "success" if lost_count == 0 else "warning")
return jsonify({
"success": True,
"total": total_target_count,
"verified": verified_count,
"message": msg,
"errors": errors if errors else None
})
@utils_bp.route("/move_guid_files", methods=["POST"])
@login_required
@@ -78,47 +149,86 @@ def move_guid_files():
dst.mkdir(parents=True, exist_ok=True)
moved = 0
skipped = 0
missing = 0
errors = []
# JSON 요청 파싱 (overwrite 플래그 확인)
data = request.get_json(silent=True) or {}
overwrite = data.get("overwrite", False)
try:
for file in src.iterdir():
if not file.is_file():
continue
files = [f for f in src.iterdir() if f.is_file()]
except Exception:
files = []
# [중복 체크]
if not overwrite:
duplicates = []
for file in files:
target = dst / file.name
if target.exists():
duplicates.append(file.name)
if duplicates:
return jsonify({
"success": False,
"requires_confirmation": True,
"duplicates": duplicates,
"duplicate_count": len(duplicates)
})
else:
logging.warning(f"⚠️ [GUID] 덮어쓰기 모드 활성화됨 - 중복 파일을 덮어씁니다.")
total_target_count = len(files)
verified_count = 0
moved_count = 0
errors = []
lost_count = 0
try:
for file in files:
target = dst / file.name
# 1. 이미 완료되었는지 확인
if target.exists():
if not overwrite:
verified_count += 1
continue
# overwrite=True면 계속 진행하여 덮어쓰기 시도
# 2. 소스 확인
if not file.exists():
if target.exists(): verified_count += 1
else: lost_count += 1
continue
# 3. 이동
try:
# 파일이 존재하는지 확인
if not file.exists():
errors.append(f"{file.name}: 파일이 존재하지 않음")
continue
# 대상 파일이 이미 존재하는 경우 건너뛰기
target = dst / file.name
if target.exists():
logging.warning(f"⚠️ 파일이 이미 존재하여 건너뜀: {file.name}")
continue
shutil.move(str(file), str(target))
moved += 1
moved_count += 1
verified_count += 1
except FileNotFoundError:
if target.exists(): verified_count += 1
else: lost_count += 1
except Exception as e:
error_msg = f"{file.name}: {str(e)}"
errors.append(error_msg)
logging.error(f"❌ 파일 이동 실패: {error_msg}")
errors.append(f"{file.name}: {e}")
# 결과 메시지
if moved > 0:
flash(f"GUID 파일이 성공적으로 이동되었습니다. ({moved}개)", "success")
logging.info(f"✅ GUID 파일 이동 완료 ({moved}개)")
# 상세 메시지
msg = f"{total_target_count}건 중 {verified_count}건 처리 완료"
logging.info(f"GUID 처리: {msg}")
flash(msg, "success" if lost_count == 0 else "warning")
if errors:
logging.warning(f"⚠️ 일부 파일 이동 실패: {errors}")
flash(f"일부 파일 이동 실패: {len(errors)}", "warning")
return jsonify({
"success": True,
"total": total_target_count,
"verified": verified_count,
"message": msg,
"errors": errors if errors else None
})
except Exception as e:
logging.error(f"❌ GUID 이동 오류: {e}")
flash(f"오류 발생: {e}", "danger")
return redirect(url_for("main.index"))
logging.error(f"❌ GUID 이동 오류: {e}")
return jsonify({"success": False, "error": str(e)})
@utils_bp.route("/move_gpu_files", methods=["POST"])
@login_required
@@ -131,52 +241,85 @@ def move_gpu_files():
dst.mkdir(parents=True, exist_ok=True)
moved = 0
skipped = 0
missing = 0
errors = []
# JSON 요청 파싱
data = request.get_json(silent=True) or {}
overwrite = data.get("overwrite", False)
try:
for file in src.iterdir():
if not file.is_file():
continue
files = [f for f in src.iterdir() if f.is_file()]
except Exception:
files = []
# GPU 관련 텍스트 파일만 이동 (GUID 등 제외)
if not file.name.lower().endswith(".txt"):
continue
# [중복 체크]
if not overwrite:
duplicates = []
for file in files:
target = dst / file.name
if target.exists():
duplicates.append(file.name)
if duplicates:
return jsonify({
"success": False,
"requires_confirmation": True,
"duplicates": duplicates,
"duplicate_count": len(duplicates)
})
else:
logging.warning(f"⚠️ [GPU] 덮어쓰기 모드 활성화됨 - 중복 파일을 덮어씁니다.")
total_target_count = len(files)
verified_count = 0
moved_count = 0
errors = []
lost_count = 0
try:
for file in files:
target = dst / file.name
# 1. 존재 확인 (덮어쓰기 아닐 경우)
if target.exists():
if not overwrite:
verified_count += 1
continue
# 2. 소스 확인
if not file.exists():
if target.exists(): verified_count += 1
else: lost_count += 1
continue
# 3. 이동
try:
# 파일 존재 확인
if not file.exists():
errors.append(f"{file.name}: 파일이 존재하지 않음")
continue
# 대상 파일이 이미 존재하면 스킵
target = dst / file.name
if target.exists():
logging.warning(f"⚠️ 이미 존재하여 건너뜀: {file.name}")
continue
# 파일 이동
shutil.move(str(file), str(target))
moved += 1
moved_count += 1
verified_count += 1
except FileNotFoundError:
if target.exists(): verified_count += 1
else: lost_count += 1
except Exception as e:
error_msg = f"{file.name}: {str(e)}"
errors.append(error_msg)
logging.error(f"❌ GPU 파일 이동 실패: {error_msg}")
errors.append(f"{file.name}: {e}")
# 결과 메시지
if moved > 0:
flash(f"GPU 시리얼 파일이 성공적으로 이동되었습니다. ({moved}개)", "success")
logging.info(f"✅ GPU 파일 이동 완료 ({moved}개)")
# 상세 메시지
msg = f"{total_target_count}건 중 {verified_count}건 처리 완료"
logging.info(f"GPU 처리: {msg}")
flash(msg, "success")
if errors:
logging.warning(f"⚠️ 일부 파일 이동 실패: {errors}")
flash(f"일부 GPU 파일 이동 실패: {len(errors)}", "warning")
return jsonify({
"success": True,
"total": total_target_count,
"verified": verified_count,
"message": msg,
"errors": errors if errors else None
})
except Exception as e:
logging.error(f"❌ GPU 이동 오류: {e}")
flash(f"오류 발생: {e}", "danger")
return redirect(url_for("main.index"))
return jsonify({"success": False, "error": str(e)})
@utils_bp.route("/update_server_list", methods=["POST"])
@login_required
@@ -213,6 +356,8 @@ def update_server_list():
@login_required
def update_guid_list():
content = request.form.get("server_list_content")
slot_priority = request.form.get("slot_priority", "") # 슬롯 우선순위 받기
if not content:
flash("내용을 입력하세요.", "warning")
return redirect(url_for("main.index"))
@@ -220,6 +365,13 @@ def update_guid_list():
path = Path(Config.SERVER_LIST_FOLDER) / "guid_list.txt"
try:
path.write_text(content, encoding="utf-8")
# 슬롯 우선순위를 환경변수로 전달
env = os.environ.copy()
if slot_priority:
env["GUID_SLOT_PRIORITY"] = slot_priority
logging.info(f"GUID 슬롯 우선순위: {slot_priority}")
result = subprocess.run(
[sys.executable, str(Path(Config.SERVER_LIST_FOLDER) / "GUIDtxtT0Execl.py")],
capture_output=True,
@@ -227,6 +379,7 @@ def update_guid_list():
check=True,
cwd=str(Path(Config.SERVER_LIST_FOLDER)),
timeout=300,
env=env, # 환경변수 전달
)
logging.info(f"GUID 리스트 스크립트 실행 결과: {result.stdout}")
flash("GUID 리스트가 업데이트되었습니다.", "success")

View File

@@ -26,6 +26,35 @@ def setup_logging(app: Optional[object] = None) -> logging.Logger:
log_dir = _ensure_log_dir()
log_path = log_dir / "app.log"
# ─────────────────────────────────────────────────────────────
# [Fix] 앱 시작 시점에 app.log가 있고 날짜가 지났다면 강제 로테이션 (백업)
# TimedRotatingFileHandler는 프로세스가 재시작되면 파일의 생성일을 기준으로
# 롤오버를 즉시 수행하지 않고 append 모드로 여는 경우가 많음.
# 이를 보완하기 위해, 직접 날짜를 확인하고 파일을 옮겨준다.
# ─────────────────────────────────────────────────────────────
if log_path.exists():
try:
from datetime import date
# 파일 마지막 수정 시간 확인
mtime = os.path.getmtime(log_path)
file_date = date.fromtimestamp(mtime)
today = date.today()
# "파일 날짜" < "오늘" 이면 백업
if file_date < today:
backup_name = file_date.strftime("%Y-%m-%d.log")
backup_path = log_dir / backup_name
# 이미 백업 파일이 있으면 굳이 덮어쓰거나 하지 않음(안전성)
if not backup_path.exists():
os.rename(log_path, backup_path)
print(f"[Logger] Rotated old log file: app.log -> {backup_name}")
except Exception as e:
# 권한 문제, 파일 잠금(Windows) 등으로 실패 시 무시하고 진행
print(f"[Logger] Failed to force rotate log file: {e}")
root = logging.getLogger()
root.setLevel(_DEF_LEVEL)
root.propagate = False
@@ -74,5 +103,5 @@ def setup_logging(app: Optional[object] = None) -> logging.Logger:
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("telegram").setLevel(logging.WARNING)
root.info("Logger initialized | level=%s | file=%s", _DEF_LEVEL, log_path)
root.debug("Logger initialized | level=%s | file=%s", _DEF_LEVEL, log_path)
return root

View File

@@ -117,4 +117,16 @@
.modal-body pre::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* 백업 파일 다중 선택 스타일 */
.backup-file-item.selected .file-card-compact {
border-color: #0d6efd !important;
background-color: #e7f1ff !important;
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
}
.file-card-compact {
transition: all 0.2s ease-in-out;
cursor: pointer;
}

View File

@@ -0,0 +1,161 @@
/* 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);
}
/* Modern Minimalist Styles */
.hover-bg-light {
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.hover-bg-light:hover {
background-color: #f8f9fa !important;
border-color: #dee2e6 !important;
}
/* Slot Badge - Clean & Flat */
.slot-badge {
position: relative;
padding: 0.4rem 0.8rem 0.4rem 2rem;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 9999px;
/* Pill shape */
color: #374151;
font-weight: 600;
font-family: monospace;
font-size: 0.85rem;
transition: all 0.2s ease;
user-select: none;
display: flex;
align-items: center;
}
.slot-badge:hover {
border-color: #3b82f6;
/* Primary Blue */
color: #3b82f6;
}
.slot-index {
position: absolute;
left: 0.35rem;
width: 1.3rem;
height: 1.3rem;
background: #f3f4f6;
color: #6b7280;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
}
/* Grid Layout for Preview */
#slotPreview {
display: grid !important;
grid-template-columns: repeat(5, 1fr);
/* 5 items per line */
gap: 0.5rem;
align-content: start;
}
/* Slot Badge - Draggable & Card-like */
.slot-badge {
position: relative;
padding: 0.5rem 0.2rem;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
color: #374151;
font-weight: 600;
font-family: monospace;
font-size: 0.75rem;
/* Reduced font size */
transition: all 0.2s ease;
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: grab;
height: 100%;
min-height: 80px;
overflow: hidden;
/* Prevent overflow */
word-break: break-all;
/* Ensure wrapping if needed */
}
.slot-badge:active {
cursor: grabbing;
}
.slot-badge:hover {
border-color: #3b82f6;
color: #3b82f6;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.slot-index {
background: #f3f4f6;
color: #6b7280;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.slot-badge:hover .slot-index {
background: #eff6ff;
color: #3b82f6;
}
/* Dragging state */
.sortable-ghost {
opacity: 0.4;
background: #e2e8f0;
border: 1px dashed #94a3b8;
}
.sortable-drag {
opacity: 1;
background: #fff;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
transform: scale(1.02);
}

View File

@@ -0,0 +1,705 @@
{% extends "base.html" %}
{% block content %}
<div class="container-fluid py-4">
{# 헤더 섹션 #}
<div class="row mb-4">
<div class="col">
<h2 class="fw-bold mb-1">
<i class="bi bi-server text-primary me-2"></i>
서버 관리 대시보드
</h2>
<p class="text-muted mb-0">IP 처리 및 파일 관리를 위한 통합 관리 도구</p>
</div>
</div>
{# 메인 작업 영역 #}
<div class="row g-4 mb-4">
{# IP 처리 카드 #}
<div class="col-lg-6">
<div class="card border shadow-sm h-100">
<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 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">
<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;">
<select id="xmlFile" name="xmlFile" class="form-select">
<option value="">XML 파일 선택</option>
{% for xml_file in xml_files %}
<option value="{{ xml_file }}">{{ xml_file }}</option>
{% endfor %}
</select>
</div>
{# IP 주소 입력 #}
<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-secondary px-2 py-1" id="btnClearIps"
title="입력 내용 지우기" style="font-size: 0.75rem;">
<i class="bi bi-trash me-1"></i>지우기
</button>
<button type="button" class="btn btn-sm btn-outline-primary px-2 py-1" id="btnStartScan"
title="10.10.0.1 ~ 255 자동 스캔" style="font-size: 0.75rem;">
<i class="bi bi-search me-1"></i>IP 스캔
</button>
</div>
</label>
<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>
<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>
</div>
{# 공유 작업 카드 #}
<div class="col-lg-6">
<div class="card border shadow-sm h-100">
<div class="card-header bg-light border-0 py-2">
<h6 class="mb-0">
<i class="bi bi-share me-2"></i>
공유 작업
</h6>
</div>
<div class="card-body p-4">
<form id="sharedForm" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="server_list_content" class="form-label">
서버 리스트 (덮어쓰기)
<span class="badge bg-secondary ms-2" id="serverLineCount">0 대설정</span>
</label>
<textarea id="server_list_content" name="server_list_content" rows="8" class="form-control font-monospace"
style="font-size: 0.95rem;" placeholder="서버 리스트를 입력하세요..."></textarea>
</div>
<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="button" data-bs-toggle="modal" data-bs-target="#slotPriorityModal"
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>
</div>
</div>
</div>
{# 진행바 #}
<div class="row mb-4">
<div class="col">
<div class="card border-0 shadow-sm">
<div class="card-body p-3">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-activity text-primary me-2"></i>
<span class="fw-semibold">처리 진행률</span>
</div>
<div class="progress" style="height: 25px;">
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success"
role="progressbar" style="width:0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<span class="fw-semibold">0%</span>
</div>
</div>
</div>
</div>
</div>
</div>
{# 파일 관리 도구 #}
<div class="row mb-4">
<div class="col">
<div class="card border shadow-sm">
<div class="card-header bg-light border-0 py-2">
<h6 class="mb-0">
<i class="bi bi-tools me-2"></i>
파일 관리 도구
</h6>
</div>
<div class="card-body p-4 file-tools">
<div class="d-flex flex-column gap-3">
<!-- 상단: 입력형 도구 (다운로드/백업) -->
<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>
</div>
<!-- 파일 백업 -->
<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="ex)PO-20251117-0015_20251223_판교_R6615(TY1A)"
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>
</div>
</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 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>
<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>
</div>
</div>
{# 처리된 파일 목록 #}
<div class="row mb-4 processed-list">
<div class="col">
<div class="card border shadow-sm">
<div class="card-header bg-light border-0 py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="bi bi-files me-2"></i>
처리된 파일 목록
</h6>
</div>
<div class="card-body p-4">
{% if files_to_display and files_to_display|length > 0 %}
<div class="row g-3">
{% for file_info in files_to_display %}
<div class="col-auto">
<div class="file-card-compact border rounded p-2 text-center">
<a href="{{ url_for('main.download_file', filename=file_info.file) }}"
class="text-decoration-none text-dark fw-semibold d-block mb-2 text-nowrap px-2" download
title="{{ file_info.name or file_info.file }}">
{{ file_info.name or file_info.file }}
</a>
<div class="file-card-buttons d-flex gap-2 justify-content-center">
<button type="button" class="btn btn-sm btn-outline btn-view-processed flex-fill"
data-bs-toggle="modal" data-bs-target="#fileViewModal" data-folder="idrac_info"
data-filename="{{ file_info.file }}">
보기
</button>
<form action="{{ url_for('main.delete_file', filename=file_info.file) }}" method="post"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline btn-delete-processed flex-fill"
onclick="return confirm('삭제하시겠습니까?');">
삭제
</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 페이지네이션 -->
{% if total_pages > 1 %}
<nav aria-label="Processed files pagination" class="mt-4">
<ul class="pagination justify-content-center mb-0">
<!-- 이전 페이지 -->
{% if page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.index', page=page-1) }}">
<i class="bi bi-chevron-left"></i> 이전
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i> 이전</span>
</li>
{% endif %}
<!-- 페이지 번호 (최대 10개 표시) -->
{% set start_page = ((page - 1) // 10) * 10 + 1 %}
{% set end_page = [start_page + 9, total_pages]|min %}
{% for p in range(start_page, end_page + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('main.index', page=p) }}">{{ p }}</a>
</li>
{% endfor %}
<!-- 다음 페이지 -->
{% if page < total_pages %} <li class="page-item">
<a class="page-link" href="{{ url_for('main.index', page=page+1) }}">
다음 <i class="bi bi-chevron-right"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">다음 <i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<!-- /페이지네이션 -->
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox fs-1 text-muted mb-3"></i>
<p class="text-muted mb-0">표시할 파일이 없습니다.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# 백업된 파일 목록 #}
<div class="row backup-list">
<div class="col">
<div class="card border shadow-sm">
<div class="card-header bg-light border-0 py-2">
<h6 class="mb-0">
<i class="bi bi-archive me-2"></i>
백업된 파일 목록
</h6>
</div>
<div class="card-body p-4">
{% if backup_files and backup_files|length > 0 %}
<div class="list-group">
{% for date, info in backup_files.items() %}
<div class="list-group-item border rounded mb-2 p-0 overflow-hidden">
<div class="d-flex justify-content-between align-items-center p-3 bg-light">
<div class="d-flex align-items-center">
<i class="bi bi-calendar3 text-primary me-2"></i>
<strong>{{ date }}</strong>
<span class="badge bg-primary ms-3">{{ info.count }} 파일</span>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse"
data-bs-target="#collapse-{{ loop.index }}" aria-expanded="false">
<i class="bi bi-chevron-down"></i>
</button>
</div>
<div id="collapse-{{ loop.index }}" class="collapse">
<div class="p-3">
<div class="row g-3 backup-files-container" data-folder="{{ date }}" style="min-height: 50px;">
{% for file in info.files %}
<div class="col-auto backup-file-item" data-filename="{{ file }}">
<div class="file-card-compact border rounded p-2 text-center bg-white">
<a href="{{ url_for('main.download_backup_file', date=date, filename=file) }}"
class="text-decoration-none text-dark fw-semibold d-block mb-2 text-nowrap px-2" download
title="{{ file }}">
{{ file.rsplit('.', 1)[0] }}
</a>
<div class="file-card-single-button">
<button type="button" class="btn btn-sm btn-outline btn-view-backup w-100"
data-bs-toggle="modal" data-bs-target="#fileViewModal" data-folder="backup"
data-date="{{ date }}" data-filename="{{ file }}">
보기
</button>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 백업 목록 페이지네이션 -->
{% if total_backup_pages > 1 %}
<nav aria-label="Backup pagination" class="mt-4">
<ul class="pagination justify-content-center mb-0">
<!-- 이전 페이지 -->
{% if backup_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.index', backup_page=backup_page-1, page=page) }}">
<i class="bi bi-chevron-left"></i> 이전
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i> 이전</span>
</li>
{% endif %}
<!-- 페이지 번호 -->
{% set start_b_page = ((backup_page - 1) // 10) * 10 + 1 %}
{% set end_b_page = [start_b_page + 9, total_backup_pages]|min %}
{% for p in range(start_b_page, end_b_page + 1) %}
<li class="page-item {% if p == backup_page %}active{% endif %}">
<a class="page-link" href="{{ url_for('main.index', backup_page=p, page=page) }}">{{ p }}</a>
</li>
{% endfor %}
<!-- 다음 페이지 -->
{% if backup_page < total_backup_pages %} <li class="page-item">
<a class="page-link" href="{{ url_for('main.index', backup_page=backup_page+1, page=page) }}">
다음 <i class="bi bi-chevron-right"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">다음 <i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox fs-1 text-muted mb-3"></i>
<p class="text-muted mb-0">백업된 파일이 없습니다.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{# 파일 보기 모달 #}
<div class="modal fade" id="fileViewModal" tabindex="-1" aria-labelledby="fileViewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="fileViewModalLabel">
<i class="bi bi-file-text me-2"></i>
파일 보기
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="닫기"></button>
</div>
<div class="modal-body bg-light">
<pre id="fileViewContent" class="mb-0 p-3 bg-white border rounded font-monospace"
style="white-space:pre-wrap;word-break:break-word;max-height:70vh;">불러오는 중...</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle me-1"></i>닫기
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/index_custom.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">
{% 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>
window.APP_CONFIG = {
moveBackupUrl: "{{ url_for('main.move_backup_files') }}",
csrfToken: "{{ csrf_token() }}",
downloadBaseUrl: "{{ url_for('main.download_backup_file', date='PLACEHOLDER_DATE', filename='PLACEHOLDER_FILE') }}"
};
</script>
<script src="{{ url_for('static', filename='js/index.js') }}?v={{ range(1, 100000) | random }}"></script>
<script src="{{ url_for('static', filename='js/index_custom.js') }}?v={{ range(1, 100000) | random }}"></script>
<!-- 외부 script.js 파일 (IP 폼 처리 로직 포함) -->
<script src="{{ url_for('static', filename='script.js') }}?v={{ range(1, 100000) | random }}"></script>
<!-- SortableJS for Drag and Drop -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<!-- 슬롯 우선순위 설정 모달 (Premium Design) -->
<div class="modal fade" id="slotPriorityModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content border-0 shadow-lg" style="border-radius: 1rem; overflow: hidden;">
<!-- 헤더: 깔끔한 모던 스타일 -->
<div class="modal-header border-bottom p-4 bg-white">
<div>
<h5 class="modal-title fw-bold text-dark mb-1">
<i class="bi bi-layers text-primary me-2"></i>GUID 슬롯 우선순위 설정
</h5>
<p class="mb-0 text-muted" style="font-size: 0.85rem;">
엑셀 변환 시 적용될 슬롯의 순서를 설정합니다.
</p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="닫기"></button>
</div>
<div class="modal-body p-0 bg-light">
<form id="slotPriorityForm" action="{{ url_for('utils.update_guid_list') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="server_list_content" id="modal_server_list_content">
<input type="hidden" name="slot_priority" id="slot_priority_input">
<div class="row g-0">
<!-- 왼쪽: 입력 및 프리셋 -->
<div class="col-lg-5 border-end bg-white p-4 d-flex flex-column">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-bold text-dark mb-0 small text-uppercase">
<i class="bi bi-keyboard me-1"></i>슬롯 번호 입력
</h6>
</div>
<div class="position-relative flex-grow-1">
<textarea id="slotNumbersInput"
class="form-control bg-light border-0 font-monospace p-3 text-dark h-100"
style="resize: none; font-size: 0.9rem; min-height: 200px;"
placeholder="슬롯 번호를 입력하세요.&#10;구분자: 쉼표(,) 공백( ) 줄바꿈&#10;&#10;예시: 38, 39, 37"></textarea>
<div class="position-absolute bottom-0 end-0 p-2">
<button type="button" id="btnClearSlots" class="btn btn-sm btn-link text-decoration-none text-muted"
style="font-size: 0.75rem;">
<i class="bi bi-x-circle me-1"></i>지우기
</button>
</div>
</div>
<!-- 미니멀한 프리셋 설정 (숫자 입력) -->
<div class="mt-3 pt-3 border-top border-light d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<span class="small text-muted me-2" style="font-size: 0.75rem;">카드 개수 설정:</span>
<div class="input-group input-group-sm" style="width: 120px;">
<span class="input-group-text bg-white border-end-0 text-muted"
style="font-size: 0.75rem;">개수</span>
<input type="number" id="presetCountInput" class="form-control border-start-0 text-center"
value="10" min="1" max="10" style="font-size: 0.8rem;">
</div>
</div>
<button type="button" id="btnApplyPreset" class="btn btn-sm btn-outline-primary rounded-pill px-3"
style="font-size: 0.75rem;">
적용
</button>
</div>
</div>
<!-- 오른쪽: 시각화 및 확인 -->
<div class="col-lg-7 p-4 bg-light d-flex flex-column">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold text-secondary mb-0 small text-uppercase">
<i class="bi bi-sort-numeric-down me-1"></i>적용 순서
</h6>
<span class="badge bg-white text-dark border rounded-pill px-3 py-1" id="slotCountDisplay">0개</span>
</div>
<!-- 미리보기 영역 -->
<div class="flex-grow-1 bg-white border rounded-3 p-4 shadow-sm mb-4 position-relative"
style="min-height: 250px; max-height: 400px; overflow-y: auto;">
<div id="slotPreview" class="d-flex flex-wrap gap-2 align-content-start h-100">
<!-- Empty State -->
<div
class="d-flex flex-column align-items-center justify-content-center w-100 h-100 text-muted opacity-50">
<i class="bi bi-layers fs-1 mb-2"></i>
<span class="small">프리셋을 선택하거나 번호를 입력하세요.</span>
</div>
</div>
</div>
<div class="mt-auto">
<div class="d-flex align-items-center mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>
<span class="small text-muted">입력된 순서대로 <strong>GUID 컬럼</strong><strong>슬롯 데이터</strong>
정렬됩니다.</span>
</div>
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold shadow-sm">
설정 확인 및 변환
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- 중복 파일 확인 모달 -->
<div class="modal fade" id="duplicateCheckModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header border-bottom-0 pb-0">
<h5 class="modal-title fw-bold text-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>중복 파일 발견
</h5>
</div>
<div class="modal-body pt-3">
<p class="text-secondary mb-3">
대상 폴더에 이미 동일한 이름의 파일이 <strong id="dupCount" class="text-dark">0</strong>개 존재합니다.<br>
덮어쓰시겠습니까?
</p>
<div class="bg-light rounded p-3 mb-3 border font-monospace text-muted small" style="max-height: 150px; overflow-y: auto;">
<ul id="dupList" class="list-unstyled mb-0">
<!-- JS로 주입됨 -->
</ul>
<div id="dupMore" class="text-center mt-2 fst-italic display-none" style="display:none;">...외 <span id="dupMoreCount">0</span></div>
</div>
<p class="small text-muted mb-0">
<i class="bi bi-info-circle me-1"></i>덮어쓰기를 선택하면 기존 파일은 삭제됩니다.
</p>
</div>
<div class="modal-footer border-top-0 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-warning text-white fw-bold" id="btnConfirmOverwrite">
<i class="bi bi-arrow-repeat me-1"></i>덮어쓰기 (Overwrite)
</button>
</div>
</div>
</div>
</div>
<!-- Styles moved to index_custom.css -->
<!-- Scripts moved to index_custom.js -->
{% endblock %}

View File

@@ -137,51 +137,8 @@ document.addEventListener('DOMContentLoaded', () => {
}
// ─────────────────────────────────────────────────────────────
// MAC 파일 이동
// ─────────────────────────────────────────────────────────────
const macForm = document.getElementById('macMoveForm');
if (macForm) {
macForm.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = macForm.querySelector('button');
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>처리 중...';
try {
await postFormAndHandle(macForm.action);
location.reload();
} catch (err) {
alert('MAC 파일 이동 중 오류가 발생했습니다: ' + (err?.message || err));
btn.disabled = false;
btn.innerHTML = originalHtml;
}
});
}
// ─────────────────────────────────────────────────────────────
// GUID 파일 이동
// ─────────────────────────────────────────────────────────────
const guidForm = document.getElementById('guidMoveForm');
if (guidForm) {
guidForm.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = guidForm.querySelector('button');
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>처리 중...';
try {
await postFormAndHandle(guidForm.action);
location.reload();
} catch (err) {
alert('GUID 파일 이동 중 오류가 발생했습니다: ' + (err?.message || err));
btn.disabled = false;
btn.innerHTML = originalHtml;
}
});
}
// ─────────────────────────────────────────────────────────────
// 알림 자동 닫기

View File

@@ -0,0 +1,443 @@
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,
});
}
// 슬롯 우선순위 로직
const slotPriorityModal = document.getElementById('slotPriorityModal');
if (slotPriorityModal) {
const slotNumbersInput = document.getElementById('slotNumbersInput');
const slotCountDisplay = document.getElementById('slotCountDisplay');
const slotPreview = document.getElementById('slotPreview');
const slotPriorityInput = document.getElementById('slot_priority_input');
const modalServerListContent = document.getElementById('modal_server_list_content');
const serverListTextarea = document.getElementById('server_list_content');
const slotPriorityForm = document.getElementById('slotPriorityForm');
const btnClearSlots = document.getElementById('btnClearSlots');
const presetCountInput = document.getElementById('presetCountInput');
const btnApplyPreset = document.getElementById('btnApplyPreset');
// 기본 우선순위 데이터 (최대 10개)
const defaultPriority = ['38', '39', '37', '36', '32', '33', '34', '35', '31', '40'];
function loadFromStorage() {
const saved = localStorage.getItem('guidSlotNumbers');
if (saved) {
slotNumbersInput.value = saved;
} else {
slotNumbersInput.value = defaultPriority.join(', ');
}
if (presetCountInput) presetCountInput.value = 10;
updatePreview();
}
function saveToStorage() {
localStorage.setItem('guidSlotNumbers', slotNumbersInput.value);
}
function parseSlots(input) {
if (!input || !input.trim()) return [];
return input.split(/[,\s\n]+/)
.map(s => s.trim())
.filter(s => s !== '')
.filter(s => /^\d+$/.test(s))
.filter((v, i, a) => a.indexOf(v) === i);
}
let sortableInstance = null;
function updatePreview() {
const slots = parseSlots(slotNumbersInput.value);
const count = slots.length;
slotCountDisplay.textContent = `${count}`;
slotCountDisplay.className = count > 0
? 'badge bg-primary text-white border border-primary rounded-pill px-3 py-1'
: 'badge bg-white text-dark border rounded-pill px-3 py-1';
if (count === 0) {
slotPreview.style.display = 'flex';
slotPreview.innerHTML = `
<div class="d-flex flex-column align-items-center justify-content-center w-100 h-100 text-muted opacity-50">
<i class="bi bi-layers fs-1 mb-2"></i>
<span class="small">프리셋을 선택하거나 번호를 입력하세요.</span>
</div>
`;
} else {
slotPreview.style.display = 'grid';
let html = '';
slots.forEach((slot, index) => {
html += `
<div class="slot-badge animate__animated animate__fadeIn" data-slot="${slot}" style="animation-delay: ${index * 0.02}s">
<span class="slot-index">${index + 1}</span>
<div>Slot ${slot}</div>
</div>
`;
});
slotPreview.innerHTML = html;
if (!sortableInstance) {
sortableInstance = new Sortable(slotPreview, {
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
onEnd: function (evt) {
updateInputFromPreview();
}
});
}
}
saveToStorage();
}
function updateInputFromPreview() {
const items = slotPreview.querySelectorAll('.slot-badge');
const newSlots = Array.from(items).map(item => item.getAttribute('data-slot'));
slotNumbersInput.value = newSlots.join(', ');
items.forEach((item, index) => {
const idxSpan = item.querySelector('.slot-index');
if (idxSpan) idxSpan.textContent = index + 1;
});
saveToStorage();
}
if (btnApplyPreset) {
btnApplyPreset.addEventListener('click', function () {
let count = parseInt(presetCountInput.value);
if (isNaN(count) || count < 1) count = 1;
if (count > 10) count = 10;
presetCountInput.value = count;
const selected = defaultPriority.slice(0, count);
slotNumbersInput.value = selected.join(', ');
slotNumbersInput.style.transition = 'background-color 0.2s';
slotNumbersInput.style.backgroundColor = '#f0f9ff';
setTimeout(() => {
slotNumbersInput.style.backgroundColor = '#f8f9fa';
}, 300);
updatePreview();
});
}
slotNumbersInput.addEventListener('input', updatePreview);
btnClearSlots.addEventListener('click', function () {
if (confirm('입력된 내용을 모두 지우시겠습니까?')) {
slotNumbersInput.value = '';
updatePreview();
}
});
slotPriorityModal.addEventListener('show.bs.modal', function () {
modalServerListContent.value = serverListTextarea.value;
loadFromStorage();
});
slotPriorityForm.addEventListener('submit', function (e) {
const slots = parseSlots(slotNumbersInput.value);
if (slots.length === 0) {
e.preventDefault();
alert('최소 1개 이상의 슬롯을 입력하세요.');
slotNumbersInput.focus();
slotNumbersInput.classList.add('is-invalid');
setTimeout(() => slotNumbersInput.classList.remove('is-invalid'), 2000);
return;
}
slotPriorityInput.value = slots.join(',');
saveToStorage();
});
}
// 백업 파일 드래그 앤 드롭 이동 기능
let selectedItems = new Set();
const backupContainers = document.querySelectorAll('.backup-files-container');
document.addEventListener('click', function (e) {
const item = e.target.closest('.backup-file-item');
if (item && !e.target.closest('a') && !e.target.closest('button')) {
if (e.ctrlKey || e.metaKey) {
toggleSelection(item);
} else {
const wasSelected = item.classList.contains('selected');
clearSelection();
if (!wasSelected) toggleSelection(item);
}
} else if (!e.target.closest('.backup-files-container')) {
// Optional: click outside behavior
}
});
document.addEventListener('mousedown', function (e) {
if (!e.target.closest('.backup-file-item') && !e.target.closest('.backup-files-container')) {
clearSelection();
}
});
function toggleSelection(item) {
if (item.classList.contains('selected')) {
item.classList.remove('selected');
selectedItems.delete(item);
} else {
item.classList.add('selected');
selectedItems.add(item);
}
}
function clearSelection() {
document.querySelectorAll('.backup-file-item.selected').forEach(el => el.classList.remove('selected'));
selectedItems.clear();
}
function updateFolderCount(folderDate) {
const container = document.querySelector(`.backup-files-container[data-folder="${folderDate}"]`);
if (container) {
const listItem = container.closest('.list-group-item');
if (listItem) {
const badge = listItem.querySelector('.badge');
if (badge) {
const count = container.children.length;
badge.textContent = `${count} 파일`;
}
}
}
}
backupContainers.forEach(container => {
new Sortable(container, {
group: 'backup-files',
animation: 150,
ghostClass: 'sortable-ghost',
delay: 100,
delayOnTouchOnly: true,
onStart: function (evt) {
if (!evt.item.classList.contains('selected')) {
clearSelection();
toggleSelection(evt.item);
}
},
onEnd: function (evt) {
if (evt.to === evt.from) return;
const sourceFolder = evt.from.getAttribute('data-folder');
const targetFolder = evt.to.getAttribute('data-folder');
if (!sourceFolder || !targetFolder) {
alert('잘못된 이동 요청입니다.');
location.reload();
return;
}
let itemsToMove = Array.from(selectedItems);
if (itemsToMove.length === 0) {
itemsToMove = [evt.item];
} else {
if (!itemsToMove.includes(evt.item)) {
itemsToMove.push(evt.item);
}
}
itemsToMove.forEach(item => {
if (item !== evt.item) {
evt.to.appendChild(item);
}
});
// Server Request
if (!window.APP_CONFIG) {
console.error("Window APP_CONFIG not found!");
return;
}
const promises = itemsToMove.map(item => {
const filename = item.getAttribute('data-filename');
if (!filename) return Promise.resolve();
return fetch(window.APP_CONFIG.moveBackupUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': window.APP_CONFIG.csrfToken
},
body: JSON.stringify({
filename: filename,
source_folder: sourceFolder,
target_folder: targetFolder
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
const btn = item.querySelector('.btn-view-backup');
if (btn) btn.setAttribute('data-date', targetFolder);
const link = item.querySelector('a[download]');
if (link) {
const newHref = window.APP_CONFIG.downloadBaseUrl
.replace('PLACEHOLDER_DATE', targetFolder)
.replace('PLACEHOLDER_FILE', filename);
link.setAttribute('href', newHref);
}
}
return data;
});
});
Promise.all(promises).then(results => {
updateFolderCount(sourceFolder);
updateFolderCount(targetFolder);
clearSelection();
const failed = results.filter(r => r && !r.success);
if (failed.length > 0) {
alert(failed.length + '개의 파일 이동 실패. 실패한 파일이 복구되지 않을 수 있으니 새로고침하세요.');
location.reload();
}
}).catch(err => {
console.error(err);
alert('이동 중 통신 오류 발생');
});
}
});
});
// ─────────────────────────────────────────────────────────────
// Quick Move 버튼 중복 클릭 방지 (Race Condition 예방)
// ─────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────
// Quick Move: 중복 처리 및 AJAX (Race Condition + Confirmation)
// ─────────────────────────────────────────────────────────────
const quickMoveForms = ['macMoveForm', 'guidMoveForm', 'gpuMoveForm'];
let pendingAction = null; // 대기 중인 재시도 액션 저장
// 모달 요소
const dupModalEl = document.getElementById('duplicateCheckModal');
const dupModal = dupModalEl ? new bootstrap.Modal(dupModalEl) : null;
const btnConfirmOverwrite = document.getElementById('btnConfirmOverwrite');
if (btnConfirmOverwrite) {
btnConfirmOverwrite.addEventListener('click', function () {
if (pendingAction) {
dupModal.hide();
pendingAction(true); // overwrite=true로 재실행
pendingAction = null;
}
});
}
quickMoveForms.forEach(id => {
const form = document.getElementById(id);
if (form) {
form.addEventListener('submit', function (e) {
e.preventDefault(); // 기본 제출 방지 (AJAX 사용)
const btn = form.querySelector('button[type="submit"]');
if (!btn || btn.disabled) return;
const originalContent = btn.innerHTML;
// 실행 함수 정의 (overwrite 여부 파라미터)
const executeMove = (overwrite = false) => {
// UI Lock
btn.classList.add('disabled');
btn.disabled = true;
btn.innerHTML = '<div class="spinner-border spinner-border-sm text-primary" role="status"></div>';
fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': window.APP_CONFIG.csrfToken
},
body: JSON.stringify({
overwrite: overwrite
})
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok: ' + response.status);
}
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
throw new TypeError("Oops, we haven't got JSON!");
}
return response.json();
})
.then(data => {
if (data.requires_confirmation) {
// 중복 발생 -> 모달 표시 (버튼 리셋 포함)
showDuplicateModal(data.duplicates, data.duplicate_count);
pendingAction = executeMove;
resetButton();
} else if (data.success) {
// 성공 -> 리로드
window.location.reload();
} else {
// 에러
alert(data.error || '작업이 실패했습니다.');
resetButton();
}
})
.catch(err => {
console.error(err);
// HTTP 200 에러이거나 단순 JSON 파싱 문제지만 실제로는 성공했을 가능성 대비
// (사용자 요청에 따라 HTTP 200 에러 알림 억제)
if (err.message && err.message.includes("200")) {
window.location.reload();
return;
}
alert('서버 통신 오류가 발생했습니다: ' + err);
resetButton();
});
};
const resetButton = () => {
btn.classList.remove('disabled');
btn.disabled = false;
btn.innerHTML = originalContent;
};
// 최초 실행 (overwrite=false)
executeMove(false);
});
}
});
function showDuplicateModal(duplicates, count) {
const listEl = document.getElementById('dupList');
const countEl = document.getElementById('dupCount');
const moreEl = document.getElementById('dupMore');
const moreCountEl = document.getElementById('dupMoreCount');
if (countEl) countEl.textContent = count;
if (listEl) {
listEl.innerHTML = '';
// 최대 10개만 표시
const limit = 10;
const showList = duplicates.slice(0, limit);
showList.forEach(name => {
const li = document.createElement('li');
li.innerHTML = `<i class="bi bi-file-earmark text-secondary me-2"></i>${name}`;
listEl.appendChild(li);
});
if (duplicates.length > limit) {
if (moreEl) {
moreEl.style.display = 'block';
moreCountEl.textContent = duplicates.length - limit;
}
} else {
if (moreEl) moreEl.style.display = 'none';
}
}
if (dupModal) dupModal.show();
}
});

View File

@@ -1,17 +1,17 @@
// script.js - 정리된 버전
document.addEventListener('DOMContentLoaded', () => {
// ─────────────────────────────────────────────────────────────
// CSRF 토큰
// ─────────────────────────────────────────────────────────────
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value || '';
// ─────────────────────────────────────────────────────────────
// 진행바 업데이트
// ─────────────────────────────────────────────────────────────
window.updateProgress = function(percent) {
window.updateProgress = function (percent) {
const bar = document.getElementById('progressBar');
if (!bar) return;
const v = Math.max(0, Math.min(100, Number(percent) || 0));
@@ -20,16 +20,16 @@ document.addEventListener('DOMContentLoaded', () => {
bar.innerHTML = `<span class="fw-semibold small">${v}%</span>`;
};
// ─────────────────────────────────────────────────────────────
// 줄 수 카운터
// ─────────────────────────────────────────────────────────────
function updateLineCount(textareaId, badgeId) {
const textarea = document.getElementById(textareaId);
const badge = document.getElementById(badgeId);
if (!textarea || !badge) return;
const updateCount = () => {
const text = textarea.value.trim();
if (text === '') {
@@ -39,18 +39,18 @@ document.addEventListener('DOMContentLoaded', () => {
const lines = text.split('\n').filter(line => line.trim().length > 0);
badge.textContent = `${lines.length}`;
};
updateCount();
textarea.addEventListener('input', updateCount);
textarea.addEventListener('change', updateCount);
textarea.addEventListener('keyup', updateCount);
textarea.addEventListener('paste', () => setTimeout(updateCount, 10));
}
updateLineCount('ips', 'ipLineCount');
updateLineCount('server_list_content', 'serverLineCount');
// ─────────────────────────────────────────────────────────────
// 스크립트 선택 시 XML 드롭다운 토글
// ─────────────────────────────────────────────────────────────
@@ -62,13 +62,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (!scriptSelect || !xmlGroup) return;
xmlGroup.style.display = (scriptSelect.value === TARGET_SCRIPT) ? 'block' : 'none';
}
if (scriptSelect) {
toggleXml();
scriptSelect.addEventListener('change', toggleXml);
}
// ─────────────────────────────────────────────────────────────
// 파일 보기 모달
// ─────────────────────────────────────────────────────────────
@@ -103,7 +103,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// ─────────────────────────────────────────────────────────────
// 공통 POST 함수
// ─────────────────────────────────────────────────────────────
@@ -130,55 +130,10 @@ document.addEventListener('DOMContentLoaded', () => {
return { success: true, html: true };
}
// ─────────────────────────────────────────────────────────────
// MAC 파일 이동
// ─────────────────────────────────────────────────────────────
const macForm = document.getElementById('macMoveForm');
if (macForm) {
macForm.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = macForm.querySelector('button');
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>처리 중...';
try {
await postFormAndHandle(macForm.action);
location.reload();
} catch (err) {
alert('MAC 이동 중 오류: ' + (err?.message || err));
btn.disabled = false;
btn.innerHTML = originalHtml;
}
});
}
// ─────────────────────────────────────────────────────────────
// GUID 파일 이동
// ─────────────────────────────────────────────────────────────
const guidForm = document.getElementById('guidMoveForm');
if (guidForm) {
guidForm.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = guidForm.querySelector('button');
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>처리 중...';
try {
await postFormAndHandle(guidForm.action);
location.reload();
} catch (err) {
alert('GUID 이동 중 오류: ' + (err?.message || err));
btn.disabled = false;
btn.innerHTML = originalHtml;
}
});
}
// ─────────────────────────────────────────────────────────────
// IP 폼 제출 및 진행률 폴링
// ─────────────────────────────────────────────────────────────
@@ -186,19 +141,19 @@ document.addEventListener('DOMContentLoaded', () => {
if (ipForm) {
ipForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const formData = new FormData(ipForm);
const btn = ipForm.querySelector('button[type="submit"]');
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>처리 중...';
try {
const res = await fetch(ipForm.action, {
method: "POST",
body: formData
});
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
@@ -219,7 +174,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// ─────────────────────────────────────────────────────────────
// 진행률 폴링 함수
// ─────────────────────────────────────────────────────────────
@@ -231,13 +186,13 @@ document.addEventListener('DOMContentLoaded', () => {
clearInterval(interval);
return;
}
const data = await res.json();
if (data.progress !== undefined) {
window.updateProgress(data.progress);
}
if (data.progress >= 100) {
clearInterval(interval);
window.updateProgress(100);
@@ -250,7 +205,7 @@ document.addEventListener('DOMContentLoaded', () => {
}, 500);
}
// ─────────────────────────────────────────────────────────────
// 알림 자동 닫기 (5초 후)
// ─────────────────────────────────────────────────────────────
@@ -260,5 +215,5 @@ document.addEventListener('DOMContentLoaded', () => {
bsAlert.close();
});
}, 5000);
});

View File

@@ -0,0 +1,534 @@
/* ─────────────────────────────────────────────────────────────
기본 레이아웃
───────────────────────────────────────────────────────────── */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic",
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
font-weight: 400;
background-color: #f8f9fa;
padding-top: 56px;
}
.container-card {
background-color: #ffffff;
padding: 20px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.08);
border-radius: 8px;
margin-bottom: 20px;
}
/* ─────────────────────────────────────────────────────────────
텍스트 및 제목 - 모두 일반 굵기
───────────────────────────────────────────────────────────── */
h1, h2, h3, h4, h5, h6 {
color: #343a40;
font-weight: 400;
}
.card-header h6 {
font-weight: 500;
}
/* ─────────────────────────────────────────────────────────────
폼 요소 - 모두 일반 굵기
───────────────────────────────────────────────────────────── */
.form-label {
color: #495057;
font-weight: 400;
}
.form-control, .form-select {
border-radius: 5px;
border: 1px solid #ced4da;
font-weight: 400;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.form-control:focus, .form-select:focus {
border-color: #80bdff;
box-shadow: 0 0 0 0.25rem rgba(0, 123, 255, 0.25);
}
/* ─────────────────────────────────────────────────────────────
버튼 - 일반 굵기
───────────────────────────────────────────────────────────── */
.btn {
border-radius: 5px;
font-weight: 400;
transition: all 0.2s ease-in-out;
}
.badge {
font-weight: 400;
}
/* ─────────────────────────────────────────────────────────────
네비게이션 바
───────────────────────────────────────────────────────────── */
.navbar {
background-color: #343a40 !important;
}
.navbar-brand {
font-weight: 700;
color: #ffffff !important;
}
.nav-link {
color: rgba(255, 255, 255, 0.75) !important;
font-weight: 400;
}
.nav-link:hover {
color: #ffffff !important;
}
/* ─────────────────────────────────────────────────────────────
카드 헤더 색상 (1번 이미지와 동일하게)
───────────────────────────────────────────────────────────── */
.card-header.bg-primary {
background-color: #007bff !important;
color: #ffffff !important;
}
.card-header.bg-success {
background-color: #28a745 !important;
color: #ffffff !important;
}
.card-header.bg-primary h6,
.card-header.bg-success h6 {
color: #ffffff !important;
}
.card-header.bg-primary i,
.card-header.bg-success i {
color: #ffffff !important;
}
/* 밝은 배경 헤더는 어두운 텍스트 */
.card-header.bg-light {
background-color: #f8f9fa !important;
color: #343a40 !important;
}
.card-header.bg-light h6 {
color: #343a40 !important;
}
.card-header.bg-light i {
color: #343a40 !important;
}
/* ─────────────────────────────────────────────────────────────
버튼 색상 (2번 이미지와 동일하게)
───────────────────────────────────────────────────────────── */
.btn-warning {
background-color: #ffc107 !important;
border-color: #ffc107 !important;
color: #000 !important;
}
.btn-warning:hover {
background-color: #e0a800 !important;
border-color: #d39e00 !important;
color: #000 !important;
}
.btn-info {
background-color: #17a2b8 !important;
border-color: #17a2b8 !important;
color: #fff !important;
}
.btn-info:hover {
background-color: #138496 !important;
border-color: #117a8b !important;
color: #fff !important;
}
/* ─────────────────────────────────────────────────────────────
진행바
───────────────────────────────────────────────────────────── */
.progress {
border-radius: 10px;
overflow: hidden;
}
.progress-bar {
transition: width 0.6s ease;
}
/* ─────────────────────────────────────────────────────────────
파일 그리드 레이아웃 - 빈 공간 없이 채우기
───────────────────────────────────────────────────────────── */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
}
/* ─────────────────────────────────────────────────────────────
파일 카드 (컴팩트)
───────────────────────────────────────────────────────────── */
.file-card-compact {
transition: all 0.2s ease;
background: #fff;
width: 100%;
max-width: 180px; /* 기본값 유지(카드가 너무 넓어지지 않도록) */
}
.file-card-compact:hover {
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.file-card-compact a {
font-size: 0.85rem;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* 파일 카드 내 모든 텍스트 일반 굵기 */
.file-card-compact,
.file-card-compact * {
font-weight: 400 !important;
}
/* ─────────────────────────────────────────────────────────────
(공통) 파일 카드 버튼 컨테이너 기본값 (기존 유지)
───────────────────────────────────────────────────────────── */
.file-card-buttons { /* 처리된 목록(2버튼) 기본 레이아웃 */
display: flex;
gap: 0.15rem;
}
.file-card-buttons > button,
.file-card-buttons > form {
width: calc(50% - 0.075rem);
}
.file-card-buttons form { margin: 0; padding: 0; }
.file-card-buttons .btn-sm {
padding: 0.1rem 0.2rem !important;
font-size: 0.65rem !important;
width: 100%;
text-align: center;
}
/* 1버튼(백업) 기본 레이아웃 */
.file-card-single-button {
display: flex;
justify-content: center;
}
.file-card-single-button .btn-sm {
padding: 0.15rem 0.3rem !important;
font-size: 0.7rem !important;
min-width: 50px;
text-align: center;
}
/* ─────────────────────────────────────────────────────────────
(공통) Outline 기본값 (기존 유지)
───────────────────────────────────────────────────────────── */
.file-card-compact .btn-outline-primary {
background-color: transparent !important;
color: #0d6efd !important;
border: 1px solid #0d6efd !important;
}
.file-card-compact .btn-outline-primary:hover {
background-color: #0d6efd !important;
color: #fff !important;
}
.file-card-compact .btn-outline-danger {
background-color: transparent !important;
color: #dc3545 !important;
border: 1px solid #dc3545 !important;
}
.file-card-compact .btn-outline-danger:hover {
background-color: #dc3545 !important;
color: #fff !important;
}
/* 기존 d-flex gap-2 레거시 대응 */
.file-card-compact .d-flex.gap-2 { display: flex; gap: 0.2rem; }
.file-card-compact .d-flex.gap-2 > * { flex: 1; }
.file-card-compact .d-flex.gap-2 form { display: contents; }
/* ─────────────────────────────────────────────────────────────
!!! 목록별 버튼 스타일 "분리" 규칙 (HTML에 클래스만 달아주면 적용)
- processed-list 블록의 보기/삭제
- backup-list 블록의 보기
───────────────────────────────────────────────────────────── */
/* 처리된 파일 목록(Processed) : 컨테이너 세부 튜닝 */
.processed-list .file-card-buttons {
display: grid;
grid-template-columns: 1fr 1fr; /* 보기/삭제 2열 격자 */
gap: 0.2rem;
}
/* 보기(처리된) — 전용 클래스 우선 */
.processed-list .btn-view-processed,
.processed-list .file-card-buttons .btn-outline-primary { /* (백워드 호환) */
border-color: #3b82f6 !important;
color: #1d4ed8 !important;
background: transparent !important;
padding: .35rem .55rem !important;
font-size: .8rem !important;
font-weight: 600 !important;
}
.processed-list .btn-view-processed:hover,
.processed-list .file-card-buttons .btn-outline-primary:hover {
background: rgba(59,130,246,.10) !important;
color: #1d4ed8 !important;
}
/* 삭제(처리된) — 전용 클래스 우선(더 작게) */
.processed-list .btn-delete-processed,
.processed-list .file-card-buttons .btn-outline-danger { /* (백워드 호환) */
border-color: #ef4444 !important;
color: #b91c1c !important;
background: transparent !important;
padding: .25rem .45rem !important; /* 더 작게 */
font-size: .72rem !important; /* 더 작게 */
font-weight: 600 !important;
}
.processed-list .btn-delete-processed:hover,
.processed-list .file-card-buttons .btn-outline-danger:hover {
background: rgba(239,68,68,.10) !important;
color: #b91c1c !important;
}
/* 백업 파일 목록(Backup) : 1버튼 컨테이너 */
.backup-list .file-card-single-button {
display: flex;
margin-top: .25rem;
}
/* 보기(백업) — 전용 클래스 우선(초록계열), 기존 .btn-outline-primary 사용 시에도 분리 적용 */
.backup-list .btn-view-backup,
.backup-list .file-card-single-button .btn-outline-primary { /* (백워드 호환) */
width: 100%;
border-color: #10b981 !important; /* emerald-500 */
color: #047857 !important; /* emerald-700 */
background: transparent !important;
padding: .42rem .7rem !important;
font-size: .8rem !important;
font-weight: 700 !important; /* 백업은 강조 */
}
.backup-list .btn-view-backup:hover,
.backup-list .file-card-single-button .btn-outline-primary:hover {
background: rgba(16,185,129,.12) !important;
color: #047857 !important;
}
/* ─────────────────────────────────────────────────────────────
[★ 보완] 버튼 크기 “완전 통일”(처리/백업 공통)
- 폰트/라인하이트/패딩을 변수화해서 두 목록 크기 동일
- 기존 개별 padding/font-size를 덮어써서 시각적 높이 통일
───────────────────────────────────────────────────────────── */
:root{
--btn-font: .80rem; /* 버튼 폰트 크기 통일 지점 */
--btn-line: 1.2; /* 버튼 라인하이트 통일 지점 */
--btn-py: .32rem; /* 수직 패딩 */
--btn-px: .60rem; /* 좌우 패딩 */
}
.processed-list .file-card-buttons .btn,
.backup-list .file-card-single-button .btn {
font-size: var(--btn-font) !important;
line-height: var(--btn-line) !important;
padding: var(--btn-py) var(--btn-px) !important;
min-height: calc(1em * var(--btn-line) + (var(--btn-py) * 2)) !important;
}
/* 이전 규칙보다 더 구체적으로 동일 규격을 한 번 더 보장 */
.processed-list .file-card-buttons .btn.btn-outline,
.backup-list .file-card-single-button .btn.btn-outline {
font-size: var(--btn-font) !important;
line-height: var(--btn-line) !important;
padding: var(--btn-py) var(--btn-px) !important;
}
/* ─────────────────────────────────────────────────────────────
[★ 보완] 카드 “자동 한줄 배치”
- 기존 Bootstrap .row.g-3를 Grid로 오버라이드(HTML 수정 無)
- 우측 여백 최소화, 화면 너비에 맞춰 자연스럽게 줄 수 변경
───────────────────────────────────────────────────────────── */
.processed-list .card-body > .row.g-3,
.backup-list .card-body .row.g-3 {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: .75rem;
}
/* 그리드 기준으로 카드 폭이 잘 늘어나도록 제한 완화 */
.processed-list .file-card-compact,
.backup-list .file-card-compact {
max-width: none !important; /* 기존 180px 제한을 목록 구간에 한해 해제 */
min-width: 160px;
width: 100%;
}
/* ─────────────────────────────────────────────────────────────
반응형 파일 그리드 (기존 유지)
───────────────────────────────────────────────────────────── */
@media (max-width: 1400px) {
.file-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
}
@media (max-width: 1200px) {
.file-grid { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
}
@media (max-width: 992px) {
.file-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
}
@media (max-width: 768px) {
.file-grid { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
}
@media (max-width: 576px) {
.file-grid { grid-template-columns: repeat(auto-fit, minmax(45%, 1fr)); }
}
/* ─────────────────────────────────────────────────────────────
백업 파일 리스트
───────────────────────────────────────────────────────────── */
.list-group-item .bg-light {
transition: background-color 0.2s ease;
}
.list-group-item:hover .bg-light {
background-color: #e9ecef !important;
}
/* ─────────────────────────────────────────────────────────────
모달 - 파일 내용 보기
───────────────────────────────────────────────────────────── */
#fileViewContent {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
padding: 1rem;
border-radius: 5px;
max-height: 60vh;
overflow-y: auto;
font-weight: 400;
}
.modal-body pre::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.modal-body pre::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.modal-body pre::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.modal-body pre::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* ─────────────────────────────────────────────────────────────
접근성 - Skip to content
───────────────────────────────────────────────────────────── */
.visually-hidden-focusable:not(:focus):not(:focus-within) {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.visually-hidden-focusable:focus {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
padding: 1rem;
background: #000;
color: #fff;
}
/* ─────────────────────────────────────────────────────────────
전역 폰트 굵기 강제 (Bootstrap 오버라이드)
───────────────────────────────────────────────────────────── */
* { font-weight: inherit; }
strong, b { font-weight: 600; }
label, .form-label, .card-title, .list-group-item strong {
font-weight: 400 !important;
}
/* ─────────────────────────────────────────────────────────────
반응형
───────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.card-body {
padding: 1.5rem !important;
}
body {
font-size: 0.95rem;
}
}
/* === [FIX] 처리된 목록 보기/삭제 버튼 크기 완전 동일화 === */
/* 1) 그리드 두 칸을 꽉 채우게 강제 */
.processed-list .file-card-buttons {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: .2rem !important;
align-items: stretch !important; /* 높이도 칸 높이에 맞춰 늘림 */
}
/* 2) 그리드 아이템(버튼/폼) 자체를 칸 너비로 확장 */
.processed-list .file-card-buttons > * {
width: 100% !important;
}
/* 3) 폼 안의 버튼도 100%로 확장 (폼이 그리드 아이템인 경우 대비) */
.processed-list .file-card-buttons > form { display: block !important; }
.processed-list .file-card-buttons > form > button {
display: block !important;
width: 100% !important;
}
/* 4) 예전 플렉스 기반 전역 규칙 덮어쓰기(폭 계산식 무력화) */
.processed-list .file-card-buttons > button,
.processed-list .file-card-buttons > form {
width: 100% !important;
}
/* 5) 폰트/라인하이트/패딩 통일(높이 동일) — 필요 시 수치만 조정 */
:root{
--btn-font: .80rem;
--btn-line: 1.2;
--btn-py: .32rem;
--btn-px: .60rem;
}
.processed-list .file-card-buttons .btn {
font-size: var(--btn-font) !important;
line-height: var(--btn-line) !important;
padding: var(--btn-py) var(--btn-px) !important;
min-height: calc(1em * var(--btn-line) + (var(--btn-py) * 2)) !important;
box-sizing: border-box;
}

View File

@@ -0,0 +1,526 @@
from __future__ import annotations
import os
import sys
import shutil
import subprocess
import logging
from pathlib import Path
from flask import Blueprint, request, redirect, url_for, flash, jsonify, send_file
from flask_login import login_required
from config import Config
utils_bp = Blueprint("utils", __name__)
def register_util_routes(app):
app.register_blueprint(utils_bp)
@utils_bp.route("/move_mac_files", methods=["POST"])
@login_required
def move_mac_files():
src = Path(Config.IDRAC_INFO_FOLDER)
dst = Path(Config.MAC_FOLDER)
dst.mkdir(parents=True, exist_ok=True)
moved = 0
skipped = 0
missing = 0
errors = []
# JSON 요청 파싱 (overwrite 플래그 확인)
data = request.get_json(silent=True) or {}
overwrite = data.get("overwrite", False)
# 1. 대상 파일 스냅샷 (이동 시도할, 또는 해야할 파일들)
try:
current_files = [f for f in src.iterdir() if f.is_file()]
except Exception as e:
logging.error(f"파일 목록 조회 실패: {e}")
return jsonify({"success": False, "error": str(e)})
# [중복 체크 로직] 덮어쓰기 모드가 아닐 때, 미리 중복 검사
if not overwrite:
duplicates = []
for file in current_files:
target = dst / file.name
if target.exists():
duplicates.append(file.name)
if duplicates:
return jsonify({
"success": False,
"requires_confirmation": True,
"duplicates": duplicates,
"duplicate_count": len(duplicates)
})
else:
logging.warning(f"⚠️ [MAC] 덮어쓰기 모드 활성화됨 - 중복 파일을 덮어씁니다.")
total_target_count = len(current_files)
# 카운터
moved_count = 0 # 내가 직접 옮김 (또는 덮어씀)
verified_count = 0 # 최종적으로 목적지에 있음을 확인 (성공)
lost_count = 0 # 소스에도 없고 목적지에도 없음 (진짜 유실?)
errors = []
for file in current_files:
target = dst / file.name
# [Step 1] 이미 목적지에 있는지 확인
if target.exists():
if overwrite:
# 덮어쓰기 모드: 기존 파일 삭제 후 이동 진행 (또는 바로 move로 덮어쓰기)
# shutil.move는 대상이 존재하면 에러가 날 수 있으므로(버전/OS따라 다름), 안전하게 삭제 시도
try:
# Windows에서는 사용 중인 파일 삭제 시 에러 발생 가능
# shutil.move(src, dst)는 dst가 존재하면 덮어쓰기 시도함 (Python 3.x)
pass
except Exception:
pass
else:
# (중복 체크를 통과했거나 Race Condition으로 생성된 경우) -> 이미 완료된 것으로 간주
verified_count += 1
logging.info(f"⏭️ 파일 이미 존재 (Skipped): {file.name}")
continue
# [Step 2] 소스에 있는지 확인 (Race Condition)
if not file.exists():
if target.exists():
verified_count += 1
continue
else:
lost_count += 1
logging.warning(f"❓ 이동 중 사라짐: {file.name}")
continue
# [Step 3] 이동 시도 (덮어쓰기 포함)
try:
shutil.move(str(file), str(target))
moved_count += 1
verified_count += 1
except shutil.Error as e:
# shutil.move might raise Error if destination exists depending on implementation,
# but standard behavior overwrites if not same file.
# If exact same file, verified.
if target.exists():
verified_count += 1
else:
errors.append(f"{file.name}: {str(e)}")
except FileNotFoundError:
# 옮기려는 찰나에 사라짐 -> 목적지 재확인
if target.exists():
verified_count += 1
logging.info(f"⏭️ 동시 처리됨 (완료): {file.name}")
else:
lost_count += 1
except Exception as e:
# 권한 에러 등 진짜 실패
error_msg = f"{file.name}: {str(e)}"
errors.append(error_msg)
logging.error(f"❌ 이동 에러: {error_msg}")
# 결과 요약
msg = f"{total_target_count}건 중 {verified_count}건 처리 완료"
if moved_count < verified_count:
msg += f" (이동: {moved_count}, 이미 완료: {verified_count - moved_count})"
if lost_count > 0:
msg += f", 확인 불가: {lost_count}"
logging.info(f"✅ MAC 처리 결과: {msg}")
flash(msg, "success" if lost_count == 0 else "warning")
return jsonify({
"success": True,
"total": total_target_count,
"verified": verified_count,
"message": msg,
"errors": errors if errors else None
})
@utils_bp.route("/move_guid_files", methods=["POST"])
@login_required
def move_guid_files():
src = Path(Config.IDRAC_INFO_FOLDER)
dst = Path(Config.GUID_FOLDER)
dst.mkdir(parents=True, exist_ok=True)
moved = 0
skipped = 0
missing = 0
errors = []
# JSON 요청 파싱 (overwrite 플래그 확인)
data = request.get_json(silent=True) or {}
overwrite = data.get("overwrite", False)
try:
files = [f for f in src.iterdir() if f.is_file()]
except Exception:
files = []
# [중복 체크]
if not overwrite:
duplicates = []
for file in files:
target = dst / file.name
if target.exists():
duplicates.append(file.name)
if duplicates:
return jsonify({
"success": False,
"requires_confirmation": True,
"duplicates": duplicates,
"duplicate_count": len(duplicates)
})
else:
logging.warning(f"⚠️ [GUID] 덮어쓰기 모드 활성화됨 - 중복 파일을 덮어씁니다.")
total_target_count = len(files)
verified_count = 0
moved_count = 0
errors = []
lost_count = 0
try:
for file in files:
target = dst / file.name
# 1. 이미 완료되었는지 확인
if target.exists():
if not overwrite:
verified_count += 1
continue
# overwrite=True면 계속 진행하여 덮어쓰기 시도
# 2. 소스 확인
if not file.exists():
if target.exists(): verified_count += 1
else: lost_count += 1
continue
# 3. 이동
try:
shutil.move(str(file), str(target))
moved_count += 1
verified_count += 1
except FileNotFoundError:
if target.exists(): verified_count += 1
else: lost_count += 1
except Exception as e:
errors.append(f"{file.name}: {e}")
# 상세 메시지
msg = f"{total_target_count}건 중 {verified_count}건 처리 완료"
logging.info(f"✅ GUID 처리: {msg}")
flash(msg, "success" if lost_count == 0 else "warning")
return jsonify({
"success": True,
"total": total_target_count,
"verified": verified_count,
"message": msg,
"errors": errors if errors else None
})
except Exception as e:
logging.error(f"❌ GUID 이동 중 오류: {e}")
return jsonify({"success": False, "error": str(e)})
@utils_bp.route("/move_gpu_files", methods=["POST"])
@login_required
def move_gpu_files():
"""
data/idrac_info → data/gpu_serial 로 GPU 시리얼 텍스트 파일 이동
"""
src = Path(Config.IDRAC_INFO_FOLDER) # 예: data/idrac_info
dst = Path(Config.GPU_FOLDER) # 예: data/gpu_serial
dst.mkdir(parents=True, exist_ok=True)
moved = 0
skipped = 0
missing = 0
errors = []
# JSON 요청 파싱
data = request.get_json(silent=True) or {}
overwrite = data.get("overwrite", False)
try:
all_files = [f for f in src.iterdir() if f.is_file()]
files = [f for f in all_files if f.name.lower().endswith(".txt")]
except Exception:
files = []
# [중복 체크]
if not overwrite:
duplicates = []
for file in files:
target = dst / file.name
if target.exists():
duplicates.append(file.name)
if duplicates:
return jsonify({
"success": False,
"requires_confirmation": True,
"duplicates": duplicates,
"duplicate_count": len(duplicates)
})
else:
logging.warning(f"⚠️ [GPU] 덮어쓰기 모드 활성화됨 - 중복 파일을 덮어씁니다.")
total_target_count = len(files)
verified_count = 0
moved_count = 0
errors = []
lost_count = 0
try:
for file in files:
target = dst / file.name
# 1. 존재 확인 (덮어쓰기 아닐 경우)
if target.exists():
if not overwrite:
verified_count += 1
continue
# 2. 소스 확인
if not file.exists():
if target.exists(): verified_count += 1
else: lost_count += 1
continue
# 3. 이동
try:
shutil.move(str(file), str(target))
moved_count += 1
verified_count += 1
except FileNotFoundError:
if target.exists(): verified_count += 1
else: lost_count += 1
except Exception as e:
errors.append(f"{file.name}: {e}")
# 상세 메시지
msg = f"{total_target_count}건 중 {verified_count}건 처리 완료"
logging.info(f"✅ GPU 처리: {msg}")
flash(msg, "success")
return jsonify({
"success": True,
"total": total_target_count,
"verified": verified_count,
"message": msg,
"errors": errors if errors else None
})
except Exception as e:
logging.error(f"❌ GPU 이동 오류: {e}")
return jsonify({"success": False, "error": str(e)})
@utils_bp.route("/update_server_list", methods=["POST"])
@login_required
def update_server_list():
content = request.form.get("server_list_content")
if not content:
flash("내용을 입력하세요.", "warning")
return redirect(url_for("main.index"))
path = Path(Config.SERVER_LIST_FOLDER) / "server_list.txt"
try:
path.write_text(content, encoding="utf-8")
result = subprocess.run(
[sys.executable, str(Path(Config.SERVER_LIST_FOLDER) / "excel.py")],
capture_output=True,
text=True,
check=True,
cwd=str(Path(Config.SERVER_LIST_FOLDER)),
timeout=300,
)
logging.info(f"서버 리스트 스크립트 실행 결과: {result.stdout}")
flash("서버 리스트가 업데이트되었습니다.", "success")
except subprocess.CalledProcessError as e:
logging.error(f"서버 리스트 스크립트 오류: {e.stderr}")
flash(f"스크립트 실행 실패: {e.stderr}", "danger")
except Exception as e:
logging.error(f"서버 리스트 처리 오류: {e}")
flash(f"서버 리스트 처리 중 오류 발생: {e}", "danger")
return redirect(url_for("main.index"))
@utils_bp.route("/update_guid_list", methods=["POST"])
@login_required
def update_guid_list():
content = request.form.get("server_list_content")
slot_priority = request.form.get("slot_priority", "") # 슬롯 우선순위 받기
if not content:
flash("내용을 입력하세요.", "warning")
return redirect(url_for("main.index"))
path = Path(Config.SERVER_LIST_FOLDER) / "guid_list.txt"
try:
path.write_text(content, encoding="utf-8")
# 슬롯 우선순위를 환경변수로 전달
env = os.environ.copy()
if slot_priority:
env["GUID_SLOT_PRIORITY"] = slot_priority
logging.info(f"GUID 슬롯 우선순위: {slot_priority}")
result = subprocess.run(
[sys.executable, str(Path(Config.SERVER_LIST_FOLDER) / "GUIDtxtT0Execl.py")],
capture_output=True,
text=True,
check=True,
cwd=str(Path(Config.SERVER_LIST_FOLDER)),
timeout=300,
env=env, # 환경변수 전달
)
logging.info(f"GUID 리스트 스크립트 실행 결과: {result.stdout}")
flash("GUID 리스트가 업데이트되었습니다.", "success")
except subprocess.CalledProcessError as e:
logging.error(f"GUID 리스트 스크립트 오류: {e.stderr}")
flash(f"스크립트 실행 실패: {e.stderr}", "danger")
except Exception as e:
logging.error(f"GUID 리스트 처리 오류: {e}")
flash(f"GUID 리스트 처리 중 오류 발생: {e}", "danger")
return redirect(url_for("main.index"))
@utils_bp.route("/update_gpu_list", methods=["POST"])
@login_required
def update_gpu_list():
"""
GPU 시리얼용 리스트(gpu_serial_list.txt)를 갱신하고 Excel을 생성합니다.
- form name="gpu_list_content" 로 내용 전달 (S/T 목록 라인별)
- txt_to_excel.py --preset gpu --list-file <gpu_serial_list.txt>
"""
content = request.form.get("server_list_content")
if not content:
flash("내용을 입력하세요.", "warning")
return redirect(url_for("main.index"))
server_list_dir = Path(Config.SERVER_LIST_FOLDER)
list_path = server_list_dir / "gpu_list.txt"
# txt_to_excel.py는 server_list 폴더에 둔다고 가정 (위치 다르면 경로만 수정)
script_path = server_list_dir / "GPUTOExecl.py"
try:
# 1) gpu_serial_list.txt 저장
list_path.write_text(content, encoding="utf-8")
# 2) 엑셀 생성 실행 (GPU 프리셋)
cmd = [
sys.executable,
str(script_path),
"--preset", "gpu",
"--list-file", str(list_path),
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
cwd=str(server_list_dir), # data/server_list 기준 실행
timeout=300,
)
logging.info(f"[GPU] 리스트 스크립트 실행 STDOUT:\n{result.stdout}")
if result.stderr:
logging.warning(f"[GPU] 리스트 스크립트 STDERR:\n{result.stderr}")
flash("GPU 리스트가 업데이트되었습니다.", "success")
except subprocess.CalledProcessError as e:
logging.error(f"[GPU] 스크립트 오류: {e.stderr}")
flash(f"스크립트 실행 실패: {e.stderr}", "danger")
except Exception as e:
logging.error(f"[GPU] 처리 오류: {e}")
flash(f"GPU 리스트 처리 중 오류 발생: {e}", "danger")
return redirect(url_for("main.index"))
logging.info(f"엑셀 파일 다운로드: {path}")
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 목록을 반환합니다.
"""
try:
import ipaddress
import platform
import concurrent.futures
data = request.get_json(force=True, silent=True) or {}
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 리스트 생성
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 발견"
})
except Exception as e:
logging.error(f"Scan network fatal error: {e}")
return jsonify({"success": False, "error": f"서버 내부 오류: {str(e)}"}), 500

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 %}

View File

@@ -0,0 +1,699 @@
{% extends "base.html" %}
{% block content %}
<div class="container-fluid py-4">
{# 헤더 섹션 #}
<div class="row mb-4">
<div class="col">
<h2 class="fw-bold mb-1">
<i class="bi bi-server text-primary me-2"></i>
서버 관리 대시보드
</h2>
<p class="text-muted mb-0">IP 처리 및 파일 관리를 위한 통합 관리 도구</p>
</div>
</div>
{# 메인 작업 영역 #}
<div class="row g-4 mb-4">
{# IP 처리 카드 #}
<div class="col-lg-6">
<div class="card border shadow-sm h-100">
<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 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">
<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;">
<select id="xmlFile" name="xmlFile" class="form-select">
<option value="">XML 파일 선택</option>
{% for xml_file in xml_files %}
<option value="{{ xml_file }}">{{ xml_file }}</option>
{% endfor %}
</select>
</div>
{# IP 주소 입력 #}
<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-secondary px-2 py-1" id="btnClearIps"
title="입력 내용 지우기" style="font-size: 0.75rem;">
<i class="bi bi-trash me-1"></i>지우기
</button>
<button type="button" class="btn btn-sm btn-outline-primary px-2 py-1" id="btnStartScan"
title="10.10.0.1 ~ 255 자동 스캔" style="font-size: 0.75rem;">
<i class="bi bi-search me-1"></i>IP 스캔
</button>
</div>
</label>
<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>
<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>
</div>
{# 공유 작업 카드 #}
<div class="col-lg-6">
<div class="card border shadow-sm h-100">
<div class="card-header bg-light border-0 py-2">
<h6 class="mb-0">
<i class="bi bi-share me-2"></i>
공유 작업
</h6>
</div>
<div class="card-body p-4">
<form id="sharedForm" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="server_list_content" class="form-label">
서버 리스트 (덮어쓰기)
<span class="badge bg-secondary ms-2" id="serverLineCount">0 대설정</span>
</label>
<textarea id="server_list_content" name="server_list_content" rows="8" class="form-control font-monospace"
style="font-size: 0.95rem;" placeholder="서버 리스트를 입력하세요..."></textarea>
</div>
<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="button" data-bs-toggle="modal" data-bs-target="#slotPriorityModal"
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>
</div>
</div>
</div>
{# 진행바 #}
<div class="row mb-4">
<div class="col">
<div class="card border-0 shadow-sm">
<div class="card-body p-3">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-activity text-primary me-2"></i>
<span class="fw-semibold">처리 진행률</span>
</div>
<div class="progress" style="height: 25px;">
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success"
role="progressbar" style="width:0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<span class="fw-semibold">0%</span>
</div>
</div>
</div>
</div>
</div>
</div>
{# 파일 관리 도구 #}
<div class="row mb-4">
<div class="col">
<div class="card border shadow-sm">
<div class="card-header bg-light border-0 py-2">
<h6 class="mb-0">
<i class="bi bi-tools me-2"></i>
파일 관리 도구
</h6>
</div>
<div class="card-body p-4 file-tools">
<div class="d-flex flex-column gap-3">
<!-- 상단: 입력형 도구 (다운로드/백업) -->
<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>
</div>
<!-- 파일 백업 -->
<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="ex)PO-20251117-0015_20251223_판교_R6615(TY1A)"
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>
</div>
</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 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>
<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>
</div>
</div>
{# 처리된 파일 목록 #}
<div class="row mb-4 processed-list">
<div class="col">
<div class="card border shadow-sm">
<div class="card-header bg-light border-0 py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="bi bi-files me-2"></i>
처리된 파일 목록
</h6>
</div>
<div class="card-body p-4">
{% if files_to_display and files_to_display|length > 0 %}
<div class="row g-3">
{% for file_info in files_to_display %}
<div class="col-auto">
<div class="file-card-compact border rounded p-2 text-center">
<a href="{{ url_for('main.download_file', filename=file_info.file) }}"
class="text-decoration-none text-dark fw-semibold d-block mb-2 text-nowrap px-2" download
title="{{ file_info.name or file_info.file }}">
{{ file_info.name or file_info.file }}
</a>
<div class="file-card-buttons d-flex gap-2 justify-content-center">
<button type="button" class="btn btn-sm btn-outline btn-view-processed flex-fill"
data-bs-toggle="modal" data-bs-target="#fileViewModal" data-folder="idrac_info"
data-filename="{{ file_info.file }}">
보기
</button>
<form action="{{ url_for('main.delete_file', filename=file_info.file) }}" method="post"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline btn-delete-processed flex-fill"
onclick="return confirm('삭제하시겠습니까?');">
삭제
</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 페이지네이션 -->
{% if total_pages > 1 %}
<nav aria-label="Processed files pagination" class="mt-4">
<ul class="pagination justify-content-center mb-0">
<!-- 이전 페이지 -->
{% if page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.index', page=page-1) }}">
<i class="bi bi-chevron-left"></i> 이전
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i> 이전</span>
</li>
{% endif %}
<!-- 페이지 번호 (최대 10개 표시) -->
{% set start_page = ((page - 1) // 10) * 10 + 1 %}
{% set end_page = [start_page + 9, total_pages]|min %}
{% for p in range(start_page, end_page + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('main.index', page=p) }}">{{ p }}</a>
</li>
{% endfor %}
<!-- 다음 페이지 -->
{% if page < total_pages %} <li class="page-item">
<a class="page-link" href="{{ url_for('main.index', page=page+1) }}">
다음 <i class="bi bi-chevron-right"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">다음 <i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<!-- /페이지네이션 -->
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox fs-1 text-muted mb-3"></i>
<p class="text-muted mb-0">표시할 파일이 없습니다.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# 백업된 파일 목록 #}
<div class="row backup-list">
<div class="col">
<div class="card border shadow-sm">
<div class="card-header bg-light border-0 py-2">
<h6 class="mb-0">
<i class="bi bi-archive me-2"></i>
백업된 파일 목록
</h6>
</div>
<div class="card-body p-4">
{% if backup_files and backup_files|length > 0 %}
<div class="list-group">
{% for date, info in backup_files.items() %}
<div class="list-group-item border rounded mb-2 p-0 overflow-hidden">
<div class="d-flex justify-content-between align-items-center p-3 bg-light">
<div class="d-flex align-items-center">
<i class="bi bi-calendar3 text-primary me-2"></i>
<strong>{{ date }}</strong>
<span class="badge bg-primary ms-3">{{ info.count }} 파일</span>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse"
data-bs-target="#collapse-{{ loop.index }}" aria-expanded="false">
<i class="bi bi-chevron-down"></i>
</button>
</div>
<div id="collapse-{{ loop.index }}" class="collapse">
<div class="p-3">
<div class="row g-3 backup-files-container" data-folder="{{ date }}" style="min-height: 50px;">
{% for file in info.files %}
<div class="col-auto backup-file-item" data-filename="{{ file }}">
<div class="file-card-compact border rounded p-2 text-center bg-white">
<a href="{{ url_for('main.download_backup_file', date=date, filename=file) }}"
class="text-decoration-none text-dark fw-semibold d-block mb-2 text-nowrap px-2" download
title="{{ file }}">
{{ file.rsplit('.', 1)[0] }}
</a>
<div class="file-card-single-button">
<button type="button" class="btn btn-sm btn-outline btn-view-backup w-100"
data-bs-toggle="modal" data-bs-target="#fileViewModal" data-folder="backup"
data-date="{{ date }}" data-filename="{{ file }}">
보기
</button>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 백업 목록 페이지네이션 -->
{% if total_backup_pages > 1 %}
<nav aria-label="Backup pagination" class="mt-4">
<ul class="pagination justify-content-center mb-0">
<!-- 이전 페이지 -->
{% if backup_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.index', backup_page=backup_page-1, page=page) }}">
<i class="bi bi-chevron-left"></i> 이전
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i> 이전</span>
</li>
{% endif %}
<!-- 페이지 번호 -->
{% set start_b_page = ((backup_page - 1) // 10) * 10 + 1 %}
{% set end_b_page = [start_b_page + 9, total_backup_pages]|min %}
{% for p in range(start_b_page, end_b_page + 1) %}
<li class="page-item {% if p == backup_page %}active{% endif %}">
<a class="page-link" href="{{ url_for('main.index', backup_page=p, page=page) }}">{{ p }}</a>
</li>
{% endfor %}
<!-- 다음 페이지 -->
{% if backup_page < total_backup_pages %} <li class="page-item">
<a class="page-link" href="{{ url_for('main.index', backup_page=backup_page+1, page=page) }}">
다음 <i class="bi bi-chevron-right"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">다음 <i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox fs-1 text-muted mb-3"></i>
<p class="text-muted mb-0">백업된 파일이 없습니다.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{# 파일 보기 모달 #}
<div class="modal fade" id="fileViewModal" tabindex="-1" aria-labelledby="fileViewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="fileViewModalLabel">
<i class="bi bi-file-text me-2"></i>
파일 보기
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="닫기"></button>
</div>
<div class="modal-body bg-light">
<pre id="fileViewContent" class="mb-0 p-3 bg-white border rounded font-monospace"
style="white-space:pre-wrap;word-break:break-word;max-height:70vh;">불러오는 중...</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle me-1"></i>닫기
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_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">
{% 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>
window.APP_CONFIG = {
moveBackupUrl: "{{ url_for('main.move_backup_files') }}",
csrfToken: "{{ csrf_token() }}",
downloadBaseUrl: "{{ url_for('main.download_backup_file', date='PLACEHOLDER_DATE', filename='PLACEHOLDER_FILE') }}"
};
</script>
<script src="{{ url_for('static', filename='js/dashboard.js') }}?v={{ range(1, 100000) | random }}"></script>
<!-- SortableJS for Drag and Drop -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<!-- 슬롯 우선순위 설정 모달 (Premium Design) -->
<div class="modal fade" id="slotPriorityModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content border-0 shadow-lg" style="border-radius: 1rem; overflow: hidden;">
<!-- 헤더: 깔끔한 모던 스타일 -->
<div class="modal-header border-bottom p-4 bg-white">
<div>
<h5 class="modal-title fw-bold text-dark mb-1">
<i class="bi bi-layers text-primary me-2"></i>GUID 슬롯 우선순위 설정
</h5>
<p class="mb-0 text-muted" style="font-size: 0.85rem;">
엑셀 변환 시 적용될 슬롯의 순서를 설정합니다.
</p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="닫기"></button>
</div>
<div class="modal-body p-0 bg-light">
<form id="slotPriorityForm" action="{{ url_for('utils.update_guid_list') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="server_list_content" id="modal_server_list_content">
<input type="hidden" name="slot_priority" id="slot_priority_input">
<div class="row g-0">
<!-- 왼쪽: 입력 및 프리셋 -->
<div class="col-lg-5 border-end bg-white p-4 d-flex flex-column">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-bold text-dark mb-0 small text-uppercase">
<i class="bi bi-keyboard me-1"></i>슬롯 번호 입력
</h6>
</div>
<div class="position-relative flex-grow-1">
<textarea id="slotNumbersInput"
class="form-control bg-light border-0 font-monospace p-3 text-dark h-100"
style="resize: none; font-size: 0.9rem; min-height: 200px;"
placeholder="슬롯 번호를 입력하세요.&#10;구분자: 쉼표(,) 공백( ) 줄바꿈&#10;&#10;예시: 38, 39, 37"></textarea>
<div class="position-absolute bottom-0 end-0 p-2">
<button type="button" id="btnClearSlots" class="btn btn-sm btn-link text-decoration-none text-muted"
style="font-size: 0.75rem;">
<i class="bi bi-x-circle me-1"></i>지우기
</button>
</div>
</div>
<!-- 미니멀한 프리셋 설정 (숫자 입력) -->
<div class="mt-3 pt-3 border-top border-light d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<span class="small text-muted me-2" style="font-size: 0.75rem;">카드 개수 설정:</span>
<div class="input-group input-group-sm" style="width: 120px;">
<span class="input-group-text bg-white border-end-0 text-muted"
style="font-size: 0.75rem;">개수</span>
<input type="number" id="presetCountInput" class="form-control border-start-0 text-center"
value="10" min="1" max="10" style="font-size: 0.8rem;">
</div>
</div>
<button type="button" id="btnApplyPreset" class="btn btn-sm btn-outline-primary rounded-pill px-3"
style="font-size: 0.75rem;">
적용
</button>
</div>
</div>
<!-- 오른쪽: 시각화 및 확인 -->
<div class="col-lg-7 p-4 bg-light d-flex flex-column">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold text-secondary mb-0 small text-uppercase">
<i class="bi bi-sort-numeric-down me-1"></i>적용 순서
</h6>
<span class="badge bg-white text-dark border rounded-pill px-3 py-1" id="slotCountDisplay">0개</span>
</div>
<!-- 미리보기 영역 -->
<div class="flex-grow-1 bg-white border rounded-3 p-4 shadow-sm mb-4 position-relative"
style="min-height: 250px; max-height: 400px; overflow-y: auto;">
<div id="slotPreview" class="d-flex flex-wrap gap-2 align-content-start h-100">
<!-- Empty State -->
<div
class="d-flex flex-column align-items-center justify-content-center w-100 h-100 text-muted opacity-50">
<i class="bi bi-layers fs-1 mb-2"></i>
<span class="small">프리셋을 선택하거나 번호를 입력하세요.</span>
</div>
</div>
</div>
<div class="mt-auto">
<div class="d-flex align-items-center mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>
<span class="small text-muted">입력된 순서대로 <strong>GUID 컬럼</strong><strong>슬롯 데이터</strong>
정렬됩니다.</span>
</div>
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold shadow-sm">
설정 확인 및 변환
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- 중복 파일 확인 모달 -->
<div class="modal fade" id="duplicateCheckModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header border-bottom-0 pb-0">
<h5 class="modal-title fw-bold text-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>중복 파일 발견
</h5>
</div>
<div class="modal-body pt-3">
<p class="text-secondary mb-3">
대상 폴더에 이미 동일한 이름의 파일이 <strong id="dupCount" class="text-dark">0</strong>개 존재합니다.<br>
덮어쓰시겠습니까?
</p>
<div class="bg-light rounded p-3 mb-3 border font-monospace text-muted small" style="max-height: 150px; overflow-y: auto;">
<ul id="dupList" class="list-unstyled mb-0">
<!-- JS로 주입됨 -->
</ul>
<div id="dupMore" class="text-center mt-2 fst-italic display-none" style="display:none;">...외 <span id="dupMoreCount">0</span></div>
</div>
<p class="small text-muted mb-0">
<i class="bi bi-info-circle me-1"></i>덮어쓰기를 선택하면 기존 파일은 삭제됩니다.
</p>
</div>
<div class="modal-footer border-top-0 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-warning text-white fw-bold" id="btnConfirmOverwrite">
<i class="bi bi-arrow-repeat me-1"></i>덮어쓰기 (Overwrite)
</button>
</div>
</div>
</div>
</div>
<!-- Styles moved to index_custom.css -->
<!-- Scripts moved to index_custom.js -->
{% endblock %}

View File

@@ -0,0 +1,705 @@
/**
* dashboard.js
* 통합된 대시보드 관리 스크립트
* (script.js + index.js + index_custom.js 통합)
*/
document.addEventListener('DOMContentLoaded', () => {
// ─────────────────────────────────────────────────────────────
// 1. 공통 유틸리티 & 설정
// ─────────────────────────────────────────────────────────────
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value || '';
// 진행바 업데이트 (전역 함수로 등록하여 다른 곳에서 호출 가능)
window.updateProgress = function (val) {
const bar = document.getElementById('progressBar');
if (!bar) return;
const v = Math.max(0, Math.min(100, Number(val) || 0));
// 부모 컨테이너가 숨겨져 있다면 표시
const progressContainer = bar.closest('.progress');
if (progressContainer && progressContainer.parentElement.classList.contains('d-none')) {
progressContainer.parentElement.classList.remove('d-none');
}
bar.style.width = v + '%';
bar.setAttribute('aria-valuenow', v);
bar.innerHTML = `<span class="fw-semibold small">${v}%</span>`;
// 100% 도달 시 애니메이션 효과 제어 등은 필요 시 추가
};
// 줄 수 카운터 (script.js에서 가져옴)
function updateLineCount(textareaId, badgeId) {
const textarea = document.getElementById(textareaId);
const badge = document.getElementById(badgeId);
if (!textarea || !badge) return;
const updateCount = () => {
const text = textarea.value.trim();
if (text === '') {
badge.textContent = '0';
return;
}
// 빈 줄 제외하고 카운트
const lines = text.split('\n').filter(line => line.trim().length > 0);
badge.textContent = lines.length; // UI 간소화를 위해 '줄' 텍스트 제외하거나 포함 가능
};
updateCount();
['input', 'change', 'keyup', 'paste'].forEach(evt => {
textarea.addEventListener(evt, () => setTimeout(updateCount, 10));
});
}
// 초기화
updateLineCount('ips', 'ipLineCount');
updateLineCount('server_list_content', 'serverLineCount');
// 알림 자동 닫기
setTimeout(() => {
document.querySelectorAll('.alert').forEach(alert => {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
});
}, 5000);
// ─────────────────────────────────────────────────────────────
// 2. IP 처리 및 스캔 로직
// ─────────────────────────────────────────────────────────────
// 2-1. 스크립트 선택 시 XML 드롭다운 토글
const TARGET_SCRIPT = "02-set_config.py";
const scriptSelect = document.getElementById('script');
const xmlGroup = document.getElementById('xmlFileGroup');
function toggleXml() {
if (!scriptSelect || !xmlGroup) return;
if (scriptSelect.value === TARGET_SCRIPT) {
xmlGroup.style.display = 'block';
xmlGroup.classList.add('fade-in');
} else {
xmlGroup.style.display = 'none';
}
}
if (scriptSelect) {
// TomSelect 적용 전/후 모두 대응하기 위해 이벤트 리스너 등록
toggleXml();
scriptSelect.addEventListener('change', toggleXml);
// TomSelect 초기화
new TomSelect("#script", {
create: false,
sortField: { field: "text", direction: "asc" },
placeholder: "스크립트를 검색하거나 선택하세요...",
plugins: ['clear_button'],
allowEmptyOption: true,
onChange: toggleXml // TomSelect 변경 시에도 호출
});
}
// 2-2. IP 입력 데이터 보존 (Local Storage)
const ipTextarea = document.getElementById('ips');
const STORAGE_KEY_IP = 'ip_input_draft';
if (ipTextarea) {
const savedIps = localStorage.getItem(STORAGE_KEY_IP);
if (savedIps) {
ipTextarea.value = savedIps;
// 강제 이벤트 트리거하여 줄 수 카운트 업데이트
ipTextarea.dispatchEvent(new Event('input'));
}
ipTextarea.addEventListener('input', () => {
localStorage.setItem(STORAGE_KEY_IP, ipTextarea.value);
});
}
// 2-3. IP 스캔 (Modal / AJAX)
const btnScan = document.getElementById('btnStartScan');
if (btnScan) {
btnScan.addEventListener('click', async () => {
const startIp = '10.10.0.2';
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) {
// 이미 존재하는 row 등을 찾아서 보여주기 (필요시)
}
window.updateProgress(100); // 스캔 중임을 알리기 위해 꽉 채움 (애니메이션)
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressBar.textContent = 'IP 스캔 중...';
}
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 })
});
if (res.redirected) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.reload();
return;
}
const contentType = res.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const text = await res.text();
throw new Error(`서버 응답 오류: ${text.substring(0, 100)}...`);
}
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) {
window.updateProgress(0);
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
}
});
}
// 2-4. IP 지우기 버튼
const btnClear = document.getElementById('btnClearIps');
if (btnClear) {
btnClear.addEventListener('click', () => {
const ipsTextarea = document.getElementById('ips');
if (ipsTextarea && confirm('입력된 IP 목록을 모두 지우시겠습니까?')) {
ipsTextarea.value = '';
ipsTextarea.dispatchEvent(new Event('input'));
}
});
}
// 2-5. 메인 IP 폼 제출 및 진행률 폴링 (script.js 로직)
const ipForm = document.getElementById("ipForm");
if (ipForm) {
ipForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const formData = new FormData(ipForm);
const btn = ipForm.querySelector('button[type="submit"]');
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>처리 중...';
// 진행바 초기화
window.updateProgress(0);
try {
const res = await fetch(ipForm.action, {
method: "POST",
body: formData
});
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
if (data.job_id) {
// 비동기 작업 시작됨 -> 폴링 시작
pollProgress(data.job_id);
} else {
// 동기 처리 완료
window.updateProgress(100);
setTimeout(() => location.reload(), 1000);
}
} catch (err) {
console.error("처리 중 오류:", err);
alert("처리 중 오류 발생: " + err.message);
btn.disabled = false;
btn.innerHTML = originalHtml;
}
});
}
// 폴링 함수
function pollProgress(jobId) {
const interval = setInterval(async () => {
try {
const res = await fetch(`/progress_status/${jobId}`);
if (!res.ok) {
clearInterval(interval);
return;
}
const data = await res.json();
if (data.progress !== undefined) {
window.updateProgress(data.progress);
}
if (data.progress >= 100) {
clearInterval(interval);
window.updateProgress(100);
setTimeout(() => location.reload(), 1500);
}
} catch (err) {
console.error('진행률 확인 중 오류:', err);
clearInterval(interval);
}
}, 500);
}
// ─────────────────────────────────────────────────────────────
// 3. 파일 관련 기능 (백업 이동, 파일 보기 등)
// ─────────────────────────────────────────────────────────────
// 3-1. 파일 보기 모달 (index.js)
const modalEl = document.getElementById('fileViewModal');
const titleEl = document.getElementById('fileViewModalLabel');
const contentEl = document.getElementById('fileViewContent');
if (modalEl) {
modalEl.addEventListener('show.bs.modal', async (ev) => {
const btn = ev.relatedTarget;
const folder = btn?.getAttribute('data-folder') || '';
const date = btn?.getAttribute('data-date') || '';
const filename = btn?.getAttribute('data-filename') || '';
titleEl.innerHTML = `<i class="bi bi-file-text me-2"></i>${filename || '파일'}`;
contentEl.textContent = '불러오는 중...';
const params = new URLSearchParams();
if (folder) params.set('folder', folder);
if (date) params.set('date', date);
if (filename) params.set('filename', filename);
try {
const res = await fetch(`/view_file?${params.toString()}`, { cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
contentEl.textContent = data?.content ?? '(빈 파일)';
} catch (e) {
contentEl.textContent = '파일을 불러오지 못했습니다: ' + (e?.message || e);
}
});
}
// 3-2. 백업 파일 드래그 앤 드롭 이동 (index_custom.js)
let selectedItems = new Set();
const backupContainers = document.querySelectorAll('.backup-files-container');
// 다중 선택 처리
document.addEventListener('click', function (e) {
const item = e.target.closest('.backup-file-item');
if (item && !e.target.closest('a') && !e.target.closest('button')) {
if (e.ctrlKey || e.metaKey) {
toggleSelection(item);
} else {
const wasSelected = item.classList.contains('selected');
clearSelection();
if (!wasSelected) toggleSelection(item);
}
} else if (!e.target.closest('.backup-files-container')) {
// 배경 클릭 시 선택 해제? (UX에 따라 결정, 여기선 일단 패스)
}
});
// 빈 공간 클릭 시 선택 해제
document.addEventListener('mousedown', function (e) {
if (!e.target.closest('.backup-file-item') && !e.target.closest('.backup-files-container')) {
clearSelection();
}
});
function toggleSelection(item) {
if (item.classList.contains('selected')) {
item.classList.remove('selected');
selectedItems.delete(item);
} else {
item.classList.add('selected');
selectedItems.add(item);
}
}
function clearSelection() {
document.querySelectorAll('.backup-file-item.selected').forEach(el => el.classList.remove('selected'));
selectedItems.clear();
}
function updateFolderCount(folderDate) {
const container = document.querySelector(`.backup-files-container[data-folder="${folderDate}"]`);
if (container) {
const listItem = container.closest('.list-group-item');
if (listItem) {
const badge = listItem.querySelector('.badge');
if (badge) {
badge.textContent = `${container.children.length} 파일`;
}
}
}
}
// Sortable 초기화
backupContainers.forEach(container => {
if (typeof Sortable === 'undefined') return;
new Sortable(container, {
group: 'backup-files',
animation: 150,
ghostClass: 'sortable-ghost',
delay: 100,
delayOnTouchOnly: true,
onStart: function (evt) {
if (!evt.item.classList.contains('selected')) {
clearSelection();
toggleSelection(evt.item);
}
},
onEnd: function (evt) {
if (evt.to === evt.from) return;
const sourceFolder = evt.from.getAttribute('data-folder');
const targetFolder = evt.to.getAttribute('data-folder');
if (!sourceFolder || !targetFolder) {
alert('잘못된 이동 요청입니다.');
location.reload();
return;
}
let itemsToMove = Array.from(selectedItems);
if (itemsToMove.length === 0) itemsToMove = [evt.item];
else if (!itemsToMove.includes(evt.item)) itemsToMove.push(evt.item);
// UI 상 이동 처리 (Sortable이 하나는 처리해주지만 다중 선택은 직접 옮겨야 함)
itemsToMove.forEach(item => {
if (item !== evt.item) evt.to.appendChild(item);
});
// 서버 요청
if (!window.APP_CONFIG) {
console.error("Window APP_CONFIG not found!");
return;
}
const promises = itemsToMove.map(item => {
const filename = item.getAttribute('data-filename');
if (!filename) return Promise.resolve();
return fetch(window.APP_CONFIG.moveBackupUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': window.APP_CONFIG.csrfToken
},
body: JSON.stringify({
filename: filename,
source_folder: sourceFolder,
target_folder: targetFolder
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
// 속성 업데이트 (다운로드 링크 등)
const btn = item.querySelector('.btn-view-backup');
if (btn) btn.setAttribute('data-date', targetFolder);
const link = item.querySelector('a[download]');
if (link && window.APP_CONFIG.downloadBaseUrl) {
const newHref = window.APP_CONFIG.downloadBaseUrl
.replace('PLACEHOLDER_DATE', targetFolder)
.replace('PLACEHOLDER_FILE', filename);
link.setAttribute('href', newHref);
}
}
return data;
});
});
Promise.all(promises).then(results => {
updateFolderCount(sourceFolder);
updateFolderCount(targetFolder);
clearSelection();
const failed = results.filter(r => r && !r.success);
if (failed.length > 0) {
alert(failed.length + '개의 파일 이동 실패. 새로고침이 필요합니다.');
location.reload();
}
}).catch(err => {
console.error(err);
alert('이동 중 통신 오류 발생');
});
}
});
});
// ─────────────────────────────────────────────────────────────
// 4. 슬롯 우선순위 설정 모달 (index_custom.js)
// ─────────────────────────────────────────────────────────────
const slotPriorityModal = document.getElementById('slotPriorityModal');
if (slotPriorityModal) {
const slotNumbersInput = document.getElementById('slotNumbersInput');
const slotCountDisplay = document.getElementById('slotCountDisplay');
const slotPreview = document.getElementById('slotPreview');
const slotPriorityInput = document.getElementById('slot_priority_input');
const modalServerListContent = document.getElementById('modal_server_list_content');
const serverListTextarea = document.getElementById('server_list_content');
const slotPriorityForm = document.getElementById('slotPriorityForm');
const btnClearSlots = document.getElementById('btnClearSlots');
const presetCountInput = document.getElementById('presetCountInput');
const btnApplyPreset = document.getElementById('btnApplyPreset');
const defaultPriority = ['38', '39', '37', '36', '32', '33', '34', '35', '31', '40'];
function loadSlots() {
const saved = localStorage.getItem('guidSlotNumbers');
slotNumbersInput.value = saved ? saved : defaultPriority.join(', ');
if (presetCountInput) presetCountInput.value = 10;
updateSlotPreview();
}
function saveSlots() {
localStorage.setItem('guidSlotNumbers', slotNumbersInput.value);
}
function parseSlots(input) {
if (!input || !input.trim()) return [];
return input.split(/[,\s\n]+/)
.map(s => s.trim())
.filter(s => s !== '' && /^\d+$/.test(s))
.filter((v, i, a) => a.indexOf(v) === i); // Unique
}
function updateSlotPreview() {
const slots = parseSlots(slotNumbersInput.value);
const count = slots.length;
slotCountDisplay.textContent = `${count}`;
slotCountDisplay.className = count > 0
? 'badge bg-primary text-white border border-primary rounded-pill px-3 py-1'
: 'badge bg-white text-dark border rounded-pill px-3 py-1';
if (count === 0) {
slotPreview.style.display = 'flex';
slotPreview.innerHTML = `
<div class="d-flex flex-column align-items-center justify-content-center w-100 h-100 text-muted opacity-50">
<i class="bi bi-layers fs-1 mb-2"></i>
<span class="small">프리셋을 선택하거나 번호를 입력하세요.</span>
</div>`;
} else {
slotPreview.style.display = 'grid';
slotPreview.innerHTML = slots.map((slot, index) => `
<div class="slot-badge animate__animated animate__fadeIn" data-slot="${slot}" style="animation-delay: ${index * 0.02}s">
<span class="slot-index">${index + 1}</span>
<div>Slot ${slot}</div>
</div>
`).join('');
// 미리보기 Sortable
new Sortable(slotPreview, {
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
onEnd: function () {
const items = slotPreview.querySelectorAll('.slot-badge');
const newSlots = Array.from(items).map(item => item.getAttribute('data-slot'));
slotNumbersInput.value = newSlots.join(', ');
items.forEach((item, index) => {
const idx = item.querySelector('.slot-index');
if (idx) idx.textContent = index + 1;
});
saveSlots();
}
});
}
saveSlots();
}
if (btnApplyPreset) {
btnApplyPreset.addEventListener('click', () => {
let count = parseInt(presetCountInput.value) || 10;
count = Math.max(1, Math.min(10, count));
presetCountInput.value = count;
slotNumbersInput.value = defaultPriority.slice(0, count).join(', ');
updateSlotPreview();
});
}
slotNumbersInput.addEventListener('input', updateSlotPreview);
btnClearSlots.addEventListener('click', () => {
if (confirm('모두 지우시겠습니까?')) {
slotNumbersInput.value = '';
updateSlotPreview();
}
});
slotPriorityModal.addEventListener('show.bs.modal', () => {
if (serverListTextarea) modalServerListContent.value = serverListTextarea.value;
loadSlots();
});
slotPriorityForm.addEventListener('submit', (e) => {
const slots = parseSlots(slotNumbersInput.value);
if (slots.length === 0) {
e.preventDefault();
alert('최소 1개 이상의 슬롯을 입력하세요.');
return;
}
slotPriorityInput.value = slots.join(',');
saveSlots();
});
}
// ─────────────────────────────────────────────────────────────
// 5. Quick Move (중복체크 포함) (index_custom.js)
// ─────────────────────────────────────────────────────────────
const quickMoveForms = ['macMoveForm', 'guidMoveForm', 'gpuMoveForm'];
let pendingAction = null;
const dupModalEl = document.getElementById('duplicateCheckModal');
const dupModal = dupModalEl ? new bootstrap.Modal(dupModalEl) : null;
const btnConfirmOverwrite = document.getElementById('btnConfirmOverwrite');
if (btnConfirmOverwrite) {
btnConfirmOverwrite.addEventListener('click', () => {
if (pendingAction) {
dupModal.hide();
pendingAction(true); // overwrite=true
pendingAction = null;
}
});
}
quickMoveForms.forEach(id => {
const form = document.getElementById(id);
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
const btn = form.querySelector('button[type="submit"]');
if (!btn || btn.disabled) return;
const originalContent = btn.innerHTML;
const executeMove = (overwrite = false) => {
btn.classList.add('disabled');
btn.disabled = true;
btn.innerHTML = '<div class="spinner-border spinner-border-sm text-primary" role="status"></div>';
fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken // 전역 csrfToken 사용
},
body: JSON.stringify({ overwrite: overwrite })
})
.then(async response => {
const ct = response.headers.get("content-type");
if (!ct || !ct.includes("application/json")) {
// HTTP 200이지만 HTML이 올 경우 에러나 마찬가지 (로그인 리다이렉트 등)
if (response.ok && response.url.includes("login")) {
window.location.reload();
return;
}
// "MAC 이동 중 오류: HTTP 200" 등 텍스트일 수 있음
const txt = await response.text();
if (response.ok) {
// 성공으로 간주하고 리로드 (가끔 백엔드가 JSON 대신 빈 성공 응답을 줄 때)
window.location.reload();
return;
}
throw new Error(txt);
}
return response.json();
})
.then(data => {
if (!data) return; // 위에서 처리됨
if (data.requires_confirmation) {
showDuplicateModal(data.duplicates, data.duplicate_count);
pendingAction = executeMove;
resetButton();
} else if (data.success) {
window.location.reload();
} else {
alert(data.error || '작업 실패');
resetButton();
}
})
.catch(err => {
console.error(err);
// HTTP 200 에러 억제 요청 반영
if (err.message && err.message.includes("200")) {
window.location.reload();
return;
}
alert('오류 발생: ' + err);
resetButton();
});
};
const resetButton = () => {
btn.classList.remove('disabled');
btn.disabled = false;
btn.innerHTML = originalContent;
};
executeMove(false);
});
}
});
function showDuplicateModal(duplicates, count) {
const listEl = document.getElementById('dupList');
const countEl = document.getElementById('dupCount');
const moreEl = document.getElementById('dupMore');
const moreCountEl = document.getElementById('dupMoreCount');
if (countEl) countEl.textContent = count;
if (listEl) {
listEl.innerHTML = '';
const limit = 10;
duplicates.slice(0, limit).forEach(name => {
const li = document.createElement('li');
li.innerHTML = `<i class="bi bi-file-earmark text-secondary me-2"></i>${name}`;
listEl.appendChild(li);
});
if (duplicates.length > limit) {
if (moreEl) {
moreEl.style.display = 'block';
moreCountEl.textContent = duplicates.length - limit;
}
} else {
if (moreEl) moreEl.style.display = 'none';
}
}
if (dupModal) dupModal.show();
}
});

View File

@@ -532,3 +532,303 @@ label, .form-label, .card-title, .list-group-item strong {
min-height: calc(1em * var(--btn-line) + (var(--btn-py) * 2)) !important;
box-sizing: border-box;
}
/* ========================================================================== */
/* Imported from index.css & index_custom.css */
/* ========================================================================== */
/* ===== 공통 ?<3F>일 카드 컴팩???<3F><>???===== */
.file-card-compact {
transition: all 0.2s ease;
background: #fff;
min-width: 120px;
max-width: 200px;
}
.file-card-compact:hover {
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.file-card-compact a {
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
/* ===== 목록<EBAAA9><EBA19D>?버튼 분리 규칙 ===== */
/* 처리???<3F>일 목록 ?<3F>용 컨테?<3F>너(보기/??<3F><> 2?? */
.processed-list .file-card-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .5rem;
}
/* 보기(처리?? */
.processed-list .btn-view-processed {
border-color: #3b82f6;
color: #1d4ed8;
padding: .425rem .6rem;
font-size: .8125rem;
font-weight: 600;
}
.processed-list .btn-view-processed:hover {
background: rgba(59, 130, 246, .08);
}
/* ??<3F><>(처리?? ?????<3F>게 */
.processed-list .btn-delete-processed {
border-color: #ef4444;
color: #b91c1c;
padding: .3rem .5rem;
font-size: .75rem;
font-weight: 600;
}
.processed-list .btn-delete-processed:hover {
background: rgba(239, 68, 68, .08);
}
/* 백업 ?<3F>일 목록 ?<3F>용 컨테?<3F>너(?<3F>일 버튼) */
.backup-list .file-card-single-button {
display: flex;
margin-top: .25rem;
}
/* 보기(백업) ??강조 ?<3F>상 */
.backup-list .btn-view-backup {
width: 100%;
border-color: #10b981;
color: #047857;
padding: .45rem .75rem;
font-size: .8125rem;
font-weight: 700;
}
.backup-list .btn-view-backup:hover {
background: rgba(16, 185, 129, .08);
}
/* ===== 백업 ?<3F>일 ?<3F>짜 ?<3F>더 ===== */
.list-group-item .bg-light {
transition: background-color 0.2s ease;
}
.list-group-item:hover .bg-light {
background-color: #e9ecef !important;
}
/* ===== 진행<ECA784><ED9689>??<3F>니메이??===== */
.progress {
border-radius: 10px;
overflow: hidden;
}
.progress-bar {
transition: width 0.6s ease;
}
/* ===== 반응???<3F>스??===== */
@media (max-width: 768px) {
.card-body {
padding: 1.5rem !important;
}
}
/* ===== ?<3F>크롤바 ?<3F><>??<3F>링(모달) ===== */
.modal-body pre::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.modal-body pre::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.modal-body pre::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.modal-body pre::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* 백업 ?<3F>일 ?<3F>중 ?<3F>택 ?<3F><>???*/
.backup-file-item.selected .file-card-compact {
border-color: #0d6efd !important;
background-color: #e7f1ff !important;
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
}
.file-card-compact {
transition: all 0.2s ease-in-out;
cursor: pointer;
}
/* 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 버튼 ?<3F>버 ?<3F>과 */
.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);
}
/* Modern Minimalist Styles */
.hover-bg-light {
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.hover-bg-light:hover {
background-color: #f8f9fa !important;
border-color: #dee2e6 !important;
}
/* Slot Badge - Clean & Flat */
.slot-badge {
position: relative;
padding: 0.4rem 0.8rem 0.4rem 2rem;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 9999px;
/* Pill shape */
color: #374151;
font-weight: 600;
font-family: monospace;
font-size: 0.85rem;
transition: all 0.2s ease;
user-select: none;
display: flex;
align-items: center;
}
.slot-badge:hover {
border-color: #3b82f6;
/* Primary Blue */
color: #3b82f6;
}
.slot-index {
position: absolute;
left: 0.35rem;
width: 1.3rem;
height: 1.3rem;
background: #f3f4f6;
color: #6b7280;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
}
/* Grid Layout for Preview */
#slotPreview {
display: grid !important;
grid-template-columns: repeat(5, 1fr);
/* 5 items per line */
gap: 0.5rem;
align-content: start;
}
/* Slot Badge - Draggable & Card-like */
.slot-badge {
position: relative;
padding: 0.5rem 0.2rem;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
color: #374151;
font-weight: 600;
font-family: monospace;
font-size: 0.75rem;
/* Reduced font size */
transition: all 0.2s ease;
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: grab;
height: 100%;
min-height: 80px;
overflow: hidden;
/* Prevent overflow */
word-break: break-all;
/* Ensure wrapping if needed */
}
.slot-badge:active {
cursor: grabbing;
}
.slot-badge:hover {
border-color: #3b82f6;
color: #3b82f6;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.slot-index {
background: #f3f4f6;
color: #6b7280;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.slot-badge:hover .slot-index {
background: #eff6ff;
color: #3b82f6;
}
/* Dragging state */
.sortable-ghost {
opacity: 0.4;
background: #e2e8f0;
border: 1px dashed #94a3b8;
}
.sortable-drag {
opacity: 1;
background: #fff;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
transform: scale(1.02);
}

View File

@@ -9,11 +9,21 @@
display: flex;
flex-direction: column;
height: 600px;
/* 초기 높이 */
min-height: 300px;
/* 최소 높이 */
max-height: 1200px;
/* 최대 높이 (선택 사항) */
background: #1e1e1e;
border: 1px solid #333;
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
overflow: hidden;
/* resize를 위해 필수 */
resize: vertical;
/* 수직 리사이징 활성화 */
position: relative;
/* 자식 요소 relative 기준 */
}
/* 툴바 (헤더) */

View File

@@ -16,8 +16,7 @@
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
integrity="sha384-tViUnnbYAV00FLIhhi3v/dWt3Jxw4gZQcNoSCxCIFNJVCx7/D55/wXsrNIRANwdD" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">

View File

@@ -131,7 +131,7 @@
</button>
</div>
<div class="col-4">
<button type="submit" formaction="{{ url_for('utils.update_guid_list') }}"
<button type="button" data-bs-toggle="modal" data-bs-target="#slotPriorityModal"
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>
@@ -300,9 +300,12 @@
<div class="col">
<div class="card border shadow-sm">
<div class="card-header bg-light border-0 py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<h6 class="mb-0 d-flex align-items-center">
<i class="bi bi-files me-2"></i>
처리된 파일 목록
{% if files_to_display %}
<span class="badge bg-primary ms-3">{{ files_to_display|length }} 파일</span>
{% endif %}
</h6>
</div>
<div class="card-body p-4">
@@ -419,10 +422,10 @@
</div>
<div id="collapse-{{ loop.index }}" class="collapse">
<div class="p-3">
<div class="row g-3">
<div class="row g-3 backup-files-container" data-folder="{{ date }}" style="min-height: 50px;">
{% for file in info.files %}
<div class="col-auto">
<div class="file-card-compact border rounded p-2 text-center">
<div class="col-auto backup-file-item" data-filename="{{ file }}">
<div class="file-card-compact border rounded p-2 text-center bg-white">
<a href="{{ url_for('main.download_backup_file', date=date, filename=file) }}"
class="text-decoration-none text-dark fw-semibold d-block mb-2 text-nowrap px-2" download
title="{{ file }}">
@@ -444,6 +447,50 @@
</div>
{% endfor %}
</div>
<!-- 백업 목록 페이지네이션 -->
{% if total_backup_pages > 1 %}
<nav aria-label="Backup pagination" class="mt-4">
<ul class="pagination justify-content-center mb-0">
<!-- 이전 페이지 -->
{% if backup_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.index', backup_page=backup_page-1, page=page) }}">
<i class="bi bi-chevron-left"></i> 이전
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i> 이전</span>
</li>
{% endif %}
<!-- 페이지 번호 -->
{% set start_b_page = ((backup_page - 1) // 10) * 10 + 1 %}
{% set end_b_page = [start_b_page + 9, total_backup_pages]|min %}
{% for p in range(start_b_page, end_b_page + 1) %}
<li class="page-item {% if p == backup_page %}active{% endif %}">
<a class="page-link" href="{{ url_for('main.index', backup_page=p, page=page) }}">{{ p }}</a>
</li>
{% endfor %}
<!-- 다음 페이지 -->
{% if backup_page < total_backup_pages %} <li class="page-item">
<a class="page-link" href="{{ url_for('main.index', backup_page=backup_page+1, page=page) }}">
다음 <i class="bi bi-chevron-right"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">다음 <i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox fs-1 text-muted mb-3"></i>
@@ -484,43 +531,8 @@
{% 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 %}
@@ -529,27 +541,164 @@
<!-- 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,
});
}
});
window.APP_CONFIG = {
moveBackupUrl: "{{ url_for('main.move_backup_files') }}",
csrfToken: "{{ csrf_token() }}",
downloadBaseUrl: "{{ url_for('main.download_backup_file', date='PLACEHOLDER_DATE', filename='PLACEHOLDER_FILE') }}"
};
</script>
<script src="{{ url_for('static', filename='js/dashboard.js') }}?v={{ range(1, 100000) | random }}"></script>
<script src="{{ url_for('static', filename='js/index.js') }}?v={{ range(1, 100000) | random }}"></script>
<!-- 외부 script.js 파일 (IP 폼 처리 로직 포함) -->
<script src="{{ url_for('static', filename='script.js') }}?v={{ range(1, 100000) | random }}"></script>
<!-- SortableJS for Drag and Drop -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<!-- 슬롯 우선순위 설정 모달 (Premium Design) -->
<div class="modal fade" id="slotPriorityModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content border-0 shadow-lg" style="border-radius: 1rem; overflow: hidden;">
<!-- 헤더: 깔끔한 모던 스타일 -->
<div class="modal-header border-bottom p-4 bg-white">
<div>
<h5 class="modal-title fw-bold text-dark mb-1">
<i class="bi bi-layers text-primary me-2"></i>GUID 슬롯 우선순위 설정
</h5>
<p class="mb-0 text-muted" style="font-size: 0.85rem;">
엑셀 변환 시 적용될 슬롯의 순서를 설정합니다.
</p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="닫기"></button>
</div>
<div class="modal-body p-0 bg-light">
<form id="slotPriorityForm" action="{{ url_for('utils.update_guid_list') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="server_list_content" id="modal_server_list_content">
<input type="hidden" name="slot_priority" id="slot_priority_input">
<div class="row g-0">
<!-- 왼쪽: 입력 및 프리셋 -->
<div class="col-lg-5 border-end bg-white p-4 d-flex flex-column">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-bold text-dark mb-0 small text-uppercase">
<i class="bi bi-keyboard me-1"></i>슬롯 번호 입력
</h6>
</div>
<div class="position-relative flex-grow-1">
<textarea id="slotNumbersInput"
class="form-control bg-light border-0 font-monospace p-3 text-dark h-100"
style="resize: none; font-size: 0.9rem; min-height: 200px;"
placeholder="슬롯 번호를 입력하세요.&#10;구분자: 쉼표(,) 공백( ) 줄바꿈&#10;&#10;예시: 38, 39, 37"></textarea>
<div class="position-absolute bottom-0 end-0 p-2">
<button type="button" id="btnClearSlots" class="btn btn-sm btn-link text-decoration-none text-muted"
style="font-size: 0.75rem;">
<i class="bi bi-x-circle me-1"></i>지우기
</button>
</div>
</div>
<!-- 미니멀한 프리셋 설정 (숫자 입력) -->
<div class="mt-3 pt-3 border-top border-light d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<span class="small text-muted me-2" style="font-size: 0.75rem;">카드 개수 설정:</span>
<div class="input-group input-group-sm" style="width: 120px;">
<span class="input-group-text bg-white border-end-0 text-muted"
style="font-size: 0.75rem;">개수</span>
<input type="number" id="presetCountInput" class="form-control border-start-0 text-center"
value="10" min="1" max="10" style="font-size: 0.8rem;">
</div>
</div>
<button type="button" id="btnApplyPreset" class="btn btn-sm btn-outline-primary rounded-pill px-3"
style="font-size: 0.75rem;">
적용
</button>
</div>
</div>
<!-- 오른쪽: 시각화 및 확인 -->
<div class="col-lg-7 p-4 bg-light d-flex flex-column">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold text-secondary mb-0 small text-uppercase">
<i class="bi bi-sort-numeric-down me-1"></i>적용 순서
</h6>
<span class="badge bg-white text-dark border rounded-pill px-3 py-1" id="slotCountDisplay">0개</span>
</div>
<!-- 미리보기 영역 -->
<div class="flex-grow-1 bg-white border rounded-3 p-4 shadow-sm mb-4 position-relative"
style="min-height: 250px; max-height: 400px; overflow-y: auto;">
<div id="slotPreview" class="d-flex flex-wrap gap-2 align-content-start h-100">
<!-- Empty State -->
<div
class="d-flex flex-column align-items-center justify-content-center w-100 h-100 text-muted opacity-50">
<i class="bi bi-layers fs-1 mb-2"></i>
<span class="small">프리셋을 선택하거나 번호를 입력하세요.</span>
</div>
</div>
</div>
<div class="mt-auto">
<div class="d-flex align-items-center mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>
<span class="small text-muted">입력된 순서대로 <strong>GUID 컬럼</strong><strong>슬롯 데이터</strong>
정렬됩니다.</span>
</div>
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold shadow-sm">
설정 확인 및 변환
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- 중복 파일 확인 모달 -->
<div class="modal fade" id="duplicateCheckModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header border-bottom-0 pb-0">
<h5 class="modal-title fw-bold text-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>중복 파일 발견
</h5>
</div>
<div class="modal-body pt-3">
<p class="text-secondary mb-3">
대상 폴더에 이미 동일한 이름의 파일이 <strong id="dupCount" class="text-dark">0</strong>개 존재합니다.<br>
덮어쓰시겠습니까?
</p>
<div class="bg-light rounded p-3 mb-3 border font-monospace text-muted small"
style="max-height: 150px; overflow-y: auto;">
<ul id="dupList" class="list-unstyled mb-0">
<!-- JS로 주입됨 -->
</ul>
<div id="dupMore" class="text-center mt-2 fst-italic display-none" style="display:none;">...외 <span
id="dupMoreCount">0</span></div>
</div>
<p class="small text-muted mb-0">
<i class="bi bi-info-circle me-1"></i>덮어쓰기를 선택하면 기존 파일은 삭제됩니다.
</p>
</div>
<div class="modal-footer border-top-0 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-warning text-white fw-bold" id="btnConfirmOverwrite">
<i class="bi bi-arrow-repeat me-1"></i>덮어쓰기 (Overwrite)
</button>
</div>
</div>
</div>
</div>
<!-- Styles moved to index_custom.css -->
<!-- Scripts moved to index_custom.js -->
{% endblock %}