update
This commit is contained in:
@@ -5,19 +5,24 @@
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Admin Page</h3>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="card-title mb-0">Admin Page</h3>
|
||||
<a href="{{ url_for('admin.settings') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-gear-fill me-1"></i>시스템 설정
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mt-2">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if messages %}
|
||||
<div class="mt-2">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="table-responsive">
|
||||
@@ -39,28 +44,24 @@
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="badge bg-success">Yes</span>
|
||||
<span class="badge bg-success">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">No</span>
|
||||
<span class="badge bg-secondary">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not user.is_active %}
|
||||
<a href="{{ url_for('admin.approve_user', user_id=user.id) }}" class="btn btn-success btn-sm me-1">Approve</a>
|
||||
<a href="{{ url_for('admin.approve_user', user_id=user.id) }}"
|
||||
class="btn btn-success btn-sm me-1">Approve</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.delete_user', user_id=user.id) }}"
|
||||
class="btn btn-danger btn-sm me-1"
|
||||
onclick="return confirm('사용자 {{ user.username }} (ID={{ user.id }}) 를 삭제하시겠습니까?');">
|
||||
Delete
|
||||
<a href="{{ url_for('admin.delete_user', user_id=user.id) }}" class="btn btn-danger btn-sm me-1"
|
||||
onclick="return confirm('사용자 {{ user.username }} (ID={{ user.id }}) 를 삭제하시겠습니까?');">
|
||||
Delete
|
||||
</a>
|
||||
|
||||
<!-- Change Password 버튼: 모달 오픈 -->
|
||||
<button type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
data-user-id="{{ user.id }}"
|
||||
data-username="{{ user.username | e }}"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#changePasswordModal">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-user-id="{{ user.id }}"
|
||||
data-username="{{ user.username | e }}" data-bs-toggle="modal" data-bs-target="#changePasswordModal">
|
||||
Change Password
|
||||
</button>
|
||||
</td>
|
||||
@@ -74,7 +75,8 @@
|
||||
</div>
|
||||
|
||||
{# ========== Change Password Modal ========== #}
|
||||
<div class="modal fade" id="changePasswordModal" tabindex="-1" aria-labelledby="changePasswordModalLabel" aria-hidden="true">
|
||||
<div class="modal fade" id="changePasswordModal" tabindex="-1" aria-labelledby="changePasswordModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<form id="changePasswordForm" method="post" action="">
|
||||
@@ -92,13 +94,15 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="newPasswordInput" class="form-label">New password</label>
|
||||
<input id="newPasswordInput" name="new_password" type="password" class="form-control" required minlength="8" placeholder="Enter new password">
|
||||
<input id="newPasswordInput" name="new_password" type="password" class="form-control" required minlength="8"
|
||||
placeholder="Enter new password">
|
||||
<div class="form-text">최소 8자 이상을 권장합니다.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="confirmPasswordInput" class="form-label">Confirm password</label>
|
||||
<input id="confirmPasswordInput" name="confirm_password" type="password" class="form-control" required minlength="8" placeholder="Confirm new password">
|
||||
<input id="confirmPasswordInput" name="confirm_password" type="password" class="form-control" required
|
||||
minlength="8" placeholder="Confirm new password">
|
||||
<div id="pwMismatch" class="invalid-feedback">비밀번호가 일치하지 않습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,58 +116,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ========== 스크립트: 모달에 사용자 정보 채우기 + 클라이언트 확인 ========== #}
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
(function () {
|
||||
// Bootstrap 5을 사용한다고 가정. data-bs-* 이벤트로 처리.
|
||||
const changePasswordModal = document.getElementById('changePasswordModal');
|
||||
const modalUserInfo = document.getElementById('modalUserInfo');
|
||||
const changePasswordForm = document.getElementById('changePasswordForm');
|
||||
const newPasswordInput = document.getElementById('newPasswordInput');
|
||||
const confirmPasswordInput = document.getElementById('confirmPasswordInput');
|
||||
const pwMismatch = document.getElementById('pwMismatch');
|
||||
|
||||
if (!changePasswordModal) return;
|
||||
|
||||
changePasswordModal.addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget; // 버튼 that triggered the modal
|
||||
const userId = button.getAttribute('data-user-id');
|
||||
const username = button.getAttribute('data-username') || ('ID ' + userId);
|
||||
|
||||
// 표시 텍스트 세팅
|
||||
modalUserInfo.textContent = username + ' (ID: ' + userId + ')';
|
||||
|
||||
// 폼 action 동적 설정: admin.reset_password 라우트 기대
|
||||
// 예: /admin/users/123/reset_password
|
||||
changePasswordForm.action = "{{ url_for('admin.reset_password', user_id=0) }}".replace('/0/', '/' + userId + '/');
|
||||
// 폼 내부 비밀번호 필드 초기화
|
||||
newPasswordInput.value = '';
|
||||
confirmPasswordInput.value = '';
|
||||
confirmPasswordInput.classList.remove('is-invalid');
|
||||
pwMismatch.style.display = 'none';
|
||||
});
|
||||
|
||||
// 폼 제출 전 클라이언트에서 비밀번호 일치 검사
|
||||
changePasswordForm.addEventListener('submit', function (e) {
|
||||
const a = newPasswordInput.value || '';
|
||||
const b = confirmPasswordInput.value || '';
|
||||
if (a.length < 8) {
|
||||
newPasswordInput.focus();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (a !== b) {
|
||||
e.preventDefault();
|
||||
confirmPasswordInput.classList.add('is-invalid');
|
||||
pwMismatch.style.display = 'block';
|
||||
confirmPasswordInput.focus();
|
||||
return;
|
||||
}
|
||||
// 제출 허용 (서버측에서도 반드시 검증)
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
{{ super() }}
|
||||
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
320
backend/templates/admin_settings.html
Normal file
320
backend/templates/admin_settings.html
Normal file
@@ -0,0 +1,320 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}시스템 설정 - Dell Server Info{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin_settings.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold mb-0">
|
||||
<i class="bi bi-gear-fill text-primary me-2"></i>시스템 설정
|
||||
</h2>
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>관리자 패널로 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 텔레그램 봇 설정 섹션 -->
|
||||
<div class="card border shadow-sm mb-4">
|
||||
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 fw-bold">
|
||||
<i class="bi bi-telegram text-info me-2"></i>텔레그램 봇 관리
|
||||
</h5>
|
||||
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addBotModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>봇 추가
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info d-flex align-items-center" role="alert">
|
||||
<i class="bi bi-info-circle-fill me-2 flex-shrink-0 fs-5"></i>
|
||||
<div>
|
||||
등록된 모든 <strong>활성(Active)</strong> 봇에게 알림이 동시에 전송됩니다.<br>
|
||||
<small class="text-muted">알림 종류: 로그인, 회원가입, 로그아웃, 서버 작업 등</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if bots %}
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-4">
|
||||
{% for bot in bots %}
|
||||
<div class="col">
|
||||
<div class="card h-100 border-0 shadow-sm hover-shadow transition-all">
|
||||
<div
|
||||
class="card-header bg-white border-0 pt-3 pb-0 d-flex justify-content-between align-items-start">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-circle bg-primary bg-opacity-10 text-primary me-2 rounded-circle d-flex align-items-center justify-content-center"
|
||||
style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-robot fs-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="card-title fw-bold mb-0 text-truncate" style="max-width: 150px;"
|
||||
title="{{ bot.name }}">
|
||||
{{ bot.name }}
|
||||
</h5>
|
||||
<small class="text-muted" style="font-size: 0.75rem;">ID: {{ bot.id }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% if bot.is_active %}
|
||||
<span
|
||||
class="badge bg-success-subtle text-success border border-success-subtle rounded-pill px-2">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>Active
|
||||
</span>
|
||||
{% else %}
|
||||
<span
|
||||
class="badge bg-secondary-subtle text-secondary border border-secondary-subtle rounded-pill px-2">
|
||||
<i class="bi bi-dash-circle me-1"></i>Inactive
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<p class="card-text text-muted small mb-3 text-truncate-2" style="min-height: 2.5em;">
|
||||
{{ bot.description or "설명이 없습니다." }}
|
||||
</p>
|
||||
|
||||
<div class="bg-light rounded p-2 mb-2">
|
||||
<div class="d-flex align-items-center text-secondary small mb-1">
|
||||
<i class="bi bi-key me-2 text-primary"></i>
|
||||
<span class="fw-semibold me-1">Token:</span>
|
||||
<span class="font-monospace text-truncate">{{ bot.token[:10] }}...</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center text-secondary small">
|
||||
<i class="bi bi-chat-dots me-2 text-success"></i>
|
||||
<span class="fw-semibold me-1">Chat ID:</span>
|
||||
<span class="font-monospace">{{ bot.chat_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{% set types = (bot.notification_types or "").split(",") %}
|
||||
{% if 'auth' in types %}
|
||||
<span
|
||||
class="badge bg-info-subtle text-info border border-info-subtle rounded-pill me-1"><i
|
||||
class="bi bi-shield-lock-fill me-1"></i>인증</span>
|
||||
{% endif %}
|
||||
{% if 'activity' in types %}
|
||||
<span
|
||||
class="badge bg-warning-subtle text-warning border border-warning-subtle rounded-pill me-1"><i
|
||||
class="bi bi-activity me-1"></i>활동</span>
|
||||
{% endif %}
|
||||
{% if 'system' in types %}
|
||||
<span
|
||||
class="badge bg-danger-subtle text-danger border border-danger-subtle rounded-pill me-1"><i
|
||||
class="bi bi-gear-fill me-1"></i>시스템</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-white border-0 pt-0 pb-3">
|
||||
<div class="btn-group w-100 shadow-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal"
|
||||
data-bs-target="#editBotModal{{ bot.id }}">
|
||||
<i class="bi bi-pencil me-1"></i>수정
|
||||
</button>
|
||||
|
||||
<button type="submit" form="testForm{{ bot.id }}"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-send me-1"></i>테스트
|
||||
</button>
|
||||
|
||||
<button type="submit" form="deleteForm{{ bot.id }}"
|
||||
class="btn btn-outline-danger btn-sm" onclick="return confirm('정말 삭제하시겠습니까?');">
|
||||
<i class="bi bi-trash me-1"></i>삭제
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden Forms -->
|
||||
<form id="testForm{{ bot.id }}" action="{{ url_for('admin.test_bot', bot_id=bot.id) }}"
|
||||
method="post" class="d-none">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
<form id="deleteForm{{ bot.id }}" action="{{ url_for('admin.delete_bot', bot_id=bot.id) }}"
|
||||
method="post" class="d-none">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수정 모달 -->
|
||||
<div class="modal fade" id="editBotModal{{ bot.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<form action="{{ url_for('admin.edit_bot', bot_id=bot.id) }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header bg-light">
|
||||
<h5 class="modal-title fw-bold">
|
||||
<i class="bi bi-pencil-square me-2"></i>봇 설정 수정
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">이름 (식별용)</label>
|
||||
<input type="text" class="form-control" name="name" value="{{ bot.name }}"
|
||||
required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Bot Token</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||
<input type="text" class="form-control font-monospace" name="token"
|
||||
value="{{ bot.token }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Chat ID</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-chat-dots"></i></span>
|
||||
<input type="text" class="form-control font-monospace" name="chat_id"
|
||||
value="{{ bot.chat_id }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">알림 유형</label>
|
||||
<div class="d-flex gap-3">
|
||||
{% set types = (bot.notification_types or "").split(",") %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="notify_types"
|
||||
value="auth" id="edit_auth_{{ bot.id }}" {{ 'checked' if 'auth' in
|
||||
types else '' }}>
|
||||
<label class="form-check-label" for="edit_auth_{{ bot.id }}">
|
||||
인증
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="notify_types"
|
||||
value="activity" id="edit_activity_{{ bot.id }}" {{ 'checked'
|
||||
if 'activity' in types else '' }}>
|
||||
<label class="form-check-label" for="edit_activity_{{ bot.id }}">
|
||||
활동
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="notify_types"
|
||||
value="system" id="edit_system_{{ bot.id }}" {{ 'checked'
|
||||
if 'system' in types else '' }}>
|
||||
<label class="form-check-label" for="edit_system_{{ bot.id }}">
|
||||
시스템
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">선택한 알림 유형만 전송됩니다</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">설명</label>
|
||||
<textarea class="form-control" name="description"
|
||||
rows="2">{{ bot.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="is_active"
|
||||
id="activeCheck{{ bot.id }}" {{ 'checked' if bot.is_active else '' }}>
|
||||
<label class="form-check-label" for="activeCheck{{ bot.id }}">
|
||||
활성화
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-primary px-4">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<div class="mb-3">
|
||||
<div class="d-inline-flex align-items-center justify-content-center bg-light rounded-circle"
|
||||
style="width: 80px; height: 80px;">
|
||||
<i class="bi bi-robot fs-1 text-secondary"></i>
|
||||
</div>
|
||||
</div>
|
||||
<h5 class="text-muted fw-normal">등록된 텔레그램 봇이 없습니다.</h5>
|
||||
<p class="text-muted small mb-4">우측 상단의 '봇 추가' 버튼을 눌러 알림을 설정하세요.</p>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addBotModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<form action="{{ url_for('admin.add_bot') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header bg-light">
|
||||
<h5 class="modal-title fw-bold">
|
||||
<i class="bi bi-plus-circle me-2"></i>새 텔레그램 봇 추가
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">이름 (식별용)</label>
|
||||
<input type="text" class="form-control" name="name" placeholder="예: 알림용 봇"
|
||||
required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Bot Token</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||
<input type="text" class="form-control font-monospace" name="token"
|
||||
placeholder="123456:ABC..." required>
|
||||
</div>
|
||||
<div class="form-text">BotFather에게 받은 API Token</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Chat ID</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-chat-dots"></i></span>
|
||||
<input type="text" class="form-control font-monospace" name="chat_id"
|
||||
placeholder="-100..." required>
|
||||
</div>
|
||||
<div class="form-text">메시지를 받을 채팅방 ID (그룹은 음수)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">알림 유형</label>
|
||||
<div class="d-flex gap-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="notify_types"
|
||||
value="auth" id="add_auth" checked>
|
||||
<label class="form-check-label" for="add_auth">
|
||||
인증
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="notify_types"
|
||||
value="activity" id="add_activity" checked>
|
||||
<label class="form-check-label" for="add_activity">
|
||||
활동
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="notify_types"
|
||||
value="system" id="add_system" checked>
|
||||
<label class="form-check-label" for="add_system">
|
||||
시스템
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">선택한 알림 유형만 전송됩니다</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">설명</label>
|
||||
<textarea class="form-control" name="description" rows="2"
|
||||
placeholder="선택 사항"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-primary px-4">추가</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,35 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="description" content="Dell Server 정보 및 MAC 주소 처리 시스템">
|
||||
|
||||
|
||||
<title>{% block title %}Dell Server Info, MAC 정보 처리{% endblock %}</title>
|
||||
|
||||
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
|
||||
|
||||
<!-- Bootstrap 5.3.3 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
|
||||
crossorigin="anonymous">
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
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"
|
||||
integrity="sha384-tViUnnbYAV00FLIhhi3v/dWt3Jxw4gZQcNoSCxCIFNJVCx7/D55/wXsrNIRANwdD" crossorigin="anonymous">
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Skip to main content (접근성) -->
|
||||
<a href="#main-content" class="visually-hidden-focusable">본문으로 건너뛰기</a>
|
||||
|
||||
{# 플래시 메시지 (전역) #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="position-fixed end-0 p-3" style="z-index: 2000; top: 70px;">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show shadow-lg" role="alert">
|
||||
<i class="bi bi-{{ 'check-circle' if cat == 'success' else 'exclamation-triangle' }} me-2"></i>
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
||||
<div class="container-fluid">
|
||||
@@ -37,12 +51,8 @@
|
||||
<i class="bi bi-server me-2"></i>
|
||||
Dell Server Info
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav"
|
||||
aria-expanded="false"
|
||||
aria-label="네비게이션 토글">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav" aria-expanded="false" aria-label="네비게이션 토글">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
@@ -53,44 +63,44 @@
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.index') }}">
|
||||
<i class="bi bi-hdd-network me-1"></i>ServerInfo
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('xml.xml_management') }}">
|
||||
<i class="bi bi-file-code me-1"></i>XML Management
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('jobs.jobs_page') }}">
|
||||
<i class="bi bi-list-task me-1"></i>Job Monitor
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.admin_panel') }}">
|
||||
<i class="bi bi-shield-lock me-1"></i>Admin
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.index') }}">
|
||||
<i class="bi bi-hdd-network me-1"></i>ServerInfo
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('xml.xml_management') }}">
|
||||
<i class="bi bi-file-code me-1"></i>XML Management
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('jobs.jobs_page') }}">
|
||||
<i class="bi bi-list-task me-1"></i>Job Monitor
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.admin_panel') }}">
|
||||
<i class="bi bi-shield-lock me-1"></i>Admin
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.login') }}">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i>Login
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.register') }}">
|
||||
<i class="bi bi-person-plus me-1"></i>Register
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.login') }}">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i>Login
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.register') }}">
|
||||
<i class="bi bi-person-plus me-1"></i>Register
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -98,7 +108,8 @@
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main id="main-content" class="{% if request.endpoint in ['auth.login', 'auth.register', 'auth.reset_password'] %}container mt-5{% else %}container mt-4 container-card{% endif %}">
|
||||
<main id="main-content"
|
||||
class="{% if request.endpoint in ['auth.login', 'auth.register', 'auth.reset_password'] %}container mt-5{% else %}container mt-4 container-card{% endif %}">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -110,17 +121,18 @@
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap 5.3.3 JS Bundle (Popper.js 포함) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Socket.IO (필요한 경우만) -->
|
||||
{% if config.USE_SOCKETIO %}
|
||||
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"
|
||||
integrity="sha384-Gr6Lu2Ajx28mzwyVR8CFkULdCU7kMlZ9UthllibdOSo6qAiN+yXNHqtgdTvFXMT4"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"
|
||||
integrity="sha384-Gr6Lu2Ajx28mzwyVR8CFkULdCU7kMlZ9UthllibdOSo6qAiN+yXNHqtgdTvFXMT4"
|
||||
crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -1,51 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit XML File - Dell Server Info{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/edit_xml.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Edit XML File</title>
|
||||
<style>
|
||||
::-webkit-scrollbar { width: 12px; }
|
||||
::-webkit-scrollbar-track { background: #f1f1f1; }
|
||||
::-webkit-scrollbar-thumb { background-color: #888; border-radius: 6px; }
|
||||
::-webkit-scrollbar-thumb:hover { background-color: #555; }
|
||||
html { scrollbar-width: thin; scrollbar-color: #888 #f1f1f1; }
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #ccc;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
textarea:hover { background-color: #f0f0f0; }
|
||||
.xml-list-item {
|
||||
padding: 10px;
|
||||
background-color: #ffffff;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
.xml-list-item:hover { background-color: #e9ecef; cursor: pointer; }
|
||||
.btn { margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3>Edit XML File: <strong>{{ filename }}</strong></h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label for="xmlContent">XML Content</label>
|
||||
<textarea id="xmlContent" name="content" class="form-control" rows="20">{{ content }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success mt-3">Save Changes</button>
|
||||
<a href="{{ url_for('xml.xml_management') }}" class="btn btn-secondary mt-3">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3>Edit XML File: <strong>{{ filename }}</strong></h3>
|
||||
</div>
|
||||
</body>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="form-group">
|
||||
<label for="xmlContent">XML Content</label>
|
||||
<textarea id="xmlContent" name="content" class="form-control" rows="20">{{ content }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success mt-3">Save Changes</button>
|
||||
<a href="{{ url_for('xml.xml_management') }}" class="btn btn-secondary mt-3">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,20 +3,7 @@
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
{# 플래시 메시지 #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="position-fixed top-0 end-0 p-3" style="z-index: 1050">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show shadow-lg" role="alert">
|
||||
<i class="bi bi-{{ 'check-circle' if cat == 'success' else 'exclamation-triangle' }} me-2"></i>
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
|
||||
{# 헤더 섹션 #}
|
||||
<div class="row mb-4">
|
||||
@@ -50,7 +37,7 @@
|
||||
<select id="script" name="script" class="form-select" required>
|
||||
<option value="">스크립트를 선택하세요</option>
|
||||
{% for script in scripts %}
|
||||
<option value="{{ script }}">{{ script }}</option>
|
||||
<option value="{{ script }}">{{ script }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -61,7 +48,7 @@
|
||||
<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>
|
||||
<option value="{{ xml_file }}">{{ xml_file }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -72,8 +59,8 @@
|
||||
IP 주소 (각 줄에 하나)
|
||||
<span class="badge bg-secondary ms-2" id="ipLineCount">0 대설정</span>
|
||||
</label>
|
||||
<textarea id="ips" name="ips" rows="4" class="form-control font-monospace"
|
||||
placeholder="예: 192.168.1.1 192.168.1.2 192.168.1.3" required></textarea>
|
||||
<textarea id="ips" name="ips" rows="4" class="form-control font-monospace"
|
||||
placeholder="예: 192.168.1.1 192.168.1.2 192.168.1.3" required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
@@ -102,22 +89,18 @@
|
||||
서버 리스트 (덮어쓰기)
|
||||
<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>
|
||||
<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="d-flex gap-2">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_server_list') }}"
|
||||
class="btn btn-secondary">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_server_list') }}" class="btn btn-secondary">
|
||||
MAC to Excel
|
||||
</button>
|
||||
<button type="submit" formaction="{{ url_for('utils.update_guid_list') }}"
|
||||
class="btn btn-success">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_guid_list') }}" class="btn btn-success">
|
||||
GUID to Excel
|
||||
</button>
|
||||
<button type="submit" formaction="{{ url_for('utils.update_gpu_list') }}"
|
||||
class="btn btn-warning">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_gpu_list') }}" class="btn btn-warning">
|
||||
GPU to Excel
|
||||
</button>
|
||||
</div>
|
||||
@@ -137,8 +120,8 @@
|
||||
<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">
|
||||
<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>
|
||||
@@ -218,45 +201,44 @@
|
||||
</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 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 }}">
|
||||
보기
|
||||
<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 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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -267,54 +249,53 @@
|
||||
|
||||
<!-- 이전 페이지 -->
|
||||
{% 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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{% 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 %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">다음 <i class="bi bi-chevron-right"></i></span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
<!-- /페이지네이션 -->
|
||||
|
||||
{% else %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# 백업된 파일 목록 #}
|
||||
<div class="row backup-list">
|
||||
@@ -328,55 +309,52 @@
|
||||
</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">
|
||||
{% for file in info.files %}
|
||||
<div class="col-auto">
|
||||
<div class="file-card-compact border rounded p-2 text-center">
|
||||
<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 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">
|
||||
{% for file in info.files %}
|
||||
<div class="col-auto">
|
||||
<div class="file-card-compact border rounded p-2 text-center">
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% 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>
|
||||
<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>
|
||||
@@ -397,8 +375,8 @@
|
||||
<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>
|
||||
<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">
|
||||
@@ -410,277 +388,15 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
/* ===== 공통 파일 카드 컴팩트 스타일 ===== */
|
||||
.file-card-compact {
|
||||
transition: all 0.2s ease;
|
||||
background: #fff;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
}
|
||||
{{ super() }}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ===== 목록별 버튼 분리 규칙 ===== */
|
||||
|
||||
/* 처리된 파일 목록 전용 컨테이너(보기/삭제 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);
|
||||
}
|
||||
|
||||
/* 삭제(처리된) — 더 작게 */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 백업 파일 목록 전용 컨테이너(단일 버튼) */
|
||||
.backup-list .file-card-single-button {
|
||||
display: flex;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
/* 보기(백업) — 강조 색상 */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* ===== 백업 파일 날짜 헤더 ===== */
|
||||
.list-group-item .bg-light {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.list-group-item:hover .bg-light {
|
||||
background-color: #e9ecef !important;
|
||||
}
|
||||
|
||||
/* ===== 진행바 애니메이션 ===== */
|
||||
.progress {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
/* ===== 반응형 텍스트 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.card-body {
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 스크롤바 스타일링(모달) ===== */
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 스크립트 선택 시 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) {
|
||||
toggleXml();
|
||||
scriptSelect.addEventListener('change', toggleXml);
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 파일 보기 모달
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 진행바 업데이트
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
window.updateProgress = function (val) {
|
||||
const bar = document.getElementById('progressBar');
|
||||
if (!bar) return;
|
||||
const v = Math.max(0, Math.min(100, Number(val) || 0));
|
||||
bar.style.width = v + '%';
|
||||
bar.setAttribute('aria-valuenow', v);
|
||||
bar.innerHTML = `<span class="fw-semibold">${v}%</span>`;
|
||||
};
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// CSRF 토큰
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value || '';
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 공통 POST 함수
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
async function postFormAndHandle(url) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Accept': 'application/json, text/html;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
});
|
||||
|
||||
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
||||
|
||||
if (ct.includes('application/json')) {
|
||||
const data = await res.json();
|
||||
if (data.success === false) {
|
||||
throw new Error(data.error || ('HTTP ' + res.status));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 알림 자동 닫기
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.alert').forEach(alert => {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/index.js') }}"></script>
|
||||
|
||||
<!-- 외부 script.js 파일 (IP 폼 처리 로직 포함) -->
|
||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}iDRAC Job Queue 모니터링 (Redfish){% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/jobs.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- CSRF Token for JavaScript -->
|
||||
<script>
|
||||
const csrfToken = "{{ csrf_token() }}";
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/jobs.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-3">
|
||||
<!-- 헤더 -->
|
||||
@@ -40,8 +52,8 @@
|
||||
<small class="text-muted d-block mb-2">
|
||||
한 줄에 하나씩 입력 (쉼표/세미콜론/공백 구분 가능, # 주석 지원)
|
||||
</small>
|
||||
<textarea id="ipInput" class="form-control font-monospace" rows="4"
|
||||
placeholder="10.10.0.11 10.10.0.12 # 주석 가능"></textarea>
|
||||
<textarea id="ipInput" class="form-control font-monospace" rows="4"
|
||||
placeholder="10.10.0.11 10.10.0.12 # 주석 가능"></textarea>
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">총 <strong id="ip-count">0</strong>개 IP</small>
|
||||
</div>
|
||||
@@ -60,13 +72,13 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-auto">
|
||||
<button id="btn-refresh" class="btn btn-primary btn-sm" disabled>
|
||||
<i class="bi bi-arrow-clockwise"></i> 지금 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch">
|
||||
@@ -75,7 +87,7 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="showCompletedSwitch" checked>
|
||||
@@ -150,494 +162,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: #6c757d;
|
||||
}
|
||||
.status-dot.active {
|
||||
background-color: #198754;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ↓↓↓ 여기부터 추가 ↓↓↓ */
|
||||
|
||||
/* 테이블 텍스트 한 줄 처리 */
|
||||
#jobs-table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#jobs-table td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* 열별 너비 고정 */
|
||||
#jobs-table td:nth-child(1) { max-width: 110px; } /* IP */
|
||||
#jobs-table td:nth-child(2) { max-width: 160px; font-size: 0.85rem; } /* JID */
|
||||
#jobs-table td:nth-child(3) { max-width: 200px; } /* 작업명 */
|
||||
#jobs-table td:nth-child(4) { max-width: 180px; } /* 상태 */
|
||||
#jobs-table td:nth-child(5) { max-width: 120px; } /* 진행률 */
|
||||
#jobs-table td:nth-child(6) { max-width: 300px; } /* 메시지 */
|
||||
#jobs-table td:nth-child(7) { max-width: 150px; } /* 시간 */
|
||||
|
||||
/* 마우스 올리면 전체 텍스트 보기 */
|
||||
#jobs-table td:hover {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* ↑↑↑ 여기까지 추가 ↑↑↑ */
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: #6c757d;
|
||||
}
|
||||
.status-dot.active {
|
||||
background-color: #198754;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const csrfToken = "{{ csrf_token() }}";
|
||||
|
||||
// ========== 전역 변수 ==========
|
||||
let CONFIG = {
|
||||
grace_minutes: 60,
|
||||
recency_hours: 24,
|
||||
poll_interval_ms: 10000
|
||||
};
|
||||
|
||||
let monitoringOn = false;
|
||||
let pollTimer = null;
|
||||
let lastRenderHash = "";
|
||||
|
||||
// ========== Elements ==========
|
||||
const $ = id => document.getElementById(id);
|
||||
const $body = $('jobs-body');
|
||||
const $last = $('last-updated');
|
||||
const $loading = $('loading-indicator');
|
||||
const $btn = $('btn-refresh');
|
||||
const $auto = $('autoRefreshSwitch');
|
||||
const $ipInput = $('ipInput');
|
||||
const $ipCount = $('ip-count');
|
||||
const $btnLoad = $('btn-load-file');
|
||||
const $btnApply = $('btn-apply');
|
||||
const $monSw = $('monitorSwitch');
|
||||
const $statusDot = $('status-dot');
|
||||
const $statusText = $('status-text');
|
||||
const $showCompleted = $('showCompletedSwitch');
|
||||
const $pollInterval = $('poll-interval');
|
||||
|
||||
// Stats
|
||||
const $statTotal = $('stat-total');
|
||||
const $statRunning = $('stat-running');
|
||||
const $statCompleted = $('stat-completed');
|
||||
const $statError = $('stat-error');
|
||||
|
||||
// ========== LocalStorage Keys ==========
|
||||
const LS_IPS = 'idrac_job_ips';
|
||||
const LS_MON = 'idrac_monitor_on';
|
||||
const LS_AUTO = 'idrac_monitor_auto';
|
||||
const LS_SHOW_COMPLETED = 'idrac_show_completed';
|
||||
|
||||
// ========== 유틸리티 ==========
|
||||
function parseIps(text) {
|
||||
if (!text) return [];
|
||||
const raw = text.replace(/[,;]+/g, '\n');
|
||||
const out = [], seen = new Set();
|
||||
raw.split('\n').forEach(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
parts.forEach(p => {
|
||||
if (!p || p.startsWith('#')) return;
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
out.push(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function getIpsFromUI() { return parseIps($ipInput.value); }
|
||||
function setIpsToUI(ips) {
|
||||
$ipInput.value = (ips || []).join('\n');
|
||||
updateIpCount();
|
||||
}
|
||||
function updateIpCount() {
|
||||
const ips = getIpsFromUI();
|
||||
$ipCount.textContent = ips.length;
|
||||
}
|
||||
function saveIps() {
|
||||
localStorage.setItem(LS_IPS, JSON.stringify(getIpsFromUI()));
|
||||
updateIpCount();
|
||||
}
|
||||
function loadIps() {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(LS_IPS) || "[]");
|
||||
return Array.isArray(v) ? v : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, m => ({
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
}[m]));
|
||||
}
|
||||
|
||||
function progressBar(pc) {
|
||||
const n = parseInt(String(pc ?? "").toString().replace('%', '').trim(), 10);
|
||||
if (isNaN(n)) return `<span class="text-muted small">${escapeHtml(pc ?? "")}</span>`;
|
||||
|
||||
let bgClass = 'bg-info';
|
||||
if (n === 100) bgClass = 'bg-success';
|
||||
else if (n < 30) bgClass = 'bg-warning';
|
||||
|
||||
return `<div class="progress" style="height:8px;">
|
||||
<div class="progress-bar ${bgClass}" role="progressbar"
|
||||
style="width:${n}%;" aria-valuenow="${n}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
<small class="text-muted">${n}%</small>`;
|
||||
}
|
||||
|
||||
function badgeStatus(status, pc, recently = false) {
|
||||
const raw = String(status || "");
|
||||
const s = raw.toLowerCase();
|
||||
let cls = "bg-secondary";
|
||||
let icon = "info-circle";
|
||||
|
||||
if (recently) {
|
||||
cls = "bg-success";
|
||||
icon = "check-circle";
|
||||
} else if (s.includes("completed")) {
|
||||
cls = "bg-success";
|
||||
icon = "check-circle";
|
||||
} else if (s.includes("running") || s.includes("progress")) {
|
||||
cls = "bg-info";
|
||||
icon = "arrow-repeat";
|
||||
} else if (s.includes("scheduled") || s.includes("pending")) {
|
||||
cls = "bg-warning text-dark";
|
||||
icon = "clock";
|
||||
} else if (s.includes("failed") || s.includes("error")) {
|
||||
cls = "bg-danger";
|
||||
icon = "x-circle";
|
||||
}
|
||||
|
||||
const pct = parseInt(String(pc ?? "").toString().replace('%', '').trim(), 10);
|
||||
const pctText = isNaN(pct) ? "" : ` (${pct}%)`;
|
||||
const text = recently ? `${raw || "Completed"} (최근${pctText})` : `${raw || "-"}${pctText}`;
|
||||
|
||||
return `<span class="badge ${cls}">
|
||||
<i class="bi bi-${icon}"></i> ${escapeHtml(text)}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function updateStats(items) {
|
||||
let total = 0, running = 0, completed = 0, error = 0;
|
||||
|
||||
items.forEach(it => {
|
||||
if (!it.ok) {
|
||||
error++;
|
||||
return;
|
||||
}
|
||||
if (it.jobs && it.jobs.length) {
|
||||
total++;
|
||||
it.jobs.forEach(j => {
|
||||
const s = (j.Status || "").toLowerCase();
|
||||
if (s.includes("running") || s.includes("progress") || s.includes("starting")) {
|
||||
running++;
|
||||
} else if (s.includes("completed") || s.includes("success")) {
|
||||
completed++;
|
||||
} else if (s.includes("failed") || s.includes("error")) {
|
||||
error++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$statTotal.textContent = items.length;
|
||||
$statRunning.textContent = running;
|
||||
$statCompleted.textContent = completed;
|
||||
$statError.textContent = error;
|
||||
}
|
||||
|
||||
// ========== 렌더링 ==========
|
||||
function renderTable(items) {
|
||||
if (!items) return;
|
||||
|
||||
const hash = JSON.stringify(items);
|
||||
if (hash === lastRenderHash) return;
|
||||
lastRenderHash = hash;
|
||||
|
||||
if (!items.length) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
|
||||
현재 모니터링 중인 Job이 없습니다.
|
||||
</td></tr>`;
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const it of items) {
|
||||
if (!it.ok) {
|
||||
rows.push(`<tr class="table-danger">
|
||||
<td><code>${escapeHtml(it.ip)}</code></td>
|
||||
<td colspan="6">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
오류: ${escapeHtml(it.error || "Unknown")}
|
||||
</td>
|
||||
</tr>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!it.jobs || !it.jobs.length) continue;
|
||||
|
||||
for (const j of it.jobs) {
|
||||
const recent = !!j.RecentlyCompleted;
|
||||
const timeText = j.CompletedAt
|
||||
? `완료: ${escapeHtml(j.CompletedAt.split('T')[1]?.split('.')[0] || j.CompletedAt)}`
|
||||
: escapeHtml(j.LastUpdateTime || "");
|
||||
|
||||
rows.push(`<tr ${recent ? 'class="table-success"' : ''}>
|
||||
<td><code>${escapeHtml(it.ip)}</code></td>
|
||||
<td><small class="font-monospace">${escapeHtml(j.JID || "")}</small></td>
|
||||
<td><strong>${escapeHtml(j.Name || "")}</strong></td>
|
||||
<td>${badgeStatus(j.Status || "", j.PercentComplete || "", recent)}</td>
|
||||
<td>${progressBar(j.PercentComplete || "0")}</td>
|
||||
<td><small>${escapeHtml(j.Message || "")}</small></td>
|
||||
<td><small class="text-muted">${timeText}</small></td>
|
||||
</tr>`);
|
||||
}
|
||||
}
|
||||
|
||||
$body.innerHTML = rows.length
|
||||
? rows.join("")
|
||||
: `<tr><td colspan="7" class="text-center text-success py-4">
|
||||
<i class="bi bi-check-circle fs-2 d-block mb-2"></i>
|
||||
현재 진행 중인 Job이 없습니다. ✅
|
||||
</td></tr>`;
|
||||
|
||||
updateStats(items);
|
||||
}
|
||||
|
||||
// ========== 서버 요청 ==========
|
||||
async function fetchJobs(auto = false) {
|
||||
if (!monitoringOn) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="bi bi-power fs-2 d-block mb-2"></i>
|
||||
모니터링이 꺼져 있습니다. 상단 스위치를 켜면 조회가 시작됩니다.
|
||||
</td></tr>`;
|
||||
$last.textContent = "";
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const ips = getIpsFromUI();
|
||||
if (!ips.length) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-warning py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>
|
||||
IP 목록이 비어 있습니다.
|
||||
</td></tr>`;
|
||||
$last.textContent = "";
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$loading.classList.remove('d-none');
|
||||
$last.textContent = "조회 중… " + new Date().toLocaleTimeString();
|
||||
|
||||
const res = await fetch("/jobs/scan", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ips,
|
||||
method: "redfish", // Redfish 사용
|
||||
recency_hours: CONFIG.recency_hours,
|
||||
grace_minutes: CONFIG.grace_minutes,
|
||||
include_tracked_done: $showCompleted.checked
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || "Scan failed");
|
||||
|
||||
renderTable(data.items);
|
||||
$last.textContent = "업데이트: " + new Date().toLocaleString();
|
||||
} catch (e) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-danger text-center py-4">
|
||||
<i class="bi bi-exclamation-circle fs-2 d-block mb-2"></i>
|
||||
로드 실패: ${escapeHtml(e.message)}
|
||||
<br><button class="btn btn-sm btn-outline-primary mt-2" onclick="fetchJobs(false)">
|
||||
<i class="bi bi-arrow-clockwise"></i> 재시도
|
||||
</button>
|
||||
</td></tr>`;
|
||||
$last.textContent = "에러: " + new Date().toLocaleString();
|
||||
console.error(e);
|
||||
} finally {
|
||||
$loading.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 모니터링 제어 ==========
|
||||
function startAuto() {
|
||||
stopAuto();
|
||||
pollTimer = setInterval(() => fetchJobs(true), CONFIG.poll_interval_ms);
|
||||
}
|
||||
|
||||
function stopAuto() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateMonitorUI() {
|
||||
$btn.disabled = !monitoringOn;
|
||||
$auto.disabled = !monitoringOn;
|
||||
|
||||
if (monitoringOn) {
|
||||
$statusDot.classList.add('active');
|
||||
$statusText.textContent = '모니터링 중';
|
||||
} else {
|
||||
$statusDot.classList.remove('active');
|
||||
$statusText.textContent = '모니터링 꺼짐';
|
||||
}
|
||||
}
|
||||
|
||||
async function setMonitoring(on) {
|
||||
monitoringOn = !!on;
|
||||
localStorage.setItem(LS_MON, monitoringOn ? "1" : "0");
|
||||
updateMonitorUI();
|
||||
|
||||
if (!monitoringOn) {
|
||||
stopAuto();
|
||||
$last.textContent = "";
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="bi bi-power fs-2 d-block mb-2"></i>
|
||||
모니터링이 꺼져 있습니다.
|
||||
</td></tr>`;
|
||||
lastRenderHash = "";
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchJobs(false);
|
||||
if ($auto.checked) startAuto();
|
||||
}
|
||||
|
||||
// ========== 초기화 ==========
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 설정 로드
|
||||
try {
|
||||
const res = await fetch('/jobs/config');
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
CONFIG = data.config;
|
||||
$pollInterval.textContent = Math.round(CONFIG.poll_interval_ms / 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load config:", e);
|
||||
}
|
||||
|
||||
// IP 복원
|
||||
const savedIps = loadIps();
|
||||
if (savedIps.length) {
|
||||
setIpsToUI(savedIps);
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch('/jobs/iplist', {
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok && data.ips) {
|
||||
setIpsToUI(data.ips);
|
||||
saveIps();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 설정 복원
|
||||
const savedMon = localStorage.getItem(LS_MON);
|
||||
const savedAuto = localStorage.getItem(LS_AUTO);
|
||||
const savedShowCompleted = localStorage.getItem(LS_SHOW_COMPLETED);
|
||||
|
||||
$monSw.checked = savedMon === "1";
|
||||
$auto.checked = savedAuto === "1";
|
||||
$showCompleted.checked = savedShowCompleted !== "0";
|
||||
|
||||
// 이벤트
|
||||
$ipInput.addEventListener('input', updateIpCount);
|
||||
$btn.addEventListener('click', () => { if (monitoringOn) fetchJobs(false); });
|
||||
$auto.addEventListener('change', e => {
|
||||
localStorage.setItem(LS_AUTO, e.target.checked ? "1" : "0");
|
||||
if (!monitoringOn) return;
|
||||
if (e.target.checked) startAuto();
|
||||
else stopAuto();
|
||||
});
|
||||
$monSw.addEventListener('click', e => setMonitoring(e.target.checked));
|
||||
$showCompleted.addEventListener('change', e => {
|
||||
localStorage.setItem(LS_SHOW_COMPLETED, e.target.checked ? "1" : "0");
|
||||
if (monitoringOn) fetchJobs(false);
|
||||
});
|
||||
$btnLoad.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch('/jobs/iplist', {
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setIpsToUI(data.ips || []);
|
||||
saveIps();
|
||||
if (monitoringOn) await fetchJobs(false);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('IP 목록 불러오기 실패: ' + e.message);
|
||||
}
|
||||
});
|
||||
$btnApply.addEventListener('click', () => {
|
||||
saveIps();
|
||||
if (monitoringOn) fetchJobs(false);
|
||||
});
|
||||
|
||||
// 초기 상태
|
||||
updateMonitorUI();
|
||||
updateIpCount();
|
||||
|
||||
if ($monSw.checked) {
|
||||
setMonitoring(true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -1,305 +1,223 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}XML 파일 관리 & 배포 - Dell Server Info{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/scp.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>XML 파일 관리</title>
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<style>
|
||||
body {
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px 0;
|
||||
}
|
||||
<h1 class="main-title">설정 파일 관리 (SCP)</h1>
|
||||
<p class="subtitle">iDRAC 서버 설정(XML)을 내보내거나 가져오고, 버전을 비교할 수 있습니다.</p>
|
||||
|
||||
.main-title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.card-header-custom {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
border: none;
|
||||
padding: 8px 24px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-file-label {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.custom-file-label::after {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border-radius: 0 3px 3px 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* 아이콘 + 뱃지 스타일 */
|
||||
.file-list {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.icon-badge-item {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.3s;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.icon-badge-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-color: #007bff;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.icon-badge-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-icon-small {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #007bff;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-name-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-name-badge {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge-custom {
|
||||
background-color: #e7f3ff;
|
||||
color: #007bff;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 30px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일 */
|
||||
.file-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 class="main-title">XML 파일 관리</h1>
|
||||
<p class="subtitle">XML 파일을 업로드하고 관리할 수 있습니다</p>
|
||||
|
||||
<!-- XML 파일 업로드 폼 -->
|
||||
<div class="row">
|
||||
<!-- 왼쪽: 파일 업로드 및 내보내기 -->
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header-custom">
|
||||
<i class="fas fa-cloud-upload-alt mr-2"></i>파일 업로드
|
||||
<span><i class="fas fa-cloud-upload-alt me-2"></i>파일 등록</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('xml.upload_xml') }}" method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<!-- 1. PC에서 업로드 -->
|
||||
<h6 class="mb-3"><i class="fas fa-laptop me-2"></i>PC에서 업로드</h6>
|
||||
<form action="{{ url_for('xml.upload_xml') }}" method="POST" enctype="multipart/form-data" class="mb-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="upload-section">
|
||||
<div class="form-group mb-2">
|
||||
<label for="xmlFile" class="form-label">XML 파일 선택</label>
|
||||
<div class="mb-2">
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="xmlFile" name="xmlFile" accept=".xml" onchange="updateFileName(this)">
|
||||
<label class="custom-file-label" for="xmlFile" id="fileLabel">파일을 선택하세요</label>
|
||||
<input type="file" class="custom-file-input" id="xmlFile" name="xmlFile" accept=".xml"
|
||||
onchange="updateFileName(this)">
|
||||
<label class="custom-file-label" for="xmlFile" id="fileLabel">파일 선택</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-upload mr-1"></i>업로드
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-upload me-1"></i>업로드
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- XML 파일 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-header-custom">
|
||||
<i class="fas fa-list mr-2"></i>파일 목록
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if xml_files %}
|
||||
<div class="file-list">
|
||||
{% for xml_file in xml_files %}
|
||||
<div class="icon-badge-item">
|
||||
<div class="icon-badge-left">
|
||||
<div class="file-icon-small">
|
||||
<i class="fas fa-file-code"></i>
|
||||
</div>
|
||||
<div class="file-name-section">
|
||||
<span class="file-name-badge" title="{{ xml_file }}">{{ xml_file }}</span>
|
||||
<span class="badge-custom">XML</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<!-- 파일 편집 버튼 -->
|
||||
<a href="{{ url_for('xml.edit_xml', filename=xml_file) }}" class="btn btn-success btn-sm">
|
||||
<i class="fas fa-edit"></i> 편집
|
||||
</a>
|
||||
<!-- 파일 삭제 버튼 -->
|
||||
<form action="{{ url_for('xml.delete_xml', filename=xml_file) }}" method="POST" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('정말 삭제하시겠습니까?')">
|
||||
<i class="fas fa-trash"></i> 삭제
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-message">
|
||||
<i class="fas fa-folder-open" style="font-size: 2rem; color: #ddd;"></i>
|
||||
<p class="mt-2 mb-0">파일이 없습니다.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<hr>
|
||||
|
||||
<!-- 2. iDRAC에서 내보내기 -->
|
||||
<h6 class="mb-3"><i class="fas fa-server me-2"></i>iDRAC에서 추출 (Export)</h6>
|
||||
<button type="button" class="btn btn-outline-primary w-100" data-bs-toggle="modal"
|
||||
data-bs-target="#exportModal">
|
||||
<i class="fas fa-download me-1"></i>설정 추출하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
|
||||
<script>
|
||||
function updateFileName(input) {
|
||||
const fileName = input.files[0]?.name || '파일을 선택하세요';
|
||||
document.getElementById('fileLabel').textContent = fileName;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<!-- 오른쪽: 파일 목록 및 작업 -->
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header-custom">
|
||||
<span><i class="fas fa-list me-2"></i>파일 목록</span>
|
||||
<button class="btn btn-light btn-sm text-primary" id="compareBtn"
|
||||
data-url="{{ url_for('scp.diff_scp') }}" onclick="compareSelected()">
|
||||
<i class="fas fa-exchange-alt me-1"></i>선택 비교
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if xml_files %}
|
||||
<div class="file-list">
|
||||
{% for xml_file in xml_files %}
|
||||
<div class="icon-badge-item">
|
||||
<div class="icon-badge-left">
|
||||
<input type="checkbox" class="select-checkbox file-selector" value="{{ xml_file }}">
|
||||
<div class="file-icon-small">
|
||||
<i class="fas fa-file-code"></i>
|
||||
</div>
|
||||
<div class="file-name-section">
|
||||
<span class="file-name-badge" title="{{ xml_file }}">{{ xml_file }}</span>
|
||||
<span class="badge-custom">XML</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<!-- 배포 버튼 -->
|
||||
<button type="button" class="btn btn-info btn-sm text-white"
|
||||
onclick="openDeployModal('{{ xml_file }}')">
|
||||
<i class="fas fa-plane-departure"></i> <span>배포</span>
|
||||
</button>
|
||||
<!-- 편집 버튼 -->
|
||||
<a href="{{ url_for('xml.edit_xml', filename=xml_file) }}" class="btn btn-success btn-sm">
|
||||
<i class="fas fa-edit"></i> <span>편집</span>
|
||||
</a>
|
||||
<!-- 삭제 버튼 -->
|
||||
<form action="{{ url_for('xml.delete_xml', filename=xml_file) }}" method="POST"
|
||||
style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<button type="submit" class="btn btn-danger btn-sm"
|
||||
onclick="return confirm('정말 삭제하시겠습니까?')">
|
||||
<i class="fas fa-trash"></i> <span>삭제</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-message">
|
||||
<i class="fas fa-folder-open" style="font-size: 2rem; color: #ddd;"></i>
|
||||
<p class="mt-2 mb-0">파일이 없습니다.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Modal -->
|
||||
<div class="modal fade" id="exportModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('scp.export_scp') }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">iDRAC 설정 내보내기</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info py-2" style="font-size: 0.9rem;">
|
||||
<i class="fas fa-info-circle me-1"></i> 네트워크 공유 폴더(CIFS)가 필요합니다.
|
||||
</div>
|
||||
|
||||
<h6>대상 iDRAC</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="target_ip" placeholder="iDRAC IP"
|
||||
required></div>
|
||||
<div class="row mb-3">
|
||||
<div class="col"><input type="text" class="form-control" name="username" placeholder="User"
|
||||
required></div>
|
||||
<div class="col"><input type="password" class="form-control" name="password"
|
||||
placeholder="Password" required></div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h6>네트워크 공유 (저장소)</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_ip"
|
||||
placeholder="Share Server IP" required></div>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_name"
|
||||
placeholder="Share Name (e.g. public)" required></div>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="filename"
|
||||
placeholder="Save Filename (e.g. backup.xml)" required></div>
|
||||
<div class="row">
|
||||
<div class="col"><input type="text" class="form-control" name="share_user"
|
||||
placeholder="Share User"></div>
|
||||
<div class="col"><input type="password" class="form-control" name="share_pwd"
|
||||
placeholder="Share Password"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-primary">내보내기 시작</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy (Import) Modal -->
|
||||
<div class="modal fade" id="deployModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('scp.import_scp') }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">설정 배포 (Import)</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning py-2" style="font-size: 0.9rem;">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i> 적용 후 서버가 재부팅될 수 있습니다.
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">배포할 파일</label>
|
||||
<input type="text" class="form-control" id="deployFilename" name="filename" readonly>
|
||||
</div>
|
||||
|
||||
<h6>대상 iDRAC</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="target_ip" placeholder="iDRAC IP"
|
||||
required></div>
|
||||
<div class="row mb-3">
|
||||
<div class="col"><input type="text" class="form-control" name="username" placeholder="User"
|
||||
required></div>
|
||||
<div class="col"><input type="password" class="form-control" name="password"
|
||||
placeholder="Password" required></div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h6>네트워크 공유 (소스 위치)</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_ip"
|
||||
placeholder="Share Server IP" required></div>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_name" placeholder="Share Name"
|
||||
required></div>
|
||||
<div class="row mb-3">
|
||||
<div class="col"><input type="text" class="form-control" name="share_user"
|
||||
placeholder="Share User"></div>
|
||||
<div class="col"><input type="password" class="form-control" name="share_pwd"
|
||||
placeholder="Share Password"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">적용 모드</label>
|
||||
<select class="form-select" name="import_mode">
|
||||
<option value="Replace">전체 교체 (Replace)</option>
|
||||
<option value="Append">변경분만 적용 (Append)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-danger">배포 시작</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/scp.js') }}"></script>
|
||||
{% endblock %}
|
||||
58
backend/templates/scp_diff.html
Normal file
58
backend/templates/scp_diff.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}설정 파일 비교 - Dell Server Info{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/scp.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>설정 파일 비교</h2>
|
||||
<a href="{{ url_for('xml.xml_management') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<i class="fas fa-exchange-alt me-2"></i>비교 대상
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-5">
|
||||
<h5>{{ file1 }}</h5>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<i class="fas fa-arrow-right text-muted"></i>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<h5>{{ file2 }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-code me-2"></i>Diff 결과
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="diff-container">
|
||||
{% for line in diff_content.splitlines() %}
|
||||
{% if line.startswith('+++') or line.startswith('---') %}
|
||||
<span class="diff-line diff-header">{{ line }}</span>
|
||||
{% elif line.startswith('+') %}
|
||||
<span class="diff-line diff-add">{{ line }}</span>
|
||||
{% elif line.startswith('-') %}
|
||||
<span class="diff-line diff-del">{{ line }}</span>
|
||||
{% else %}
|
||||
<span class="diff-line">{{ line }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,21 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<body>
|
||||
<h1>Uploaded XML Files</h1>
|
||||
<ul>
|
||||
{% for file in files %}
|
||||
<li>
|
||||
{{ file }}
|
||||
<a href="{{ url_for('download_xml', filename=file) }}">Download</a>
|
||||
<a href="{{ url_for('edit_xml', filename=file) }}">Edit</a>
|
||||
<form action="{{ url_for('delete_xml', filename=file) }}" method="post" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="{{ url_for('upload_xml') }}">Upload new file</a>
|
||||
</body>
|
||||
<h1>Uploaded XML Files</h1>
|
||||
<ul>
|
||||
{% for file in files %}
|
||||
<li>
|
||||
{{ file }}
|
||||
<a href="{{ url_for('download_xml', filename=file) }}">Download</a>
|
||||
<a href="{{ url_for('edit_xml', filename=file) }}">Edit</a>
|
||||
<form action="{{ url_for('delete_xml', filename=file) }}" method="post" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="{{ url_for('upload_xml') }}">Upload new file</a>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user