Update 2025-12-19 16:23:03
This commit is contained in:
@@ -1,72 +1,151 @@
|
||||
{# backend/templates/admin.html #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}관리자 패널 - Dell Server Info{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="card-title mb-0">Admin Page</h3>
|
||||
<a href="{{ url_for('admin.settings') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-gear-fill me-1"></i>시스템 설정
|
||||
</a>
|
||||
</div>
|
||||
<div class="container py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">
|
||||
<i class="bi bi-shield-lock text-primary me-2"></i>관리자 패널
|
||||
</h2>
|
||||
<p class="text-muted mb-0">사용자 관리 및 시스템 설정을 수행합니다.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('admin.view_logs') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-journal-text me-1"></i>로그 보기
|
||||
</a>
|
||||
<a href="{{ url_for('admin.settings') }}" class="btn btn-primary">
|
||||
<i class="bi bi-gear-fill me-1"></i>시스템 설정
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mt-2">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
<!-- Dashboard Stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100 bg-primary bg-opacity-10">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<div class="rounded-circle bg-primary text-white p-3 me-3">
|
||||
<i class="bi bi-people-fill fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="text-primary fw-bold mb-1">총 사용자</h6>
|
||||
<h3 class="mb-0 fw-bold">{{ users|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100 bg-success bg-opacity-10">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<div class="rounded-circle bg-success text-white p-3 me-3">
|
||||
<i class="bi bi-person-check-fill fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="text-success fw-bold mb-1">활성 사용자</h6>
|
||||
{% set active_users = users | selectattr("is_active") | list %}
|
||||
<h3 class="mb-0 fw-bold">{{ active_users|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100 bg-warning bg-opacity-10">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<div class="rounded-circle bg-warning text-white p-3 me-3">
|
||||
<i class="bi bi-person-dash-fill fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="text-warning fw-bold mb-1">승인 대기</h6>
|
||||
<h3 class="mb-0 fw-bold">{{ (users|length) - (active_users|length) }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management Table -->
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white border-bottom py-3">
|
||||
<h5 class="mb-0 fw-bold">
|
||||
<i class="bi bi-person-lines-fill text-primary me-2"></i>사용자 목록
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th style="width:60px">ID</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th style="width:80px">Active</th>
|
||||
<th style="width:260px">Action</th>
|
||||
<th class="ps-4 py-3 text-secondary text-uppercase small fw-bold" style="width: 60px;">NO</th>
|
||||
<th class="py-3 text-secondary text-uppercase small fw-bold">이름</th>
|
||||
<th class="py-3 text-secondary text-uppercase small fw-bold">ID (Email)</th>
|
||||
<th class="py-3 text-secondary text-uppercase small fw-bold">상태</th>
|
||||
<th class="py-3 text-secondary text-uppercase small fw-bold text-end pe-4">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td class="ps-4 fw-bold text-secondary">{{ loop.index }}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div
|
||||
class="avatar-initial rounded-circle bg-light text-primary fw-bold me-2 d-flex align-items-center justify-content-center border"
|
||||
style="width: 32px; height: 32px; font-size: 0.9rem;">
|
||||
{{ user.username[:1] | upper }}
|
||||
</div>
|
||||
<span class="fw-bold text-dark">{{ user.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-secondary small font-monospace">{{ user.email }}</td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="badge bg-success">Yes</span>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-pill px-3">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>Active
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">No</span>
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle rounded-pill px-3">
|
||||
<i class="bi bi-hourglass-split me-1"></i>Pending
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_admin %}
|
||||
<span
|
||||
class="badge bg-primary-subtle text-primary border border-primary-subtle rounded-pill px-2 ms-1">Admin</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not user.is_active %}
|
||||
<a href="{{ url_for('admin.approve_user', user_id=user.id) }}"
|
||||
class="btn btn-success btn-sm me-1">Approve</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.delete_user', user_id=user.id) }}" class="btn btn-danger btn-sm me-1"
|
||||
onclick="return confirm('사용자 {{ user.username }} (ID={{ user.id }}) 를 삭제하시겠습니까?');">
|
||||
Delete
|
||||
</a>
|
||||
<td class="text-end pe-4">
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
{% if not user.is_active %}
|
||||
<a href="{{ url_for('admin.approve_user', user_id=user.id) }}"
|
||||
class="btn btn-sm btn-success text-white d-flex align-items-center gap-1" title="가입 승인">
|
||||
<i class="bi bi-check-lg"></i>승인
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Change Password 버튼: 모달 오픈 -->
|
||||
<button type="button" class="btn btn-primary btn-sm" data-user-id="{{ user.id }}"
|
||||
data-username="{{ user.username | e }}" data-bs-toggle="modal" data-bs-target="#changePasswordModal">
|
||||
Change Password
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary d-flex align-items-center gap-1"
|
||||
data-user-id="{{ user.id }}" data-username="{{ user.username | e }}" data-bs-toggle="modal"
|
||||
data-bs-target="#changePasswordModal">
|
||||
<i class="bi bi-key"></i>비밀번호
|
||||
</button>
|
||||
|
||||
<a href="{{ url_for('admin.delete_user', user_id=user.id) }}"
|
||||
class="btn btn-sm btn-outline-danger d-flex align-items-center gap-1"
|
||||
onclick="return confirm('⚠️ 경고: 사용자 [{{ user.username }}]님을 정말 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.');">
|
||||
<i class="bi bi-trash"></i>삭제
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not users %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-5 text-muted">사용자가 없습니다.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -75,41 +154,44 @@
|
||||
</div>
|
||||
|
||||
{# ========== Change Password Modal ========== #}
|
||||
<div class="modal fade" id="changePasswordModal" tabindex="-1" aria-labelledby="changePasswordModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal fade" id="changePasswordModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<form id="changePasswordForm" method="post" action="">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="changePasswordModalLabel">Change Password</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
<div class="modal-header bg-light">
|
||||
<h5 class="modal-title fw-bold">
|
||||
<i class="bi bi-key-fill me-2"></i>비밀번호 변경
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">User:</small>
|
||||
<div id="modalUserInfo" class="fw-bold"></div>
|
||||
<div class="modal-body p-4">
|
||||
<div class="alert alert-light border mb-4 d-flex align-items-center">
|
||||
<i class="bi bi-person-circle fs-4 me-3 text-secondary"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">대상 사용자</small>
|
||||
<span id="modalUserInfo" class="fw-bold text-dark fs-5"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="newPasswordInput" class="form-label">New password</label>
|
||||
<label for="newPasswordInput" class="form-label fw-semibold">새 비밀번호</label>
|
||||
<input id="newPasswordInput" name="new_password" type="password" class="form-control" required minlength="8"
|
||||
placeholder="Enter new password">
|
||||
<div class="form-text">최소 8자 이상을 권장합니다.</div>
|
||||
placeholder="최소 8자 이상">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="confirmPasswordInput" class="form-label">Confirm password</label>
|
||||
<label for="confirmPasswordInput" class="form-label fw-semibold">비밀번호 확인</label>
|
||||
<input id="confirmPasswordInput" name="confirm_password" type="password" class="form-control" required
|
||||
minlength="8" placeholder="Confirm new password">
|
||||
minlength="8" placeholder="비밀번호 재입력">
|
||||
<div id="pwMismatch" class="invalid-feedback">비밀번호가 일치하지 않습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button id="modalSubmitBtn" type="submit" class="btn btn-primary">Change Password</button>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button id="modalSubmitBtn" type="submit" class="btn btn-primary px-4">변경 저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
245
backend/templates/admin_logs.html
Normal file
245
backend/templates/admin_logs.html
Normal file
@@ -0,0 +1,245 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}시스템 로그 - Dell Server Info{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* 전체 레이아웃 */
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 600px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 툴바 (헤더) */
|
||||
.editor-toolbar {
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 에디터 본문 */
|
||||
#monaco-editor-root {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 로딩 인디케이터 */
|
||||
.editor-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #d4d4d4;
|
||||
font-size: 1.1rem;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">
|
||||
<i class="bi bi-terminal text-dark me-2"></i>시스템 로그
|
||||
</h2>
|
||||
<p class="text-muted mb-0 small">최근 생성된 1000줄의 시스템 로그를 실시간으로 확인합니다.</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>돌아가기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<!-- Toolbar -->
|
||||
<div class="editor-toolbar">
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<div class="input-group input-group-sm" style="width: 250px;">
|
||||
<span class="input-group-text bg-dark border-secondary text-light"><i
|
||||
class="bi bi-search"></i></span>
|
||||
<input type="text" id="logSearch" class="form-control bg-dark border-secondary text-light"
|
||||
placeholder="검색어 입력...">
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<input type="checkbox" class="btn-check" id="checkInfo" checked autocomplete="off">
|
||||
<label class="btn btn-outline-secondary text-light" for="checkInfo">INFO</label>
|
||||
|
||||
<input type="checkbox" class="btn-check" id="checkWarn" checked autocomplete="off">
|
||||
<label class="btn btn-outline-warning" for="checkWarn">WARN</label>
|
||||
|
||||
<input type="checkbox" class="btn-check" id="checkError" checked autocomplete="off">
|
||||
<label class="btn btn-outline-danger" for="checkError">ERROR</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-light" id="btnScrollBottom">
|
||||
<i class="bi bi-arrow-down-circle me-1"></i>맨 아래로
|
||||
</button>
|
||||
<a href="{{ url_for('admin.view_logs') }}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>새로고침
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Area -->
|
||||
<div id="monaco-editor-root">
|
||||
<div class="editor-loading">
|
||||
<div class="spinner-border text-light me-3" role="status"></div>
|
||||
<div>로그 뷰어를 불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- Monaco Editor Loader -->
|
||||
<script src="https://unpkg.com/monaco-editor@0.45.0/min/vs/loader.js"></script>
|
||||
|
||||
<script>
|
||||
// 서버에서 전달된 로그 데이터 (Python list -> JS array)
|
||||
// tojson safe 필터 사용
|
||||
const allLogs = {{ logs | tojson | safe }};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof require === 'undefined') {
|
||||
document.querySelector('.editor-loading').innerHTML =
|
||||
'<div class="text-danger"><i class="bi bi-exclamation-triangle me-2"></i>Monaco Editor를 로드할 수 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
require.config({ paths: { 'vs': 'https://unpkg.com/monaco-editor@0.45.0/min/vs' } });
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
var container = document.getElementById('monaco-editor-root');
|
||||
container.innerHTML = ''; // 로딩 제거
|
||||
|
||||
// 1. 커스텀 로그 언어 정의 (간단한 하이라이팅)
|
||||
monaco.languages.register({ id: 'simpleLog' });
|
||||
monaco.languages.setMonarchTokensProvider('simpleLog', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/\[INFO\]|INFO:/, 'info-token'],
|
||||
[/\[WARNING\]|\[WARN\]|WARNING:|WARN:/, 'warn-token'],
|
||||
[/\[ERROR\]|ERROR:|Traceback/, 'error-token'],
|
||||
[/\[DEBUG\]|DEBUG:/, 'debug-token'],
|
||||
[/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}/, 'date-token'],
|
||||
[/".*?"/, 'string']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 테마 정의
|
||||
monaco.editor.defineTheme('logTheme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'info-token', foreground: '4ec9b0' },
|
||||
{ token: 'warn-token', foreground: 'cca700', fontStyle: 'bold' },
|
||||
{ token: 'error-token', foreground: 'f44747', fontStyle: 'bold' },
|
||||
{ token: 'debug-token', foreground: '808080' },
|
||||
{ token: 'date-token', foreground: '569cd6' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#1e1e1e'
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 에디터 생성
|
||||
var editor = monaco.editor.create(container, {
|
||||
value: allLogs.join('\n'),
|
||||
language: 'simpleLog',
|
||||
theme: 'logTheme',
|
||||
readOnly: true,
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 13,
|
||||
lineHeight: 19, // 밀도 조절
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
renderLineHighlight: 'all',
|
||||
contextmenu: false,
|
||||
padding: { top: 10, bottom: 10 }
|
||||
});
|
||||
|
||||
// 4. 필터링 로직
|
||||
function updateLogs() {
|
||||
const query = document.getElementById('logSearch').value.toLowerCase();
|
||||
const showInfo = document.getElementById('checkInfo').checked;
|
||||
const showWarn = document.getElementById('checkWarn').checked;
|
||||
const showError = document.getElementById('checkError').checked;
|
||||
|
||||
const filtered = allLogs.filter(line => {
|
||||
const lower = line.toLowerCase();
|
||||
|
||||
// 레벨 체크 (매우 단순화)
|
||||
let levelMatch = false;
|
||||
|
||||
const isError = lower.includes('[error]') || lower.includes('error:') || lower.includes('traceback');
|
||||
const isWarn = lower.includes('[warning]') || lower.includes('[warn]') || lower.includes('warn:');
|
||||
const isInfo = lower.includes('[info]') || lower.includes('info:');
|
||||
|
||||
if (isError) {
|
||||
if (showError) levelMatch = true;
|
||||
} else if (isWarn) {
|
||||
if (showWarn) levelMatch = true;
|
||||
} else if (isInfo) {
|
||||
if (showInfo) levelMatch = true;
|
||||
} else {
|
||||
// 레벨 키워드가 없는 줄은 기본적으로 표시 (맥락 유지)
|
||||
levelMatch = true;
|
||||
}
|
||||
|
||||
if (!levelMatch) return false;
|
||||
|
||||
// 검색어 체크
|
||||
if (query && !lower.includes(query)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 현재 스크롤 위치 저장? 아니면 항상 아래로? -> 보통 필터링하면 아래로 가는게 편함
|
||||
const currentModel = editor.getModel();
|
||||
if (currentModel) {
|
||||
currentModel.setValue(filtered.join('\n'));
|
||||
}
|
||||
// editor.revealLine(editor.getModel().getLineCount());
|
||||
}
|
||||
|
||||
// 이벤트 연결
|
||||
document.getElementById('logSearch').addEventListener('keyup', updateLogs);
|
||||
document.getElementById('checkInfo').addEventListener('change', updateLogs);
|
||||
document.getElementById('checkWarn').addEventListener('change', updateLogs);
|
||||
document.getElementById('checkError').addEventListener('change', updateLogs);
|
||||
|
||||
// 맨 아래로 버튼
|
||||
document.getElementById('btnScrollBottom').addEventListener('click', function () {
|
||||
editor.revealLine(editor.getModel().getLineCount());
|
||||
});
|
||||
|
||||
// 초기 스크롤 (약간의 지연 후)
|
||||
setTimeout(() => {
|
||||
editor.revealLine(editor.getModel().getLineCount());
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -29,21 +29,30 @@
|
||||
<!-- Skip to main content (접근성) -->
|
||||
<a href="#main-content" class="visually-hidden-focusable">본문으로 건너뛰기</a>
|
||||
|
||||
{# 플래시 메시지 (전역) #}
|
||||
{# 플래시 메시지 (좌측 상단 토스트 스타일) #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="position-fixed end-0 p-3" style="z-index: 2000; top: 70px;">
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 2000; margin-top: 60px;">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show shadow-lg" role="alert">
|
||||
<i class="bi bi-{{ 'check-circle' if cat == 'success' else 'exclamation-triangle' }} me-2"></i>
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
<div class="toast align-items-center text-white bg-{{ 'success' if cat == 'success' else 'danger' if cat == 'error' else 'primary' }} border-0 fade show"
|
||||
role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="3000">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body d-flex align-items-center">
|
||||
<i
|
||||
class="bi bi-{{ 'check-circle-fill' if cat == 'success' else 'exclamation-diamond-fill' }} me-2 fs-5"></i>
|
||||
<div>{{ msg }}</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
||||
<div class="container-fluid">
|
||||
@@ -110,6 +119,10 @@
|
||||
<!-- Main Content -->
|
||||
<main id="main-content"
|
||||
class="{% if request.endpoint in ['auth.login', 'auth.register', 'auth.reset_password'] %}container mt-5{% else %}container mt-4 container-card{% endif %}">
|
||||
|
||||
{# 플래시 메시지 (컨텐츠 상단 표시) #}
|
||||
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -133,6 +146,24 @@
|
||||
{% endif %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
<!-- Auto-hide Toasts -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var toastElList = [].slice.call(document.querySelectorAll('.toast'));
|
||||
var toastList = toastElList.map(function (toastEl) {
|
||||
// 부트스트랩 토스트 인스턴스 생성 (autohide: true 기본값)
|
||||
var toast = new bootstrap.Toast(toastEl, { delay: 3000 });
|
||||
toast.show();
|
||||
|
||||
// 3초 후 자동으로 DOM에서 제거하고 싶다면 이벤트 리스너 추가 가능
|
||||
toastEl.addEventListener('hidden.bs.toast', function () {
|
||||
// toastEl.remove(); // 필요시 제거
|
||||
});
|
||||
return toast;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,26 +1,170 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit XML File - Dell Server Info{% endblock %}
|
||||
{% block title %}XML 편집: {{ filename }} - Dell Server Info{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/edit_xml.css') }}">
|
||||
<style>
|
||||
/* 전체 레이아웃 */
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 160px);
|
||||
/* 헤더/푸터 제외 높이 (조정 가능) */
|
||||
min-height: 600px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 툴바 (헤더) */
|
||||
.editor-toolbar {
|
||||
background-color: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 에디터 본문 */
|
||||
#monaco-editor-root {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 로딩 인디케이터 */
|
||||
.editor-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #64748b;
|
||||
font-size: 1.2rem;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3>Edit XML File: <strong>{{ filename }}</strong></h3>
|
||||
<div class="container-fluid py-4 h-100">
|
||||
<!-- Breadcrumb / Navigation -->
|
||||
<div class="mb-3 d-flex align-items-center">
|
||||
<a href="{{ url_for('xml.xml_management') }}" class="text-decoration-none text-muted small fw-bold">
|
||||
<i class="bi bi-arrow-left me-1"></i>목록으로 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="form-group">
|
||||
<label for="xmlContent">XML Content</label>
|
||||
<textarea id="xmlContent" name="content" class="form-control" rows="20">{{ content }}</textarea>
|
||||
|
||||
<form id="editorForm" method="post" style="height: 100%;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<!-- Monaco Editor의 내용은 submit 시 이 textarea에 동기화됨 -->
|
||||
<textarea name="content" id="hiddenContent" style="display:none;">{{ content }}</textarea>
|
||||
|
||||
<div class="editor-container">
|
||||
<!-- Toolbar -->
|
||||
<div class="editor-toolbar">
|
||||
<div class="editor-title">
|
||||
<i class="bi bi-filetype-xml text-primary fs-4"></i>
|
||||
<span>{{ filename }}</span>
|
||||
<span class="badge bg-light text-secondary border ms-2">XML</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<!-- 포맷팅 버튼 (Monaco 기능 호출) -->
|
||||
<button type="button" class="btn btn-white border text-dark btn-sm fw-bold" id="btnFormat">
|
||||
<i class="bi bi-magic me-1 text-info"></i> 자동 정렬
|
||||
</button>
|
||||
<!-- 저장 버튼 -->
|
||||
<button type="submit" class="btn btn-primary btn-sm fw-bold px-4">
|
||||
<i class="bi bi-save me-1"></i> 저장하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success mt-3">Save Changes</button>
|
||||
<a href="{{ url_for('xml.xml_management') }}" class="btn btn-secondary mt-3">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Editor Area -->
|
||||
<div id="monaco-editor-root">
|
||||
<div class="editor-loading">
|
||||
<div class="spinner-border text-primary me-3" role="status"></div>
|
||||
<div>에디터를 불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- Monaco Editor Loader -->
|
||||
<script src="https://unpkg.com/monaco-editor@0.45.0/min/vs/loader.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof require === 'undefined') {
|
||||
document.querySelector('.editor-loading').innerHTML =
|
||||
'<div class="text-danger"><i class="bi bi-exclamation-triangle me-2"></i>Monaco Editor를 로드할 수 없습니다. 인터넷 연결을 확인하거나 CDN 차단을 확인하세요.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
require.config({ paths: { 'vs': 'https://unpkg.com/monaco-editor@0.45.0/min/vs' } });
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
// 초기 컨텐츠 가져오기
|
||||
var initialContent = document.getElementById('hiddenContent').value;
|
||||
var container = document.getElementById('monaco-editor-root');
|
||||
|
||||
// 기존 로딩 메시지 제거
|
||||
container.innerHTML = '';
|
||||
|
||||
// 에디터 생성
|
||||
var editor = monaco.editor.create(container, {
|
||||
value: initialContent,
|
||||
language: 'xml',
|
||||
theme: 'vs', // or 'vs-dark'
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: 'on',
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
wordWrap: 'on'
|
||||
});
|
||||
|
||||
// 1. 폼 제출 시 에디터 내용을 textarea에 동기화
|
||||
document.getElementById('editorForm').addEventListener('submit', function () {
|
||||
document.getElementById('hiddenContent').value = editor.getValue();
|
||||
});
|
||||
|
||||
// 2. 자동 정렬(Format) 버튼 기능 연결
|
||||
document.getElementById('btnFormat').addEventListener('click', function () {
|
||||
editor.getAction('editor.action.formatDocument').run();
|
||||
});
|
||||
|
||||
// 3. Ctrl+S 저장 단축키 지원
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function () {
|
||||
document.getElementById('editorForm').requestSubmit();
|
||||
});
|
||||
|
||||
}, function (err) {
|
||||
// 로드 실패 시 에러 표시
|
||||
document.querySelector('.editor-loading').innerHTML =
|
||||
'<div class="text-danger"><i class="bi bi-exclamation-triangle me-2"></i>에디터 리소스 로드 실패: ' + err.message + '</div>';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -21,30 +21,39 @@
|
||||
{# IP 처리 카드 #}
|
||||
<div class="col-lg-6">
|
||||
<div class="card border shadow-sm h-100">
|
||||
<div class="card-header bg-primary text-white border-0 py-2">
|
||||
<h6 class="mb-0 fw-semibold">
|
||||
<div class="card-header bg-light border-0 py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-hdd-network me-2"></i>
|
||||
IP 처리
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form id="ipForm" method="post" action="{{ url_for('main.process_ips') }}">
|
||||
<div class="card-body p-4 h-100 d-flex flex-column">
|
||||
<form id="ipForm" method="post" action="{{ url_for('main.process_ips') }}" class="h-100 d-flex flex-column">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{# 스크립트 선택 #}
|
||||
<div class="mb-3">
|
||||
<label for="script" class="form-label">스크립트 선택</label>
|
||||
<select id="script" name="script" class="form-select" required>
|
||||
<select id="script" name="script" class="form-select" required autocomplete="off">
|
||||
<option value="">스크립트를 선택하세요</option>
|
||||
{% if grouped_scripts %}
|
||||
{% for category, s_list in grouped_scripts.items() %}
|
||||
<optgroup label="{{ category }}">
|
||||
{% for script in s_list %}
|
||||
<option value="{{ script }}">{{ script }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# 만약 grouped_scripts가 없는 경우(하위 호환) #}
|
||||
{% for script in scripts %}
|
||||
<option value="{{ script }}">{{ script }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# XML 파일 선택 (조건부) #}
|
||||
<div class="mb-3" id="xmlFileGroup" style="display:none;">
|
||||
<label for="xmlFile" class="form-label">XML 파일 선택</label>
|
||||
<select id="xmlFile" name="xmlFile" class="form-select">
|
||||
<option value="">XML 파일 선택</option>
|
||||
{% for xml_file in xml_files %}
|
||||
@@ -54,18 +63,32 @@
|
||||
</div>
|
||||
|
||||
{# IP 주소 입력 #}
|
||||
<div class="mb-3">
|
||||
<label for="ips" class="form-label">
|
||||
IP 주소 (각 줄에 하나)
|
||||
<span class="badge bg-secondary ms-2" id="ipLineCount">0 대설정</span>
|
||||
<div class="mb-3 flex-grow-1 d-flex flex-column">
|
||||
<label for="ips" class="form-label w-100 d-flex justify-content-between align-items-end mb-2">
|
||||
<span class="mb-1">
|
||||
IP 주소
|
||||
<span class="badge bg-secondary ms-1" id="ipLineCount">0</span>
|
||||
</span>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="btnStartScan"
|
||||
title="10.10.0.1 ~ 255 자동 스캔">
|
||||
<i class="bi bi-search me-1"></i>IP 자동 스캔 (10.10.0.x)
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<textarea id="ips" name="ips" rows="4" class="form-control font-monospace"
|
||||
placeholder="예: 192.168.1.1 192.168.1.2 192.168.1.3" required></textarea>
|
||||
<textarea id="ips" name="ips" class="form-control font-monospace flex-grow-1"
|
||||
placeholder="예: 192.168.1.1 192.168.1.2" required style="resize: none;"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
처리
|
||||
</button>
|
||||
<div class="mt-auto">
|
||||
<button type="submit"
|
||||
class="btn btn-white bg-white border shadow-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move">
|
||||
<div class="rounded-circle bg-primary bg-opacity-10 text-primary p-1">
|
||||
<i class="bi bi-play-circle-fill fs-5"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.8rem;">처리 시작</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,8 +97,8 @@
|
||||
{# 공유 작업 카드 #}
|
||||
<div class="col-lg-6">
|
||||
<div class="card border shadow-sm h-100">
|
||||
<div class="card-header bg-success text-white border-0 py-2">
|
||||
<h6 class="mb-0 fw-semibold">
|
||||
<div class="card-header bg-light border-0 py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-share me-2"></i>
|
||||
공유 작업
|
||||
</h6>
|
||||
@@ -93,16 +116,34 @@
|
||||
style="font-size: 0.95rem;" placeholder="서버 리스트를 입력하세요..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_server_list') }}" class="btn btn-secondary">
|
||||
MAC to Excel
|
||||
</button>
|
||||
<button type="submit" formaction="{{ url_for('utils.update_guid_list') }}" class="btn btn-success">
|
||||
GUID to Excel
|
||||
</button>
|
||||
<button type="submit" formaction="{{ url_for('utils.update_gpu_list') }}" class="btn btn-warning">
|
||||
GPU to Excel
|
||||
</button>
|
||||
<div class="row g-2">
|
||||
<div class="col-4">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_server_list') }}"
|
||||
class="btn btn-white bg-white border shadow-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move h-100">
|
||||
<div class="rounded-circle bg-primary bg-opacity-10 text-primary p-1">
|
||||
<i class="bi bi-file-earmark-spreadsheet fs-5"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.8rem;">MAC to Excel</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_guid_list') }}"
|
||||
class="btn btn-white bg-white border shadow-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move h-100">
|
||||
<div class="rounded-circle bg-success bg-opacity-10 text-success p-1">
|
||||
<i class="bi bi-file-earmark-excel fs-5"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.8rem;">GUID to Excel</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button type="submit" formaction="{{ url_for('utils.update_gpu_list') }}"
|
||||
class="btn btn-white bg-white border shadow-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move h-100">
|
||||
<div class="rounded-circle bg-danger bg-opacity-10 text-danger p-1">
|
||||
<i class="bi bi-gpu-card fs-5"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.8rem;">GPU to Excel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -142,59 +183,107 @@
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4 file-tools">
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xxl-5 g-3 align-items-end">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
|
||||
<!-- ZIP 다운로드 -->
|
||||
<div class="col">
|
||||
<label class="form-label text-nowrap">ZIP 다운로드</label>
|
||||
<form method="post" action="{{ url_for('main.download_zip') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" name="zip_filename" placeholder="파일명" required>
|
||||
<button class="btn btn-primary" type="submit">다운로드</button>
|
||||
<!-- 상단: 입력형 도구 (다운로드/백업) -->
|
||||
<div class="row g-2">
|
||||
<!-- ZIP 다운로드 -->
|
||||
<div class="col-6">
|
||||
<div class="card h-100 border-primary-subtle bg-primary-subtle bg-opacity-10">
|
||||
<div class="card-body p-2 d-flex flex-column justify-content-center">
|
||||
<h6 class="card-title fw-bold text-primary mb-1 small" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-file-earmark-zip me-1"></i>ZIP
|
||||
</h6>
|
||||
<form method="post" action="{{ url_for('main.download_zip') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control border-primary-subtle form-control-sm"
|
||||
name="zip_filename" placeholder="파일명" required
|
||||
style="font-size: 0.75rem; padding: 0.2rem 0.5rem;">
|
||||
<button class="btn btn-primary btn-sm px-2" type="submit">
|
||||
<i class="bi bi-download" style="font-size: 0.75rem;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 파일 백업 -->
|
||||
<div class="col">
|
||||
<label class="form-label text-nowrap">파일 백업</label>
|
||||
<form method="post" action="{{ url_for('main.backup_files') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" name="backup_prefix" placeholder="PO로 시작">
|
||||
<button class="btn btn-success" type="submit">백업</button>
|
||||
<!-- 파일 백업 -->
|
||||
<div class="col-6">
|
||||
<div class="card h-100 border-success-subtle bg-success-subtle bg-opacity-10">
|
||||
<div class="card-body p-2 d-flex flex-column justify-content-center">
|
||||
<h6 class="card-title fw-bold text-success mb-1 small" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-hdd-network me-1"></i>백업
|
||||
</h6>
|
||||
<form method="post" action="{{ url_for('main.backup_files') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control border-success-subtle form-control-sm"
|
||||
name="backup_prefix" placeholder="Prefix" style="font-size: 0.75rem; padding: 0.2rem 0.5rem;">
|
||||
<button class="btn btn-success btn-sm px-2" type="submit">
|
||||
<i class="bi bi-save" style="font-size: 0.75rem;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MAC 파일 이동 -->
|
||||
<div class="col">
|
||||
<label class="form-label text-nowrap">MAC 파일 이동</label>
|
||||
<form id="macMoveForm" method="post" action="{{ url_for('utils.move_mac_files') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-warning w-100" type="submit">MAC Move</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- 하단: 원클릭 액션 (파일 정리) -->
|
||||
<div class="card bg-light border-0">
|
||||
<div class="card-body p-3">
|
||||
<small class="text-muted fw-bold text-uppercase mb-2 d-block">
|
||||
<i class="bi bi-folder-symlink me-1"></i>파일 정리 (Quick Move)
|
||||
</small>
|
||||
<div class="row g-2">
|
||||
<!-- MAC Move -->
|
||||
<div class="col-4">
|
||||
<form id="macMoveForm" method="post" action="{{ url_for('utils.move_mac_files') }}" class="h-100">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button
|
||||
class="btn btn-white bg-white border shadow-sm w-100 h-100 py-1 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move"
|
||||
type="submit">
|
||||
<div class="rounded-circle bg-primary bg-opacity-10 text-primary p-1">
|
||||
<i class="bi bi-cpu fs-6"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.75rem;">MAC</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- GUID 파일 이동 -->
|
||||
<div class="col">
|
||||
<label class="form-label text-nowrap">GUID 파일 이동</label>
|
||||
<form id="guidMoveForm" method="post" action="{{ url_for('utils.move_guid_files') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-info w-100" type="submit">GUID Move</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- GUID Move -->
|
||||
<div class="col-4">
|
||||
<form id="guidMoveForm" method="post" action="{{ url_for('utils.move_guid_files') }}" class="h-100">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button
|
||||
class="btn btn-white bg-white border shadow-sm w-100 h-100 py-1 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move"
|
||||
type="submit">
|
||||
<div class="rounded-circle bg-success bg-opacity-10 text-success p-1">
|
||||
<i class="bi bi-fingerprint fs-6"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.75rem;">GUID</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- GPU 파일 이동 -->
|
||||
<div class="col">
|
||||
<label class="form-label text-nowrap">GPU 파일 이동</label>
|
||||
<form id="gpuMoveForm" method="post" action="{{ url_for('utils.move_gpu_files') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-secondary w-100" type="submit">GPU Move</button>
|
||||
</form>
|
||||
<div class="col-4">
|
||||
<form id="gpuMoveForm" method="post" action="{{ url_for('utils.move_gpu_files') }}" class="h-100">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button
|
||||
class="btn btn-white bg-white border shadow-sm w-100 h-100 py-1 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move"
|
||||
type="submit">
|
||||
<div class="rounded-circle bg-danger bg-opacity-10 text-danger p-1">
|
||||
<i class="bi bi-gpu-card fs-6"></i>
|
||||
</div>
|
||||
<span class="fw-medium text-dark" style="font-size: 0.75rem;">GPU</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -386,15 +475,74 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||
<!-- Tom Select CSS (Bootstrap 5 theme) -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.bootstrap5.min.css" rel="stylesheet">
|
||||
<style>
|
||||
/* Tom Select 미세 조정 */
|
||||
.ts-wrapper.form-select {
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ts-control {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
.ts-wrapper.focus .ts-control {
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
/* Quick Move 버튼 호버 효과 */
|
||||
.btn-quick-move {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-quick-move:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .1) !important;
|
||||
background-color: #f8f9fa !important;
|
||||
border-color: #dee2e6 !important;
|
||||
}
|
||||
|
||||
.btn-quick-move:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
|
||||
<!-- Tom Select JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Tom Select 초기화
|
||||
// 모바일 등 환경 고려, 검색 가능하게 설정
|
||||
if (document.getElementById('script')) {
|
||||
new TomSelect("#script", {
|
||||
create: false,
|
||||
sortField: {
|
||||
field: "text",
|
||||
direction: "asc"
|
||||
},
|
||||
placeholder: "스크립트를 검색하거나 선택하세요...",
|
||||
plugins: ['clear_button'],
|
||||
allowEmptyOption: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/index.js') }}"></script>
|
||||
|
||||
<!-- 외부 script.js 파일 (IP 폼 처리 로직 포함) -->
|
||||
|
||||
@@ -1,215 +1,454 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}XML 파일 관리 & 배포 - Dell Server Info{% endblock %}
|
||||
{% block title %}XML 설정 관리 & 배포 - Dell Server Info{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<!-- Existing SCP CSS for legacy support or specific components -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/scp.css') }}">
|
||||
<!-- Overriding/New Styles for Modern Look -->
|
||||
<style>
|
||||
/* 드래그 앤 드롭 영역 스타일 */
|
||||
.drop-zone {
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
background-color: #f8fafc;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone.dragover {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.drop-zone-icon {
|
||||
font-size: 2.5rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.drop-zone-text {
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.drop-zone-hint {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.drop-zone input[type="file"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 카드 그리드 스타일 (index.html과 유사) */
|
||||
.xml-file-card {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.xml-file-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.file-icon-wrapper {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
padding: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="main-title">설정 파일 관리 (SCP)</h1>
|
||||
<p class="subtitle">iDRAC 서버 설정(XML)을 내보내거나 가져오고, 버전을 비교할 수 있습니다.</p>
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<div class="row">
|
||||
<!-- 왼쪽: 파일 업로드 및 내보내기 -->
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header-custom">
|
||||
<span><i class="fas fa-cloud-upload-alt me-2"></i>파일 등록</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 1. PC에서 업로드 -->
|
||||
<h6 class="mb-3"><i class="fas fa-laptop me-2"></i>PC에서 업로드</h6>
|
||||
<form action="{{ url_for('xml.upload_xml') }}" method="POST" enctype="multipart/form-data" class="mb-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="upload-section">
|
||||
<div class="mb-2">
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="xmlFile" name="xmlFile" accept=".xml"
|
||||
onchange="updateFileName(this)">
|
||||
<label class="custom-file-label" for="xmlFile" id="fileLabel">파일 선택</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-upload me-1"></i>업로드
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- 2. iDRAC에서 내보내기 -->
|
||||
<h6 class="mb-3"><i class="fas fa-server me-2"></i>iDRAC에서 추출 (Export)</h6>
|
||||
<button type="button" class="btn btn-outline-primary w-100" data-bs-toggle="modal"
|
||||
data-bs-target="#exportModal">
|
||||
<i class="fas fa-download me-1"></i>설정 추출하기
|
||||
</button>
|
||||
</div>
|
||||
<!-- Header Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2 class="fw-bold mb-1">
|
||||
<i class="bi bi-file-earmark-code text-primary me-2"></i>
|
||||
설정 파일 관리
|
||||
</h2>
|
||||
<p class="text-muted mb-0">서버 설정(XML) 파일을 업로드, 관리 및 배포합니다.</p>
|
||||
</div>
|
||||
<div class="col-auto align-self-end">
|
||||
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#exportModal">
|
||||
<i class="bi bi-server me-2"></i>iDRAC에서 추출
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 파일 목록 및 작업 -->
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header-custom">
|
||||
<span><i class="fas fa-list me-2"></i>파일 목록</span>
|
||||
<button class="btn btn-light btn-sm text-primary" id="compareBtn"
|
||||
data-url="{{ url_for('scp.diff_scp') }}" onclick="compareSelected()">
|
||||
<i class="fas fa-exchange-alt me-1"></i>선택 비교
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if xml_files %}
|
||||
<div class="file-list">
|
||||
{% for xml_file in xml_files %}
|
||||
<div class="icon-badge-item">
|
||||
<div class="icon-badge-left">
|
||||
<input type="checkbox" class="select-checkbox file-selector" value="{{ xml_file }}">
|
||||
<div class="file-icon-small">
|
||||
<i class="fas fa-file-code"></i>
|
||||
<div class="row g-4">
|
||||
<!-- Left: Upload Section (30% on large screens) -->
|
||||
<div class="col-lg-4 col-xl-3">
|
||||
<div class="card border shadow-sm h-100">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||
<h6 class="fw-bold mb-0 text-dark">
|
||||
<i class="bi bi-cloud-upload me-2 text-primary"></i>파일 업로드
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('xml.upload_xml') }}" method="POST" enctype="multipart/form-data"
|
||||
id="uploadForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<input type="file" name="xmlFile" id="xmlFile" accept=".xml"
|
||||
onchange="handleFileSelect(this)">
|
||||
<div class="drop-zone-icon">
|
||||
<i class="bi bi-file-earmark-arrow-up"></i>
|
||||
</div>
|
||||
<div class="file-name-section">
|
||||
<span class="file-name-badge" title="{{ xml_file }}">{{ xml_file }}</span>
|
||||
<span class="badge-custom">XML</span>
|
||||
<div class="drop-zone-text" id="dropZoneText">
|
||||
클릭하여 파일 선택<br>또는 파일을 여기로 드래그
|
||||
</div>
|
||||
<div class="drop-zone-hint">XML 파일만 지원됩니다.</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<!-- 배포 버튼 -->
|
||||
<button type="button" class="btn btn-info btn-sm text-white"
|
||||
onclick="openDeployModal('{{ xml_file }}')">
|
||||
<i class="fas fa-plane-departure"></i> <span>배포</span>
|
||||
</button>
|
||||
<!-- 편집 버튼 -->
|
||||
<a href="{{ url_for('xml.edit_xml', filename=xml_file) }}" class="btn btn-success btn-sm">
|
||||
<i class="fas fa-edit"></i> <span>편집</span>
|
||||
</a>
|
||||
<!-- 삭제 버튼 -->
|
||||
<form action="{{ url_for('xml.delete_xml', filename=xml_file) }}" method="POST"
|
||||
style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<button type="submit" class="btn btn-danger btn-sm"
|
||||
onclick="return confirm('정말 삭제하시겠습니까?')">
|
||||
<i class="fas fa-trash"></i> <span>삭제</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 mt-3 shadow-sm">
|
||||
<i class="bi bi-upload me-2"></i>업로드 시작
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="alert alert-light mt-4 border" role="alert">
|
||||
<h6 class="alert-heading fs-6 fw-bold"><i class="bi bi-info-circle me-2"></i>도움말</h6>
|
||||
<p class="mb-0 fs-small text-muted" style="font-size: 0.85rem;">
|
||||
업로드된 XML 파일을 사용하여 여러 서버에 동일한 설정을 일괄 배포할 수 있습니다.
|
||||
'비교' 기능을 사용하여 버전 간 차이를 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-message">
|
||||
<i class="fas fa-folder-open" style="font-size: 2rem; color: #ddd;"></i>
|
||||
<p class="mt-2 mb-0">파일이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: File List (70%) -->
|
||||
<div class="col-lg-8 col-xl-9">
|
||||
<div class="card border shadow-sm h-100">
|
||||
<div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<h6 class="fw-bold mb-0 text-dark me-3">
|
||||
<i class="bi bi-list-check me-2 text-success"></i>파일 목록
|
||||
</h6>
|
||||
<span class="badge bg-light text-dark border">{{ xml_files|length }}개 파일</span>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="compareBtn"
|
||||
data-url="{{ url_for('scp.diff_scp') }}" onclick="compareSelected()">
|
||||
<i class="bi bi-arrow-left-right me-1"></i>선택 비교
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body bg-light">
|
||||
{% if xml_files %}
|
||||
<!-- 카드 크기 조정: 한 줄에 4개(xxl), 3개(xl) 등으로 조금 더 키움 -->
|
||||
<div class="row row-cols-1 row-cols-lg-2 row-cols-xl-3 row-cols-xxl-4 g-3">
|
||||
{% for xml_file in xml_files %}
|
||||
<div class="col">
|
||||
<div class="xml-file-card position-relative p-3 h-100 d-flex flex-column">
|
||||
<div class="position-absolute top-0 end-0 p-2 me-1">
|
||||
<input type="checkbox" class="form-check-input file-selector border-secondary"
|
||||
value="{{ xml_file }}" style="cursor: pointer;">
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="file-icon-wrapper me-3 mb-0 shadow-sm"
|
||||
style="width: 42px; height: 42px; font-size: 1.4rem;">
|
||||
<i class="bi bi-filetype-xml"></i>
|
||||
</div>
|
||||
<div class="file-name text-truncate fw-bold mb-0 text-dark"
|
||||
style="max-width: 140px; font-size: 0.95rem;" title="{{ xml_file }}">
|
||||
{{ xml_file }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-3 border-top">
|
||||
<div class="d-flex gap-2">
|
||||
<!-- 배포 버튼 -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-primary flex-fill d-flex align-items-center justify-content-center gap-1"
|
||||
onclick="openDeployModal('{{ xml_file }}')" title="배포">
|
||||
<i class="bi bi-send-fill"></i> <span class="small fw-bold">배포</span>
|
||||
</button>
|
||||
|
||||
<!-- 편집 버튼 -->
|
||||
<a href="{{ url_for('xml.edit_xml', filename=xml_file) }}"
|
||||
class="btn btn-sm btn-white border flex-fill d-flex align-items-center justify-content-center gap-1 text-dark bg-white"
|
||||
title="편집">
|
||||
<i class="bi bi-pencil-fill text-secondary"></i> <span
|
||||
class="small fw-bold">편집</span>
|
||||
</a>
|
||||
|
||||
<!-- 삭제 버튼 -->
|
||||
<form action="{{ url_for('xml.delete_xml', filename=xml_file) }}" method="POST"
|
||||
class="d-flex flex-fill m-0" style="min-width: 0;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-white border w-100 d-flex align-items-center justify-content-center gap-1 text-danger bg-white"
|
||||
onclick="return confirm('정말 삭제하시겠습니까?')" title="삭제">
|
||||
<i class="bi bi-trash-fill"></i> <span class="small fw-bold">삭제</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5 my-5">
|
||||
<div class="mb-3 text-secondary" style="font-size: 3rem; opacity: 0.3;">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
</div>
|
||||
<h5 class="text-secondary fw-normal">등록된 파일이 없습니다.</h5>
|
||||
<p class="text-muted">좌측 패널에서 XML 파일을 업로드해주세요.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Modal -->
|
||||
<!-- Export Modal (Include existing modal logic but restyled) -->
|
||||
<div class="modal fade" id="exportModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg">
|
||||
<form action="{{ url_for('scp.export_scp') }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">iDRAC 설정 내보내기</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title fs-6 fw-bold"><i class="bi bi-download me-2"></i>iDRAC 설정 내보내기</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info py-2" style="font-size: 0.9rem;">
|
||||
<i class="fas fa-info-circle me-1"></i> 네트워크 공유 폴더(CIFS)가 필요합니다.
|
||||
<div class="modal-body p-4">
|
||||
<div class="alert alert-info py-2 small mb-4">
|
||||
<i class="bi bi-info-circle-fill me-2"></i> CIFS 네트워크 공유 폴더가 필요합니다.
|
||||
</div>
|
||||
|
||||
<h6>대상 iDRAC</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="target_ip" placeholder="iDRAC IP"
|
||||
required></div>
|
||||
<div class="row mb-3">
|
||||
<div class="col"><input type="text" class="form-control" name="username" placeholder="User"
|
||||
required></div>
|
||||
<div class="col"><input type="password" class="form-control" name="password"
|
||||
placeholder="Password" required></div>
|
||||
<h6 class="text-primary fw-bold mb-3 small text-uppercase">대상 iDRAC</h6>
|
||||
<div class="form-floating mb-2">
|
||||
<input type="text" class="form-control" id="targetIp" name="target_ip" placeholder="IP"
|
||||
required>
|
||||
<label for="targetIp">iDRAC IP Address</label>
|
||||
</div>
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" id="targetUser" name="username"
|
||||
placeholder="User" required>
|
||||
<label for="targetUser">Username</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="password" class="form-control" id="targetPwd" name="password"
|
||||
placeholder="Pwd" required>
|
||||
<label for="targetPwd">Password</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h6>네트워크 공유 (저장소)</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_ip"
|
||||
placeholder="Share Server IP" required></div>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_name"
|
||||
placeholder="Share Name (e.g. public)" required></div>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="filename"
|
||||
placeholder="Save Filename (e.g. backup.xml)" required></div>
|
||||
<div class="row">
|
||||
<div class="col"><input type="text" class="form-control" name="share_user"
|
||||
placeholder="Share User"></div>
|
||||
<div class="col"><input type="password" class="form-control" name="share_pwd"
|
||||
placeholder="Share Password"></div>
|
||||
<h6 class="text-success fw-bold mb-3 small text-uppercase">저장소 (CIFS Share)</h6>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-8">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_ip" placeholder="IP" required>
|
||||
<label>Share IP</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_name" placeholder="Name" required>
|
||||
<label>Share Name</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-floating mb-2">
|
||||
<input type="text" class="form-control" name="filename" placeholder="Filename" required>
|
||||
<label>저장할 파일명 (예: backup.xml)</label>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_user" placeholder="User">
|
||||
<label>Share User</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="password" class="form-control" name="share_pwd" placeholder="Pwd">
|
||||
<label>Share Password</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-primary">내보내기 시작</button>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-primary px-4">내보내기 실행</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy (Import) Modal -->
|
||||
<!-- Deploy Modal -->
|
||||
<div class="modal fade" id="deployModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg">
|
||||
<form action="{{ url_for('scp.import_scp') }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">설정 배포 (Import)</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title fs-6 fw-bold"><i class="bi bi-send-fill me-2"></i>설정 배포 (Import)</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning py-2" style="font-size: 0.9rem;">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i> 적용 후 서버가 재부팅될 수 있습니다.
|
||||
<div class="modal-body p-4">
|
||||
<div class="alert alert-warning py-2 small mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i> 적용 후 서버가 재부팅될 수 있습니다.
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">배포할 파일</label>
|
||||
<input type="text" class="form-control" id="deployFilename" name="filename" readonly>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold small text-muted">배포 파일</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light"><i class="bi bi-file-code"></i></span>
|
||||
<input type="text" class="form-control fw-bold text-primary" id="deployFilename"
|
||||
name="filename" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>대상 iDRAC</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="target_ip" placeholder="iDRAC IP"
|
||||
required></div>
|
||||
<div class="row mb-3">
|
||||
<div class="col"><input type="text" class="form-control" name="username" placeholder="User"
|
||||
required></div>
|
||||
<div class="col"><input type="password" class="form-control" name="password"
|
||||
placeholder="Password" required></div>
|
||||
<h6 class="text-primary fw-bold mb-3 small text-uppercase">대상 iDRAC</h6>
|
||||
<div class="form-floating mb-2">
|
||||
<input type="text" class="form-control" name="target_ip" placeholder="IP" required>
|
||||
<label>iDRAC IP</label>
|
||||
</div>
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="username" placeholder="User" required>
|
||||
<label>Username</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="password" class="form-control" name="password" placeholder="Pwd" required>
|
||||
<label>Password</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h6>네트워크 공유 (소스 위치)</h6>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_ip"
|
||||
placeholder="Share Server IP" required></div>
|
||||
<div class="mb-2"><input type="text" class="form-control" name="share_name" placeholder="Share Name"
|
||||
required></div>
|
||||
<div class="row mb-3">
|
||||
<div class="col"><input type="text" class="form-control" name="share_user"
|
||||
placeholder="Share User"></div>
|
||||
<div class="col"><input type="password" class="form-control" name="share_pwd"
|
||||
placeholder="Share Password"></div>
|
||||
<h6 class="text-success fw-bold mb-3 small text-uppercase">소스 위치 (CIFS Share)</h6>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-8">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_ip" placeholder="IP" required>
|
||||
<label>Share IP</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_name" placeholder="Name" required>
|
||||
<label>Share Name</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" name="share_user" placeholder="User">
|
||||
<label>Share User</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="password" class="form-control" name="share_pwd" placeholder="Pwd">
|
||||
<label>Share Password</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">적용 모드</label>
|
||||
<select class="form-select" name="import_mode">
|
||||
<div class="form-floating">
|
||||
<select class="form-select" name="import_mode" id="importMode">
|
||||
<option value="Replace">전체 교체 (Replace)</option>
|
||||
<option value="Append">변경분만 적용 (Append)</option>
|
||||
</select>
|
||||
<label for="importMode">적용 모드</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-danger">배포 시작</button>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-danger px-4">배포 시작</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -220,4 +459,67 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/scp.js') }}"></script>
|
||||
{% endblock %}
|
||||
<script>
|
||||
// 드래그 앤 드롭 파일 처리
|
||||
function handleFileSelect(input) {
|
||||
const fileName = input.files[0]?.name;
|
||||
const dropZoneText = document.getElementById('dropZoneText');
|
||||
if (fileName) {
|
||||
dropZoneText.innerHTML = `<span class="text-primary fw-bold">${fileName}</span><br><span class="text-muted small">파일이 선택되었습니다.</span>`;
|
||||
document.getElementById('dropZone').classList.add('border-primary', 'bg-light');
|
||||
} else {
|
||||
dropZoneText.innerHTML = '클릭하여 파일 선택<br>또는 파일을 여기로 드래그';
|
||||
document.getElementById('dropZone').classList.remove('border-primary', 'bg-light');
|
||||
}
|
||||
}
|
||||
|
||||
// 드래그 효과
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, highlight, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, unhighlight, false);
|
||||
});
|
||||
|
||||
function highlight(e) {
|
||||
dropZone.classList.add('dragover');
|
||||
}
|
||||
|
||||
function unhighlight(e) {
|
||||
dropZone.classList.remove('dragover');
|
||||
}
|
||||
|
||||
dropZone.addEventListener('drop', handleDrop, false);
|
||||
|
||||
function handleDrop(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
const input = document.getElementById('xmlFile');
|
||||
|
||||
if (files.length > 0) {
|
||||
input.files = files; // input에 파일 할당
|
||||
handleFileSelect(input);
|
||||
}
|
||||
}
|
||||
|
||||
// 툴팁 초기화 및 자동 닫기
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
```
|
||||
@@ -1,58 +1,173 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}설정 파일 비교 - Dell Server Info{% endblock %}
|
||||
{% block title %}설정 파일 비교: {{ file1 }} vs {{ file2 }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/scp.css') }}">
|
||||
<!-- Monaco Diff Editor Styles -->
|
||||
<style>
|
||||
.diff-page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 140px);
|
||||
min-height: 600px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diff-toolbar {
|
||||
background-color: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.diff-files {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.file-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.diff-arrow {
|
||||
color: #94a3b8;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
#monaco-diff-root {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>설정 파일 비교</h2>
|
||||
<a href="{{ url_for('xml.xml_management') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> 목록으로
|
||||
<div class="container-fluid py-4 h-100">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('xml.xml_management') }}" class="text-decoration-none text-muted small fw-bold">
|
||||
<i class="bi bi-arrow-left me-1"></i>목록으로 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<i class="fas fa-exchange-alt me-2"></i>비교 대상
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-5">
|
||||
<h5>{{ file1 }}</h5>
|
||||
<div class="diff-page-container">
|
||||
<!-- Toolbar -->
|
||||
<div class="diff-toolbar">
|
||||
<div class="diff-files">
|
||||
<div class="file-badge text-danger border-danger-subtle bg-danger-subtle">
|
||||
<i class="bi bi-file-earmark-minus"></i>
|
||||
<span>{{ file1 }}</span> <!-- Original -->
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<i class="fas fa-arrow-right text-muted"></i>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<h5>{{ file2 }}</h5>
|
||||
<i class="bi bi-arrow-right diff-arrow"></i>
|
||||
<div class="file-badge text-success border-success-subtle bg-success-subtle">
|
||||
<i class="bi bi-file-earmark-plus"></i>
|
||||
<span>{{ file2 }}</span> <!-- Modified -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary" onclick="toggleInlineDiff()" id="viewToggleBtn">
|
||||
<i class="bi bi-layout-split me-1"></i>Inline View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monaco Diff Editor -->
|
||||
<div id="monaco-diff-root"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-code me-2"></i>Diff 결과
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="diff-container">
|
||||
{% for line in diff_content.splitlines() %}
|
||||
{% if line.startswith('+++') or line.startswith('---') %}
|
||||
<span class="diff-line diff-header">{{ line }}</span>
|
||||
{% elif line.startswith('+') %}
|
||||
<span class="diff-line diff-add">{{ line }}</span>
|
||||
{% elif line.startswith('-') %}
|
||||
<span class="diff-line diff-del">{{ line }}</span>
|
||||
{% else %}
|
||||
<span class="diff-line">{{ line }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Raw Content Hidden Inputs (Jinja2 will escape HTML entities automatically) -->
|
||||
<textarea id="hidden_content1" style="display:none;">{{ content1 }}</textarea>
|
||||
<textarea id="hidden_content2" style="display:none;">{{ content2 }}</textarea>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://unpkg.com/monaco-editor@0.45.0/min/vs/loader.js"></script>
|
||||
<script>
|
||||
let diffEditor = null;
|
||||
let isInline = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// 1. Check for loader failure
|
||||
if (typeof require === 'undefined') {
|
||||
document.getElementById('monaco-diff-root').innerHTML =
|
||||
'<div class="d-flex justify-content-center align-items-center h-100 text-danger">' +
|
||||
'<i class="bi bi-exclamation-triangle me-2"></i>Monaco Editor 리소스를 불러올 수 없습니다. 인터넷 연결을 확인하세요.' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
require.config({ paths: { 'vs': 'https://unpkg.com/monaco-editor@0.45.0/min/vs' } });
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
// 2. Read content from hidden textareas
|
||||
// Jinja2가 HTML escaping을 처리하므로, .value를 통해 원본 XML을 얻을 수 있습니다.
|
||||
const content1 = document.getElementById('hidden_content1').value;
|
||||
const content2 = document.getElementById('hidden_content2').value;
|
||||
|
||||
const originalModel = monaco.editor.createModel(content1, 'xml');
|
||||
const modifiedModel = monaco.editor.createModel(content2, 'xml');
|
||||
|
||||
const container = document.getElementById('monaco-diff-root');
|
||||
|
||||
// 3. Create Diff Editor
|
||||
diffEditor = monaco.editor.createDiffEditor(container, {
|
||||
theme: 'vs',
|
||||
originalEditable: false,
|
||||
readOnly: true,
|
||||
renderSideBySide: true, // Default: Split View
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: true },
|
||||
diffWordWrap: 'off'
|
||||
});
|
||||
|
||||
diffEditor.setModel({
|
||||
original: originalModel,
|
||||
modified: modifiedModel
|
||||
});
|
||||
|
||||
// 네비게이션 기능 추가
|
||||
diffEditor.getNavigator();
|
||||
|
||||
}, function (err) {
|
||||
document.getElementById('monaco-diff-root').innerHTML =
|
||||
'<div class="d-flex justify-content-center align-items-center h-100 text-danger">' +
|
||||
'<i class="bi bi-exclamation-triangle me-2"></i>에디터 로드 실패: ' + err.message + '</div>';
|
||||
});
|
||||
});
|
||||
|
||||
function toggleInlineDiff() {
|
||||
if (!diffEditor) return;
|
||||
|
||||
isInline = !isInline;
|
||||
diffEditor.updateOptions({
|
||||
renderSideBySide: !isInline
|
||||
});
|
||||
|
||||
const btn = document.getElementById('viewToggleBtn');
|
||||
if (isInline) {
|
||||
btn.innerHTML = '<i class="bi bi-layout-sidebar me-1"></i>Split View';
|
||||
} else {
|
||||
btn.innerHTML = '<i class="bi bi-layout-split me-1"></i>Inline View';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user