This commit is contained in:
unknown
2026-03-21 07:26:38 +09:00
parent 166d94fca4
commit 40150cd66d
406 changed files with 34179 additions and 55658 deletions
+2 -3
View File
@@ -12,8 +12,7 @@
/* 초기 높이 */
min-height: 300px;
/* 최소 높이 */
max-height: 1200px;
/* 최대 높이 (선택 사항) */
/* max-height 제거하여 제한 없이 확장 가능하게 함 */
background: #1e1e1e;
border: 1px solid #333;
border-radius: 6px;
@@ -21,7 +20,7 @@
overflow: hidden;
/* resize를 위해 필수 */
resize: vertical;
/* 수직 리사이징 활성화 */
/* 수직 리사이징 활성화 (수평 고정) */
position: relative;
/* 자식 요소 relative 기준 */
}
+10
View File
@@ -86,6 +86,16 @@
<i class="bi bi-list-task me-1"></i>Job Monitor
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('bios_baseline.index') }}">
<i class="bi bi-clipboard-data me-1"></i>BIOS Baseline
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('script_manager.index') }}">
<i class="bi bi-code-square me-1"></i>스크립트 관리
</a>
</li>
{% if current_user.is_admin %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.admin_panel') }}">
+512
View File
@@ -0,0 +1,512 @@
{% extends "base.html" %}
{% block content %}
<style>
/* 모달이 Navbar 위에 오도록 z-index 조정 */
.modal {
z-index: 1060 !important;
}
.modal-backdrop {
z-index: 1050 !important;
}
/* 모달 헤더가 항상 잘 보이도록 배경색 및 위치 조정 */
.modal-header {
background-color: #fff;
border-bottom: 1px solid #dee2e6;
padding: 1rem 1rem;
position: sticky;
top: 0;
z-index: 1070;
}
/* 모달이 너무 위로 붙지 않게 여백 추가 */
.modal-dialog {
margin-top: 5rem;
}
/* ===== 탭 가시성 개선 ===== */
/* Pills 스타일 탭 버튼 */
.nav-pills .nav-link {
color: #495057 !important;
background-color: #e9ecef !important;
border: 1px solid #ced4da !important;
margin-right: 0.25rem;
font-weight: 500;
padding: 0.5rem 1rem;
transition: all 0.2s ease;
}
.nav-pills .nav-link:hover {
background-color: #dee2e6 !important;
border-color: #adb5bd !important;
color: #212529 !important;
}
.nav-pills .nav-link.active {
background-color: #0d6efd !important;
border-color: #0d6efd !important;
color: #ffffff !important;
font-weight: 600;
}
/* Select 드롭다운 가시성 개선 */
.form-select {
border: 2px solid #ced4da !important;
font-weight: 500;
color: #212529 !important;
background-color: #ffffff !important;
}
.form-select:focus {
border-color: #0d6efd !important;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25) !important;
}
.form-select option {
color: #212529 !important;
background-color: #ffffff !important;
padding: 0.5rem;
}
/* 탭 컨테이너 배경 */
.nav-pills-container {
background-color: #f8f9fa;
padding: 0.75rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
}
</style>
<div class="container-fluid py-4">
<!-- 헤더 -->
<div class="row mb-4">
<div class="col">
<h2 class="fw-bold mb-1">
<i class="bi bi-clipboard-data text-primary me-2"></i>
BIOS Baseline 비교
</h2>
<p class="text-muted mb-0">납품 전 서버의 BIOS 설정을 표준 설정과 비교합니다</p>
</div>
</div>
<!-- 메인 작업 영역 -->
<div class="row g-4 mb-4">
<!-- 왼쪽: IP 입력 및 설정 -->
<div class="col-lg-5">
<div class="card border shadow-sm h-100">
<div class="card-header bg-light border-0 py-2">
<h6 class="mb-0">
<i class="bi bi-search me-2"></i>
비교 설정
</h6>
</div>
<div class="card-body p-4 d-flex flex-column">
<!-- Baseline 선택 -->
<div class="mb-3">
<label for="baseline-select" class="form-label">Baseline 선택</label>
<select id="baseline-select" class="form-select">
<option value="">Baseline을 선택하세요</option>
</select>
<button class="btn btn-sm btn-outline-primary mt-2 w-100" onclick="showBaselineManageModal()">
<i class="bi bi-plus-circle me-1"></i> 새 Baseline 생성
</button>
</div>
<!-- IP 주소 입력 -->
<div class="mb-3 flex-grow-1 d-flex flex-column">
<label for="ip-addresses"
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="ip-count">0</span>
</span>
<button type="button" class="btn btn-sm btn-outline-secondary px-2 py-1"
onclick="clearIPs()" title="입력 내용 지우기" style="font-size: 0.75rem;">
<i class="bi bi-trash me-1"></i>지우기
</button>
</label>
<textarea id="ip-addresses" class="form-control font-monospace flex-grow-1"
placeholder="예:&#10;10.10.0.1&#10;10.10.0.2&#10;10.10.0.3" style="resize: none;"></textarea>
</div>
<!-- 인증 정보 -->
<div class="row g-2 mb-3">
<div class="col-6">
<input type="text" id="username" class="form-control" value="root" placeholder="Username">
</div>
<div class="col-6">
<input type="password" id="password" class="form-control" placeholder="Password">
</div>
</div>
<!-- 비교 시작 버튼 -->
<button onclick="startComparison()" class="btn btn-primary w-100 py-2">
<i class="bi bi-arrows-angle-contract me-1"></i> 비교 시작
</button>
</div>
</div>
</div>
<!-- 오른쪽: 결과 요약 -->
<div class="col-lg-7">
<div class="card border shadow-sm h-100">
<div class="card-header bg-light border-0 py-2">
<h6 class="mb-0">
<i class="bi bi-bar-chart me-2"></i>
비교 결과 요약
</h6>
</div>
<div class="card-body p-4" id="summary-section">
<div class="text-center text-muted py-5">
<i class="bi bi-info-circle fs-1 mb-3"></i>
<p class="mb-0">비교를 시작하면 결과가 여기에 표시됩니다</p>
</div>
</div>
</div>
</div>
</div>
<!-- 진행률 -->
<div class="row mb-4" id="progress-section" style="display: none;">
<div class="col">
<div class="card border-0 shadow-sm">
<div class="card-body p-3">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-activity text-primary me-2"></i>
<span class="fw-semibold">처리 진행률</span>
</div>
<div class="progress" style="height: 25px;">
<div id="progress-bar"
class="progress-bar progress-bar-striped progress-bar-animated bg-success"
role="progressbar" style="width: 0%">
<span class="fw-semibold">0%</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 상세 비교 결과 -->
<div class="row" id="results-section" style="display: none;">
<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-list-check me-2"></i>
상세 비교 결과
</h6>
<div>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleDiffOnly()" id="diff-only-btn">
<i class="bi bi-funnel me-1"></i> 차이점만 보기
</button>
<button class="btn btn-sm btn-outline-primary" onclick="downloadResults()">
<i class="bi bi-download me-1"></i> Excel 다운로드
</button>
</div>
</div>
<div class="card-body p-4" id="results-container">
<!-- 서버별 결과가 여기에 표시됨 -->
</div>
</div>
</div>
</div>
</div>
<!-- Baseline 관리 모달 -->
<div class="modal fade" id="baseline-manage-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-clipboard-data me-2"></i>
Baseline 관리
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!---->
<div class="nav-pills-container">
<ul class="nav nav-pills mb-0" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="pill" data-bs-target="#create-tab">
새로 생성
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#list-tab">
목록 관리
</button>
</li>
</ul>
</div>
<div class="tab-content">
<!-- 생성 탭 -->
<div class="tab-pane fade show active" id="create-tab">
<form id="create-baseline-form">
<div class="mb-3">
<label class="form-label">서버 IP 주소 *</label>
<input type="text" class="form-control" id="create-ip" placeholder="10.10.0.1" required>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label">Username</label>
<input type="text" class="form-control" id="create-username" value="root">
</div>
<div class="col-6">
<label class="form-label">Password *</label>
<input type="password" class="form-control" id="create-password" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Baseline 이름 *</label>
<input type="text" class="form-control" id="create-name"
placeholder="R6615_GPU_Standard_2024" required>
</div>
<div class="mb-3">
<label class="form-label">서버 타입</label>
<input type="text" class="form-control" id="create-type" placeholder="GPU Server">
</div>
<div class="mb-3">
<label class="form-label">설명</label>
<textarea class="form-control" id="create-description" rows="2"
placeholder="표준 GPU 서버 설정"></textarea>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-download me-1"></i> Redfish로 가져와서 생성
</button>
</form>
</div>
<!-- 목록 탭 -->
<div class="tab-pane fade" id="list-tab">
<div id="baseline-list">
<div class="text-center text-muted py-4">
<i class="bi bi-hourglass-split"></i> 로딩 중...
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Baseline 상세 편집 모달 -->
<div class="modal fade" id="edit-baseline-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-pencil-square me-2"></i>
Baseline 상세 편집
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body bg-light">
<div class="row g-3">
<!-- 왼쪽: 메타데이터 편집 -->
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header bg-white fw-bold">기본 정보</div>
<div class="card-body">
<form id="edit-baseline-meta-form">
<input type="hidden" id="edit-id">
<div class="mb-3">
<label class="form-label small text-muted">Baseline 이름</label>
<input type="text" class="form-control" id="edit-name" required>
</div>
<div class="mb-3">
<label class="form-label small text-muted">서버 모델</label>
<input type="text" class="form-control bg-light" id="edit-model" readonly>
</div>
<div class="mb-3">
<label class="form-label small text-muted">서버 타입</label>
<input type="text" class="form-control" id="edit-type">
</div>
<div class="mb-3">
<label class="form-label small text-muted">설명</label>
<textarea class="form-control" id="edit-description" rows="4"></textarea>
</div>
<div class="text-muted small">
<p class="mb-1"><i class="bi bi-calendar me-1"></i>생성일: <span
id="edit-created-at">-</span></p>
<p class="mb-1"><i class="bi bi-cpu me-1"></i>원본 IP: <span
id="edit-source-ip">-</span></p>
</div>
</form>
</div>
</div>
</div>
<!-- 오른쪽: BIOS 설정 편집 -->
<!-- 오른쪽: 상세 설정 편집 -->
<div class="col-lg-8">
<div class="card h-100 border shadow-sm">
<div class="card-body p-3">
<!-- 탭 메뉴 -->
<div class="nav-pills-container">
<ul class="nav nav-pills mb-0" id="edit-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-edit-bios" data-bs-toggle="pill"
data-bs-target="#content-edit-bios" type="button">BIOS</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-edit-idrac" data-bs-toggle="pill"
data-bs-target="#content-edit-idrac" type="button">iDRAC</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-edit-raid" data-bs-toggle="pill"
data-bs-target="#content-edit-raid" type="button">RAID</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-edit-nic" data-bs-toggle="pill"
data-bs-target="#content-edit-nic" type="button">NIC</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-edit-boot" data-bs-toggle="pill"
data-bs-target="#content-edit-boot" type="button">Boot</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-edit-firmware" data-bs-toggle="pill"
data-bs-target="#content-edit-firmware" type="button">Firmware</button>
</li>
</ul>
</div>
<div class="tab-content h-100">
<!-- BIOS 탭 -->
<div class="tab-pane fade show active h-100" id="content-edit-bios">
<div
class="d-flex justify-content-between align-items-center p-2 border-bottom bg-light">
<span class="small text-muted fw-bold ms-2">항목 수: <span
id="edit-bios-count">0</span></span>
<button class="btn btn-sm btn-outline-primary bg-white"
onclick="addSettingRow('bios')">
<i class="bi bi-plus-lg"></i> 항목 추가
</button>
</div>
<div class="table-responsive" style="max-height: 450px;">
<table class="table table-hover mb-0" id="table-edit-bios">
<thead class="table-light sticky-top">
<tr>
<th style="width: 40%;">설정 항목 (Key)</th>
<th style="width: 40%;">설정값 (Value)</th>
<th style="width: 20%;">작업</th>
</tr>
</thead>
<tbody><!-- JS 채움 --></tbody>
</table>
</div>
</div>
<!-- iDRAC 탭 -->
<div class="tab-pane fade h-100" id="content-edit-idrac">
<div
class="d-flex justify-content-between align-items-center p-2 border-bottom bg-light">
<span class="small text-muted fw-bold ms-2">항목 수: <span
id="edit-idrac-count">0</span></span>
<button class="btn btn-sm btn-outline-primary bg-white"
onclick="addSettingRow('idrac')">
<i class="bi bi-plus-lg"></i> 항목 추가
</button>
</div>
<div class="table-responsive" style="max-height: 450px;">
<table class="table table-hover mb-0" id="table-edit-idrac">
<thead class="table-light sticky-top">
<tr>
<th style="width: 40%;">설정 항목 (Key)</th>
<th style="width: 40%;">설정값 (Value)</th>
<th style="width: 20%;">작업</th>
</tr>
</thead>
<tbody><!-- JS 채움 --></tbody>
</table>
</div>
</div>
<!-- RAID 탭 -->
<div class="tab-pane fade h-100 p-3" id="content-edit-raid">
<div class="alert alert-info py-2 small">
<i class="bi bi-info-circle me-1"></i>
RAID 구성은 복잡하므로 현재 JSON 직접 편집만 지원합니다. (Read-Only 권장)
</div>
<textarea class="form-control font-monospace small" id="json-edit-raid"
rows="15" style="height: 400px;"></textarea>
</div>
<!-- NIC 탭 -->
<div class="tab-pane fade h-100 p-3" id="content-edit-nic">
<div class="alert alert-info py-2 small">
<i class="bi bi-info-circle me-1"></i>
NIC 설정은 JSON 형식으로 편집합니다.
</div>
<textarea class="form-control font-monospace small" id="json-edit-nic" rows="15"
style="height: 400px;"></textarea>
</div>
<!-- Boot 탭 -->
<div class="tab-pane fade h-100 p-3" id="content-edit-boot">
<div class="alert alert-info py-2 small">
<i class="bi bi-info-circle me-1"></i>
부팅 순서 및 설정을 JSON 형식으로 편집합니다.
</div>
<textarea class="form-control font-monospace small" id="json-edit-boot"
rows="15" style="height: 400px;"></textarea>
</div>
<!-- Firmware 탭 -->
<div class="tab-pane fade h-100" id="content-edit-firmware">
<div
class="d-flex justify-content-between align-items-center p-2 border-bottom bg-light">
<span class="small text-muted fw-bold ms-2">항목 수: <span
id="edit-firmware-count">0</span></span>
<button class="btn btn-sm btn-outline-primary bg-white"
onclick="addSettingRow('firmware')">
<i class="bi bi-plus-lg"></i> 항목 추가
</button>
</div>
<div class="table-responsive" style="max-height: 450px;">
<table class="table table-hover mb-0" id="table-edit-firmware">
<thead class="table-light sticky-top">
<tr>
<th style="width: 40%;">컴포넌트</th>
<th style="width: 40%;">버전</th>
<th style="width: 20%;">작업</th>
</tr>
</thead>
<tbody><!-- JS 채움 --></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" onclick="saveBaselineChanges()">
<i class="bi bi-save me-1"></i> 변경사항 저장
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/bios_baseline.js') }}?v={{ range(1, 100000) | random }}"></script>
{% endblock %}
+50 -209
View File
@@ -5,152 +5,70 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Dell iDRAC 멀티 서버 펌웨어 관리</title>
<title>Dell iDRAC 멀티 서버 관리</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/idrac_style.css') }}">
</head>
<body>
<div class="container">
<header>
<h1>🖥️ Dell iDRAC 멀티 서버 펌웨어 관리</h1>
<p class="subtitle">납품 전 펌웨어 일괄 업데이트 시스템 - 버전 비교 기능</p>
<h1>🖥️ Dell iDRAC 멀티 서버 관리</h1>
<p class="subtitle">서버 등록, 연결 상태 확인 및 설정 정밀 분석 시스템</p>
</header>
<!-- 탭 메뉴 -->
<div class="tab-menu">
<button class="tab-button active" onclick="showTab('servers')">서버 관리</button>
<button class="tab-button" onclick="showTab('versions')">펌웨어 버전 관리</button>
<button class="tab-button" onclick="showTab('comparison')">버전 비교 결과</button>
</div>
<!-- 서버 관리 탭 -->
<div id="servers-tab" class="tab-content active">
<!-- 서버 관리 섹션 -->
<section class="card">
<div class="card-header">
<h2>📋 서버 관리</h2>
<div class="header-actions">
<button onclick="showAddServerModal()" class="btn btn-primary">+ 서버 추가</button>
<button onclick="refreshServers()" class="btn btn-info">🔄 새로고침</button>
</div>
<!-- 서버 관리 섹션 -->
<section class="card">
<div class="card-header">
<h2>📋 서버 목록 관리</h2>
<div class="header-actions">
<button onclick="showAddServerModal()" class="btn btn-primary">+ 서버 추가</button>
<a href="/server-config/" class="btn btn-secondary">⚙️ 설정 정밀 분석 및 DB 동기화</a>
<button onclick="refreshServers()" class="btn btn-info">🔄 새로고침</button>
</div>
</div>
<!-- 그룹 필터 -->
<div class="filter-group">
<label>그룹:</label>
<select id="group-filter" onchange="filterByGroup()">
<option value="all">전체</option>
</select>
<span id="server-count" class="count-badge">0대</span>
</div>
<!-- 그룹 필터 -->
<div class="filter-group">
<label>그룹:</label>
<select id="group-filter" onchange="filterByGroup()">
<option value="all">전체</option>
</select>
<span id="server-count" class="count-badge">0대</span>
</div>
<!-- 서버 목록 테이블 -->
<div class="table-container">
<table class="server-table" id="server-table">
<thead>
<tr>
<th width="40"><input type="checkbox" id="select-all" onchange="toggleSelectAll()"></th>
<th>서버명</th>
<th>IP 주소</th>
<th>그룹</th>
<th>상태</th>
<th>BIOS 버전</th>
<th>업데이트 상태</th>
<th width="150">작업</th>
</tr>
</thead>
<tbody id="server-list">
<tr>
<td colspan="8" class="empty-message">등록된 서버가 없습니다</td>
</tr>
</tbody>
</table>
</div>
<!-- 서버 목록 테이블 -->
<div class="table-container">
<table class="server-table" id="server-table">
<thead>
<tr>
<th width="40"><input type="checkbox" id="select-all" onchange="toggleSelectAll()"></th>
<th>서버명</th>
<th>IP 주소</th>
<th>그룹</th>
<th>상태</th>
<th>모델</th>
<th>최근 연결</th>
<th width="150">작업</th>
</tr>
</thead>
<tbody id="server-list">
<tr>
<td colspan="8" class="empty-message">등록된 서버가 없습니다</td>
</tr>
</tbody>
</table>
</div>
<!-- 선택 작업 -->
<div class="bulk-actions">
<span id="selected-count">선택: 0대</span>
<button onclick="testSelectedConnections()" class="btn btn-info">연결 테스트</button>
<button onclick="compareSelectedFirmware()" class="btn btn-warning">버전 비교</button>
<button onclick="showUploadModal()" class="btn btn-primary">펌웨어 업로드</button>
<button onclick="rebootSelected()" class="btn btn-danger">재부팅</button>
</div>
</section>
<!-- 업로드 진행 상황 -->
<section class="card" id="upload-progress-section" style="display: none;">
<h2>📊 업로드 진행 상황</h2>
<div id="upload-progress-list"></div>
<div class="progress-summary" id="progress-summary"></div>
</section>
</div>
<!-- 펌웨어 버전 관리 탭 -->
<div id="versions-tab" class="tab-content">
<section class="card">
<div class="card-header">
<h2>📦 펌웨어 최신 버전 관리</h2>
<div class="header-actions">
<button onclick="syncDellCatalog('PowerEdge R750')" class="btn btn-success">🔄 Dell에서 버전
가져오기</button>
<button onclick="syncFromDRM()" class="btn btn-info">📁 DRM에서 가져오기</button>
<button onclick="showAddVersionModal()" class="btn btn-primary">+ 버전 추가</button>
</div>
</div>
<div class="table-container">
<table class="server-table">
<thead>
<tr>
<th>컴포넌트</th>
<th>최신 버전</th>
<th>서버 모델</th>
<th>릴리즈 날짜</th>
<th>중요</th>
<th width="150">작업</th>
</tr>
</thead>
<tbody id="version-list">
<tr>
<td colspan="6" class="empty-message">등록된 버전 정보가 없습니다</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<!-- 버전 비교 결과 탭 -->
<div id="comparison-tab" class="tab-content">
<section class="card">
<div class="card-header">
<h2>🔍 펌웨어 버전 비교 결과</h2>
<button onclick="compareSelectedFirmware()" class="btn btn-primary">🔄 다시 비교</button>
</div>
<!-- 로딩 상태 -->
<div id="comparison-loading" style="display: none;">
<div class="loading-spinner"></div>
<p>버전 비교 중...</p>
</div>
<!-- 비교 결과 -->
<div id="comparison-result">
<div class="info-text">
<p><strong>📋 사용 방법:</strong></p>
<ol style="margin-top: 10px; margin-left: 20px; line-height: 1.8;">
<li>"서버 관리" 탭에서 비교할 서버를 선택하세요</li>
<li>"버전 비교" 버튼을 클릭하세요</li>
<li>현재 설치된 펌웨어 버전과 최신 버전을 자동으로 비교합니다</li>
<li>업데이트가 필요한 컴포넌트를 확인할 수 있습니다</li>
</ol>
</div>
</div>
</section>
</div>
<!-- 선택 작업 -->
<div class="bulk-actions">
<span id="selected-count">선택: 0대</span>
<button onclick="testSelectedConnections()" class="btn btn-info">연결 테스트</button>
<button onclick="rebootSelected()" class="btn btn-danger">재부팅</button>
</div>
</section>
<footer>
<p>Dell iDRAC 멀티 서버 펌웨어 관리 시스템 - 버전 자동 비교</p>
<p>Dell iDRAC 멀티 서버 관리 시스템</p>
</footer>
</div>
@@ -194,83 +112,6 @@
</div>
</div>
<!-- 버전 추가 모달 -->
<div id="add-version-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>펌웨어 최신 버전 추가</h3>
<span class="close" onclick="closeAddVersionModal()">&times;</span>
</div>
<div class="modal-body">
<div class="form-group">
<label for="version-component">컴포넌트명 *</label>
<input type="text" id="version-component" placeholder="예: BIOS">
</div>
<div class="form-group">
<label for="version-latest">최신 버전 *</label>
<input type="text" id="version-latest" placeholder="예: 2.15.0">
</div>
<div class="form-group">
<label for="version-model">서버 모델 (선택)</label>
<input type="text" id="version-model" placeholder="예: PowerEdge R750 (비우면 모든 모델)">
</div>
<div class="form-group">
<label for="version-vendor">벤더</label>
<input type="text" id="version-vendor" placeholder="예: Dell">
</div>
<div class="form-group">
<label for="version-release-date">릴리즈 날짜</label>
<input type="date" id="version-release-date">
</div>
<div class="form-group">
<label for="version-download-url">다운로드 URL</label>
<input type="text" id="version-download-url" placeholder="DUP 파일 다운로드 링크">
</div>
<div class="form-group">
<label for="version-notes">비고</label>
<textarea id="version-notes" rows="3" placeholder="버전 변경 사항 등"></textarea>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="version-critical">
중요 업데이트
</label>
</div>
</div>
<div class="modal-footer">
<button onclick="closeAddVersionModal()" class="btn btn-secondary">취소</button>
<button onclick="addFirmwareVersion()" class="btn btn-primary">추가</button>
</div>
</div>
</div>
<!-- 펌웨어 업로드 모달 -->
<div id="upload-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>펌웨어 업로드</h3>
<span class="close" onclick="closeUploadModal()">&times;</span>
</div>
<div class="modal-body">
<p class="warning-text">⚠️ 선택한 서버에 펌웨어를 일괄 업로드합니다</p>
<div class="form-group">
<label>선택한 서버: <strong id="upload-server-count">0대</strong></label>
</div>
<div class="form-group">
<label for="firmware-file">DUP 파일 선택 *</label>
<input type="file" id="firmware-file" accept=".exe,.bin">
</div>
</div>
<div class="modal-footer">
<button onclick="closeUploadModal()" class="btn btn-secondary">취소</button>
<button onclick="startMultiUpload()" class="btn btn-primary">업로드 시작</button>
</div>
</div>
</div>
<section class="card" id="result-section" style="display:none;">
<div id="result-content"></div>
</section>
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
<script src="{{ url_for('static', filename='js/idrac_main.js') }}"></script>
</body>
+500 -203
View File
@@ -31,9 +31,37 @@
<form id="ipForm" method="post" action="{{ url_for('main.process_ips') }}" class="h-100 d-flex flex-column">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# 스크립트 선택 #}
{# 작업 유형 선택 #}
<div class="mb-3">
<select id="script" name="script" class="form-select" required autocomplete="off">
<label for="job_type" class="form-label d-flex justify-content-between">
<span>작업 유형</span>
<a href="{{ url_for('script_manager.index') }}" class="text-decoration-none small">
<i class="bi bi-gear-fill me-1"></i>프로파일 관리
</a>
</label>
<select id="job_type" name="job_type" class="form-select" required>
<option value="" disabled selected>작업을 선택하세요</option>
<option value="mac">MAC 주소 수집</option>
<option value="server_info">서버 정보 수집</option>
<option value="guid">GUID 수집</option>
<option value="gpu">GPU 시리얼 수집</option>
<option value="legacy">기존 스크립트 사용 (Legacy)</option>
</select>
</div>
{# 프로파일 선택 (동적) #}
<div class="mb-3" id="profileGroup" style="display:none;">
<label for="profile" class="form-label">프로파일 선택</label>
<select id="profile" name="profile" class="form-select">
<option value="">프로파일을 선택하세요</option>
</select>
<div class="form-text small" id="profileDesc"></div>
</div>
{# 기존 스크립트 선택 (Legacy) #}
<div class="mb-3" id="legacyScriptGroup" style="display:none;">
<label for="script" class="form-label">스크립트 선택</label>
<select id="script" name="script" class="form-select" autocomplete="off">
<option value="">스크립트를 선택하세요</option>
{% if grouped_scripts %}
{% for category, s_list in grouped_scripts.items() %}
@@ -81,16 +109,16 @@
</div>
</label>
<textarea id="ips" name="ips" class="form-control font-monospace flex-grow-1"
placeholder="예:&#10;192.168.1.1&#10;192.168.1.2" required style="resize: none;"></textarea>
placeholder="iDRAC IP 정보를 입력하세요&#10;10.10.0.2&#10;10.10.0.3" required style="resize: none;"></textarea>
</div>
<div class="mt-auto">
<button type="submit"
class="btn btn-white bg-white border shadow-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move">
class="btn btn-white bg-white border shadow-sm w-100 py-1 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>
<i class="bi bi-play-circle-fill fs-6"></i>
</div>
<span class="fw-medium text-dark" style="font-size: 0.8rem;">처리 시작</span>
<span class="fw-medium text-dark" style="font-size: 0.75rem;">처리 시작</span>
</button>
</div>
</form>
@@ -123,29 +151,29 @@
<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">
class="btn btn-white bg-white border shadow-sm w-100 py-1 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>
<i class="bi bi-file-earmark-spreadsheet fs-6"></i>
</div>
<span class="fw-medium text-dark" style="font-size: 0.8rem;">MAC to Excel</span>
<span class="fw-medium text-dark" style="font-size: 0.75rem;">MAC to Excel</span>
</button>
</div>
<div class="col-4">
<button type="button" data-bs-toggle="modal" data-bs-target="#slotPriorityModal"
class="btn btn-white bg-white border shadow-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 btn-quick-move h-100">
class="btn btn-white bg-white border shadow-sm w-100 py-1 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>
<i class="bi bi-file-earmark-excel fs-6"></i>
</div>
<span class="fw-medium text-dark" style="font-size: 0.8rem;">GUID to Excel</span>
<span class="fw-medium text-dark" style="font-size: 0.75rem;">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">
class="btn btn-white bg-white border shadow-sm w-100 py-1 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>
<i class="bi bi-gpu-card fs-6"></i>
</div>
<span class="fw-medium text-dark" style="font-size: 0.8rem;">GPU to Excel</span>
<span class="fw-medium text-dark" style="font-size: 0.75rem;">GPU to Excel</span>
</button>
</div>
</div>
@@ -155,8 +183,104 @@
</div>
</div>
{# 진행바 #}
{# 시스템 제어 패널 #}
<div class="row mb-4">
<div class="col">
<div class="card border shadow-sm">
<div class="card-header bg-light border-0 py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="bi bi-terminal me-2"></i>
시스템 제어 (System Control)
<small class="text-muted ms-2 fw-normal">- 위 "IP 주소" 입력란의 대상을 제어합니다.</small>
</h6>
<div class="d-flex align-items-center gap-2">
<small id="systemControlHint" class="text-primary fw-bold"
style="font-size: 0.75rem; cursor: pointer;">(클릭하여 열기)</small>
<button class="btn btn-sm btn-link text-secondary p-0 text-decoration-none" type="button"
data-bs-toggle="collapse" data-bs-target="#systemControlPanel" aria-expanded="false"
aria-controls="systemControlPanel">
<i class="bi bi-chevron-down"></i>
</button>
</div>
</div>
<div id="systemControlPanel" class="collapse">
<div class="card-body p-4">
<div class="row g-2">
<!-- Power ON -->
<div class="col-md-2">
<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"
onclick="runControl('power_on')">
<div class="rounded-circle bg-success bg-opacity-10 text-success p-1">
<i class="bi bi-power fs-6"></i>
</div>
<span class="fw-medium text-dark" style="font-size: 0.7rem;">Power ON</span>
</button>
</div>
<!-- Power OFF -->
<div class="col-md-2">
<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"
onclick="runControl('power_off')">
<div class="rounded-circle bg-danger bg-opacity-10 text-danger p-1">
<i class="bi bi-person-x fs-6"></i>
</div>
<span class="fw-medium text-dark" style="font-size: 0.7rem;">Power OFF</span>
</button>
</div>
<!-- Log Clear -->
<div class="col-md-2">
<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"
onclick="runControl('log_clear')">
<div class="rounded-circle bg-warning bg-opacity-10 text-warning p-1">
<i class="bi bi-eraser fs-6"></i>
</div>
<span class="fw-medium text-dark" style="font-size: 0.7rem;">Log Clear</span>
</button>
</div>
<!-- Job Delete -->
<div class="col-md-2">
<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"
onclick="runControl('job_delete')">
<div class="rounded-circle bg-secondary bg-opacity-10 text-secondary p-1">
<i class="bi bi-trash fs-6"></i>
</div>
<span class="fw-medium text-dark" style="font-size: 0.7rem;">Job Delete</span>
</button>
</div>
<!-- TSR Collect -->
<div class="col-md-2">
<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"
onclick="runControl('tsr_collect')">
<div class="rounded-circle bg-info bg-opacity-10 text-info p-1">
<i class="bi bi-collection fs-6"></i>
</div>
<span class="fw-medium text-dark" style="font-size: 0.7rem;">TSR Collect</span>
</button>
</div>
<!-- TSR Save -->
<div class="col-md-2">
<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"
onclick="runControl('tsr_save')">
<div class="rounded-circle bg-primary bg-opacity-10 text-primary p-1">
<i class="bi bi-save fs-6"></i>
</div>
<span class="fw-medium text-dark" style="font-size: 0.7rem;">TSR Save</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{# 진행바 #}
<div class="row mb-4" id="progressSection" style="display: none;">
<div class="col">
<div class="card border-0 shadow-sm">
<div class="card-body p-3">
@@ -202,7 +326,7 @@
<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
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>
@@ -295,213 +419,344 @@
</div>
</div>
{# 처리된 파일 목록 #}
<div class="row mb-4 processed-list">
{# 통합 파일 관리 섹션 (탭 인터페이스) #}
<style>
/* 탭/필 텍스트 가시성 강제 설정 (강제 덮어쓰기) */
/* 기본(비활성) 상태: 무조건 검은색 */
#fileTabs .nav-link,
#repoTabs .nav-link {
color: #000 !important;
font-weight: 500;
}
/* 메인 탭 활성 상태: 검은색 텍스트 + 흰색 배경 */
#fileTabs .nav-link.active {
color: #000 !important;
background-color: #fff !important;
border-color: #dee2e6 #dee2e6 #fff !important;
font-weight: bold;
}
/* 서브 탭(Pills) 활성 상태: 흰색 텍스트 + 파란색 배경 */
#repoTabs .nav-link.active {
color: #fff !important;
background-color: #0d6efd !important;
}
/* 커스텀 탭 패널 가시성 제어 */
.custom-tab-panel {
display: none !important;
}
.custom-tab-panel.custom-active {
display: block !important;
}
/* 커스텀 서브 탭 패널 가시성 제어 */
.custom-sub-panel {
display: none !important;
}
.custom-sub-panel.custom-sub-active {
display: block !important;
}
</style>
<div class="row mb-5">
<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 d-flex align-items-center">
<i class="bi bi-files me-2"></i>
처리된 파일 목록
{% if files_to_display %}
<span class="badge bg-primary ms-3">{{ files_to_display|length }} 파일</span>
{% endif %}
</h6>
</div>
<div class="card-body p-4">
{% if files_to_display and files_to_display|length > 0 %}
<div class="row g-3">
{% for file_info in files_to_display %}
<div class="col-auto">
<div class="file-card-compact border rounded p-2 text-center">
<a href="{{ url_for('main.download_file', filename=file_info.file) }}"
class="text-decoration-none text-dark fw-semibold d-block mb-2 text-nowrap px-2" download
title="{{ file_info.name or file_info.file }}">
{{ file_info.name or file_info.file }}
</a>
<div class="file-card-buttons d-flex gap-2 justify-content-center">
<button type="button" class="btn btn-sm btn-outline btn-view-processed flex-fill"
data-bs-toggle="modal" data-bs-target="#fileViewModal" data-folder="idrac_info"
data-filename="{{ file_info.file }}">
보기
</button>
<form action="{{ url_for('main.delete_file', filename=file_info.file) }}" method="post"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline btn-delete-processed flex-fill"
onclick="return confirm('삭제하시겠습니까?');">
삭제
</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 페이지네이션 -->
{% if total_pages > 1 %}
<nav aria-label="Processed files pagination" class="mt-4">
<ul class="pagination justify-content-center mb-0">
<!-- 이전 페이지 -->
{% if page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.index', page=page-1) }}">
<i class="bi bi-chevron-left"></i> 이전
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i> 이전</span>
</li>
{% endif %}
<!-- 페이지 번호 (최대 10개 표시) -->
{% set start_page = ((page - 1) // 10) * 10 + 1 %}
{% set end_page = [start_page + 9, total_pages]|min %}
{% for p in range(start_page, end_page + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('main.index', page=p) }}">{{ p }}</a>
</li>
{% endfor %}
<!-- 다음 페이지 -->
{% if page < total_pages %} <li class="page-item">
<a class="page-link" href="{{ url_for('main.index', page=page+1) }}">
다음 <i class="bi bi-chevron-right"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">다음 <i class="bi bi-chevron-right"></i></span>
</li>
<div class="card-header bg-white border-0 py-3">
<ul class="nav nav-tabs card-header-tabs" id="fileTabs" role="tablist">
{# 탭 1: 처리된 파일 #}
<li class="nav-item" role="presentation">
<button class="nav-link active" id="processed-tab" data-custom-toggle="tab" data-bs-target="#processed"
type="button" role="tab" aria-controls="processed" aria-selected="true">
<i class="bi bi-check-circle-fill text-success me-2"></i>처리된 파일
{% if files_to_display %}
<span class="badge bg-secondary ms-1 rounded-pill">{{ files_to_display|length }}</span>
{% endif %}
</ul>
</nav>
{% endif %}
<!-- /페이지네이션 -->
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox fs-1 text-muted mb-3"></i>
<p class="text-muted mb-0">표시할 파일이 없습니다.</p>
</div>
{% endif %}
</button>
</li>
{# 탭 2: 백업 및 저장소 (드롭다운으로 할지, 서브탭으로 할지 결정. 여기서는 메인 탭 내부 서브탭 방식 채택) #}
<li class="nav-item" role="presentation">
<button class="nav-link" id="repository-tab" data-custom-toggle="tab" data-bs-target="#repository-panel"
type="button" role="tab" aria-controls="repository-panel" aria-selected="false">
<i class="bi bi-archive-fill text-primary me-2"></i>백업 및 저장소
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
{# 백업된 파일 목록 #}
<div class="row backup-list">
<div class="col">
<div class="card border shadow-sm">
<div class="card-header bg-light border-0 py-2">
<h6 class="mb-0">
<i class="bi bi-archive me-2"></i>
백업된 파일 목록
</h6>
</div>
<div class="card-body p-4">
{% if backup_files and backup_files|length > 0 %}
<div class="list-group">
{% for date, info in backup_files.items() %}
<div class="list-group-item border rounded mb-2 p-0 overflow-hidden">
<div class="d-flex justify-content-between align-items-center p-3 bg-light">
<div class="d-flex align-items-center">
<i class="bi bi-calendar3 text-primary me-2"></i>
<strong>{{ date }}</strong>
<span class="badge bg-primary ms-3">{{ info.count }} 파일</span>
<div class="custom-tabs-container" id="fileTabsContent">
{# ===================== [TAB 1] 처리된 파일 ===================== #}
<div class="custom-tab-panel custom-active" id="processed" role="tabpanel" aria-labelledby="processed-tab">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>현재 스테이징 폴더(data/temp/staging)에
있는 파일들입니다.</h6>
<div class="input-group input-group-sm w-auto">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-search"></i></span>
<input type="text" class="form-control border-start-0" id="searchProcessed" placeholder="파일 검색..."
onkeyup="filterTable('tableProcessed', this.value)">
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse"
data-bs-target="#collapse-{{ loop.index }}" aria-expanded="false">
<i class="bi bi-chevron-down"></i>
</button>
</div>
<div id="collapse-{{ loop.index }}" class="collapse">
<div class="p-3">
<div class="row g-3 backup-files-container" data-folder="{{ date }}" style="min-height: 50px;">
{% for file in info.files %}
<div class="col-auto backup-file-item" data-filename="{{ file }}">
<div class="file-card-compact border rounded p-2 text-center bg-white">
<a href="{{ url_for('main.download_backup_file', date=date, filename=file) }}"
class="text-decoration-none text-dark fw-semibold d-block mb-2 text-nowrap px-2" download
title="{{ file }}">
{{ file.rsplit('.', 1)[0] }}
</a>
<div class="file-card-single-button">
<button type="button" class="btn btn-sm btn-outline btn-view-backup w-100"
data-bs-toggle="modal" data-bs-target="#fileViewModal" data-folder="backup"
data-date="{{ date }}" data-filename="{{ file }}">
보기
<div class="table-responsive bg-white border rounded">
<table class="table table-hover align-middle mb-0" id="tableProcessed">
<thead class="bg-light">
<tr>
<th class="ps-3" style="cursor:pointer;" onclick="sortTable('tableProcessed', 0)">파일명 <i
class="bi bi-arrow-down-up small text-muted"></i></th>
<th style="width: 150px;">작업</th>
</tr>
</thead>
<tbody>
{% if files_to_display and files_to_display|length > 0 %}
{% for file_info in files_to_display %}
<tr>
<td class="ps-3">
<div class="d-flex align-items-center">
<i class="bi bi-file-earmark-text text-secondary fs-5 me-3"></i>
<div>
<a href="{{ url_for('main.download_file', filename=file_info.file) }}"
class="text-decoration-none fw-semibold text-dark" download>
{{ file_info.name or file_info.file }}
</a>
</div>
</div>
</td>
<td>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-light text-primary bg-primary-subtle border-0"
data-bs-toggle="modal" data-bs-target="#fileViewModal" data-folder="idrac_info"
data-filename="{{ file_info.file }}" title="내용 보기">
<i class="bi bi-eye-fill"></i>
</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-light text-danger bg-danger-subtle border-0"
onclick="return confirm('이 파일을 삭제하시겠습니까?');" title="삭제">
<i class="bi bi-trash-fill"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="text-center py-4 text-muted">표시할 파일이 없습니다.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{# 페이지네이션 (기존 로직 유지) #}
{% if total_pages > 1 %}
<nav aria-label="Processed files pagination" class="mt-4">
<ul class="pagination justify-content-center mb-0 pagination-sm">
{% if page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.index', page=page-1) }}"><i
class="bi bi-chevron-left"></i></a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"><i class="bi bi-chevron-left"></i></span></li>
{% endif %}
{% set start_page = ((page - 1) // 10) * 10 + 1 %}
{% set end_page = [start_page + 9, total_pages]|min %}
{% for p in range(start_page, end_page + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('main.index', page=p) }}">{{ p }}</a>
</li>
{% endfor %}
{% if page < total_pages %} <li class="page-item"><a class="page-link"
href="{{ url_for('main.index', page=page+1) }}"><i class="bi bi-chevron-right"></i></a></li>
{% else %}
<li class="page-item disabled"><span class="page-link"><i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{# ===================== [TAB 2] 백업 및 저장소 ===================== #}
<div class="custom-tab-panel" id="repository-panel" role="tabpanel" aria-labelledby="repository-tab"
style="display: none !important;">
{# 서브 탭 (Pills) #}
<ul class="nav nav-pills mb-3 gap-2" id="repoTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active btn-sm" id="subpanel-backup-tab" data-custom-toggle="pill"
data-bs-target="#subpanel-backup" type="button" role="tab" aria-selected="true">
<i class="bi bi-hdd-server me-1"></i>서버 정보
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link btn-sm" id="subpanel-mac-tab" data-custom-toggle="pill"
data-bs-target="#subpanel-mac" type="button" role="tab" aria-selected="false">
<i class="bi bi-cpu me-1"></i>MAC
<span class="badge bg-light text-dark ms-1">{{ mac_files|length }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link btn-sm" id="subpanel-guid-tab" data-custom-toggle="pill"
data-bs-target="#subpanel-guid" type="button" role="tab" aria-selected="false">
<i class="bi bi-fingerprint me-1"></i>GUID
<span class="badge bg-light text-dark ms-1">{{ guid_files|length }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link btn-sm" id="subpanel-gpu-tab" data-custom-toggle="pill"
data-bs-target="#subpanel-gpu" type="button" role="tab" aria-selected="false">
<i class="bi bi-gpu-card me-1"></i>GPU
<span class="badge bg-light text-dark ms-1">{{ gpu_files|length }}</span>
</button>
</li>
</ul>
<div class="custom-sub-tabs-container" id="repoTabsContent">
{# ----- 서브탭 1: 일별 백업 (기존 아코디언 스타일 유지 + 파일 목록 테이블화) ----- #}
<div class="custom-sub-panel" id="subpanel-backup" role="tabpanel">
{% if server_info_files and server_info_files|length > 0 %}
<div class="accordion" id="backupAccordion">
{% for folder_name, info in server_info_files.items() %}
<div class="accordion-item mb-2 border rounded overflow-hidden">
<h2 class="accordion-header" id="heading-{{ loop.index }}">
<div class="d-flex align-items-center bg-light w-100 px-0">
<button class="accordion-button collapsed bg-light py-2 shadow-none border-0" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse-{{ loop.index }}" aria-expanded="false"
style="width: auto; flex-grow: 1;">
<div class="d-flex align-items-center">
<i class="bi bi-hdd-server text-primary me-2"></i>
<span class="fw-semibold me-3">{{ folder_name
}}</span>
<span class="badge bg-secondary rounded-pill">{{ info.count }} 파일</span>
</div>
</button>
{# 폴더 액션 버튼 (버튼 밖으로 이동하여 HTML 표준 준수) #}
<div class="d-flex gap-2 pe-3 me-4">
<form action="{{ url_for('main.archive_backup_folder', folder_name=folder_name) }}"
method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-xs btn-outline-secondary" title="아카이브로 이동"
onclick="return confirm('아카이브하시겠습니까?');">
<i class="bi bi-archive"></i>
</button>
</form>
<form action="{{ url_for('main.delete_backup_folder', folder_name=folder_name) }}"
method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-xs btn-outline-danger" title="폴더 삭제"
onclick="return confirm('영구 삭제하시겠습니까?');">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
</h2>
<div id="collapse-{{ loop.index }}" class="accordion-collapse collapse">
<div class="accordion-body p-0">
{# 백업 파일 테이블 (내부 검색/정렬은 생략하거나 필요시 추가) #}
<div class="table-responsive" style="max-height: 400px;">
<table class="table table-sm table-hover mb-0 font-monospace" style="font-size: 0.9rem;">
<thead class="bg-light sticky-top">
<tr>
<th class="ps-3">파일명</th>
<th class="text-end pe-3" style="width: 100px;">보기</th>
</tr>
</thead>
<tbody class="backup-files-container" data-folder="{{ folder_name }}">
{% for file in info.files %}
<tr class="backup-file-item" data-filename="{{ file }}">
<td class="ps-3 align-middle">
<a href="{{ url_for('main.download_backup_file', date=folder_name, filename=file) }}"
class="text-decoration-none text-dark d-block text-truncate" download>
{{ file }}
</a>
</td>
<td class="text-end pe-3">
<button type="button" class="btn btn-xs btn-link text-secondary p-0 btn-view-backup"
data-bs-toggle="modal" data-bs-target="#fileViewModal" data-folder="server_info"
data-date="{{ folder_name }}" data-filename="{{ file }}">
<i class="bi bi-eye"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{# 백업 페이지네이션 #}
{% if total_backup_pages > 1 %}
<nav class="mt-3">
<ul class="pagination pagination-sm justify-content-center">
{% if backup_page > 1 %}
<li class="page-item"><a class="page-link"
href="{{ url_for('main.index', backup_page=backup_page-1, page=page) }}"><i
class="bi bi-chevron-left"></i></a></li>
{% endif %}
{% set start_b = ((backup_page - 1) // 10) * 10 + 1 %}
{% set end_b = [start_b + 9, total_backup_pages]|min %}
{% for p in range(start_b, end_b + 1) %}
<li class="page-item {% if p == backup_page %}active{% endif %}"><a class="page-link"
href="{{ url_for('main.index', backup_page=p, page=page) }}">{{ p }}</a></li>
{% endfor %}
{% if backup_page < total_backup_pages %} <li class="page-item"><a class="page-link"
href="{{ url_for('main.index', backup_page=backup_page+1, page=page) }}"><i
class="bi bi-chevron-right"></i></a></li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">백업된 파일이 없습니다.</div>
{% endif %}
</div>
{# ----- 서브탭 2: MAC ----- #}
<div class="custom-sub-panel d-none" id="subpanel-mac" role="tabpanel">
{% with table_id="tableMac", files=mac_files, folder="mac", empty_msg="MAC 파일이 없습니다." %}
{% include "snippets/repo_file_table.html" %}
{% endwith %}
</div>
{# ----- 서브탭 3: GUID ----- #}
<div class="custom-sub-panel d-none" id="subpanel-guid" role="tabpanel">
{% with table_id="tableGuid", files=guid_files, folder="guid", empty_msg="GUID 파일이 없습니다." %}
{% include "snippets/repo_file_table.html" %}
{% endwith %}
</div>
{# ----- 서브탭 4: GPU ----- #}
<div class="custom-sub-panel d-none" id="subpanel-gpu" role="tabpanel">
{% with table_id="tableGpu", files=gpu_files, folder="gpu", empty_msg="GPU 파일이 없습니다." %}
{% include "snippets/repo_file_table.html" %}
{% endwith %}
</div>
</div>
</div>
{% endfor %}
</div> <!-- End Tab 2 -->
</div>
<!-- 백업 목록 페이지네이션 -->
{% if total_backup_pages > 1 %}
<nav aria-label="Backup pagination" class="mt-4">
<ul class="pagination justify-content-center mb-0">
<!-- 이전 페이지 -->
{% if backup_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.index', backup_page=backup_page-1, page=page) }}">
<i class="bi bi-chevron-left"></i> 이전
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i> 이전</span>
</li>
{% endif %}
<!-- 페이지 번호 -->
{% set start_b_page = ((backup_page - 1) // 10) * 10 + 1 %}
{% set end_b_page = [start_b_page + 9, total_backup_pages]|min %}
{% for p in range(start_b_page, end_b_page + 1) %}
<li class="page-item {% if p == backup_page %}active{% endif %}">
<a class="page-link" href="{{ url_for('main.index', backup_page=p, page=page) }}">{{ p }}</a>
</li>
{% endfor %}
<!-- 다음 페이지 -->
{% if backup_page < total_backup_pages %} <li class="page-item">
<a class="page-link" href="{{ url_for('main.index', backup_page=backup_page+1, page=page) }}">
다음 <i class="bi bi-chevron-right"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">다음 <i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox fs-1 text-muted mb-3"></i>
<p class="text-muted mb-0">백업된 파일이 없습니다.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{# 파일 보기 모달 #}
@@ -547,11 +802,53 @@
window.APP_CONFIG = {
moveBackupUrl: "{{ url_for('main.move_backup_files') }}",
csrfToken: "{{ csrf_token() }}",
downloadBaseUrl: "{{ url_for('main.download_backup_file', date='PLACEHOLDER_DATE', filename='PLACEHOLDER_FILE') }}"
downloadBaseUrl: "{{ url_for('main.download_backup_file', date='PLACEHOLDER_DATE', filename='PLACEHOLDER_FILE') }}",
systemControlUrl: "{{ url_for('utils.system_control') }}"
};
</script>
<script src="{{ url_for('static', filename='js/dashboard.js') }}?v={{ range(1, 100000) | random }}"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
async function runControl(action) {
// 메인 IP 입력란('ips')에서 값을 가져옴
const ipText = document.getElementById("ips").value.trim();
if (!ipText) {
alert("IP 처리에 입력된 IP 주소가 없습니다.\n먼저 'IP 처리' 카드에 IP를 입력해주세요.");
document.getElementById("ips").focus();
return;
}
if (!confirm(`정말로 ${action} 작업을 진행하시겠습니까?`)) return;
// Simple loading indicator (can be improved)
const btn = event.target.closest('button');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 처리중...';
try {
const response = await axios.post(window.APP_CONFIG.systemControlUrl, {
action: action,
ips: ipText.split("\n").filter(ip => ip.trim() !== "")
}, {
headers: {
'X-CSRFToken': window.APP_CONFIG.csrfToken
}
});
if (response.data.success) {
alert("작업 완료: " + response.data.message);
} else {
alert("오류 발생: " + response.data.error);
}
} catch (error) {
alert("서버 통신 오류: " + (error.response?.data?.error || error.message));
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
</script>
<!-- SortableJS for Drag and Drop -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
+78 -51
View File
@@ -98,6 +98,26 @@
margin-bottom: 1rem;
}
/* 테이블 뷰 스타일 */
#xmlFileTable {
font-size: 0.85rem;
}
.table-hover tbody tr:hover {
background-color: #f8fafc;
}
.file-icon-wrapper-sm {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background-color: #eff6ff;
color: #3b82f6;
border-radius: 6px;
}
.card-actions {
display: flex;
gap: 0.5rem;
@@ -215,58 +235,56 @@
<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>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0" id="xmlFileTable">
<thead class="table-light">
<tr>
<th scope="col" style="width: 50px;" class="text-center">
<input type="checkbox" class="form-check-input" id="checkAll">
</th>
<th scope="col">파일명</th>
<th scope="col" style="width: 180px;" class="text-center">관리</th>
</tr>
</thead>
<tbody>
{% for xml_file in xml_files %}
<tr>
<td class="text-center">
<input type="checkbox" class="form-check-input file-selector"
value="{{ xml_file }}">
</td>
<td>
<div class="d-flex align-items-center">
<div class="file-icon-wrapper-sm me-3">
<i class="bi bi-filetype-xml fs-5"></i>
</div>
<span class="fw-bold text-dark">{{ xml_file }}</span>
</div>
</td>
<td class="text-center">
<div class="d-flex justify-content-center gap-1">
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="openDeployModal('{{ xml_file }}')" title="배포">
<i class="bi bi-send-fill"></i>
</button>
</form>
</div>
</div>
</div>
</div>
{% endfor %}
<a href="{{ url_for('xml.edit_xml', filename=xml_file) }}"
class="btn btn-sm btn-outline-secondary" title="편집">
<i class="bi bi-pencil-fill"></i>
</a>
<form action="{{ url_for('xml.delete_xml', filename=xml_file) }}"
method="POST" class="d-inline-block m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button type="submit" class="btn btn-sm btn-outline-danger"
onclick="return confirm('정말 삭제하시겠습니까?')" title="삭제">
<i class="bi bi-trash-fill"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 my-5">
@@ -519,6 +537,15 @@
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
// 전체 선택 체크박스 로직
const checkAll = document.getElementById('checkAll');
if (checkAll) {
checkAll.addEventListener('change', function () {
const checkboxes = document.querySelectorAll('.file-selector');
checkboxes.forEach(cb => cb.checked = checkAll.checked);
});
}
});
</script>
{% endblock %}
+691
View File
@@ -0,0 +1,691 @@
{% extends "base.html" %}
{% block title %}스크립트 관리{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-code-square"></i> 스크립트 관리</h2>
<button class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#yamlGuideModal">
<i class="bi bi-question-circle"></i> YAML 작성 가이드
</button>
</div>
<!-- 탭 네비게이션 -->
<ul class="nav nav-tabs mb-3" id="scriptTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="mac-tab" data-bs-toggle="tab" data-bs-target="#mac" type="button">
<i class="bi bi-ethernet"></i> MAC 수집
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="server-tab" data-bs-toggle="tab" data-bs-target="#server" type="button">
<i class="bi bi-server"></i> 서버 정보
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="gpu-tab" data-bs-toggle="tab" data-bs-target="#gpu" type="button">
<i class="bi bi-gpu-card"></i> GPU 서버 정보
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="terminal-tab" data-bs-toggle="tab" data-bs-target="#terminal" type="button">
<i class="bi bi-terminal"></i> 터미널
</button>
</li>
</ul>
<!-- 탭 컨텐츠 -->
<div class="tab-content" id="scriptTabContent">
<!-- MAC 수집 탭 -->
<div class="tab-pane fade show active" id="mac" role="tabpanel">
<div class="row g-3" id="macProfiles">
<!-- 프로파일 카드가 여기에 동적으로 추가됨 -->
</div>
</div>
<!-- 서버 정보 탭 -->
<div class="tab-pane fade" id="server" role="tabpanel">
<div class="row g-3" id="serverProfiles">
<!-- 프로파일 카드가 여기에 동적으로 추가됨 -->
</div>
</div>
<!-- GPU 서버 정보 탭 -->
<div class="tab-pane fade" id="gpu" role="tabpanel">
<div class="row g-3" id="gpuProfiles">
<!-- 프로파일 카드가 여기에 동적으로 추가됨 -->
</div>
</div>
<!-- 터미널 탭 (Racadm Tester) -->
<div class="tab-pane fade" id="terminal" role="tabpanel">
<div class="row">
<div class="col-md-12">
<div class="card border shadow-sm">
<div class="card-header bg-light py-3">
<h5 class="mb-0"><i class="bi bi-terminal-fill me-2"></i>Racadm Command Tester</h5>
</div>
<div class="card-body p-4">
<form id="racadmForm" onsubmit="event.preventDefault(); runRacadmCommand();">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label fw-bold">iDRAC IP</label>
<input type="text" class="form-control" id="targetIp" placeholder="192.168.0.1"
required>
</div>
<div class="col-md-3">
<label class="form-label fw-bold">Username</label>
<input type="text" class="form-control" id="targetUser" value="root">
</div>
<div class="col-md-3">
<label class="form-label fw-bold">Password</label>
<input type="password" class="form-control" id="targetPass" value="calvin">
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-dark w-100" id="btnRunCommand">
<i class="bi bi-play-fill me-1"></i> Run Command
</button>
</div>
<div class="col-12">
<label class="form-label fw-bold">Command</label>
<div class="input-group">
<span class="input-group-text font-monospace bg-light">racadm</span>
<input type="text" class="form-control font-monospace" id="targetCmd"
placeholder="getsysinfo" value="getsysinfo" required>
</div>
<div class="form-text">Note: 'racadm' is automatically prepended. Just enter the
arguments (e.g. 'getsysinfo' or 'get NIC.NICConfig.1').</div>
</div>
</div>
</form>
<hr class="my-4">
<div>
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label fw-bold mb-0">Output</label>
<button class="btn btn-sm btn-outline-secondary"
onclick="document.getElementById('cmdOutput').innerText=''">
Clear
</button>
</div>
<div class="bg-dark text-light p-3 rounded font-monospace"
style="min-height: 200px; overflow: auto; resize: vertical;">
<pre id="cmdOutput" class="mb-0 text-break" style="white-space: pre-wrap;"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- YAML 가이드 모달 -->
<div class="modal fade" id="yamlGuideModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-book"></i> YAML 프로파일 작성 가이드</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- 탭 메뉴 -->
<ul class="nav nav-tabs mb-3" id="yamlGuideTabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#guide-basic"><i
class="bi bi-lightbulb"></i> YAML 기초</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#guide-mac">MAC 수집</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#guide-server">서버 정보</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#guide-gpu">GPU/GUID</button>
</li>
</ul>
<!-- 탭 컨텐츠 -->
<div class="tab-content border p-3 rounded bg-light" style="max-height: 60vh; overflow-y: auto;">
<!-- 1. YAML 기초 -->
<div class="tab-pane fade show active" id="guide-basic">
<h5 class="mb-3 text-primary">YAML 파일 작성 규칙 (초보자 필독)</h5>
<div class="alert alert-warning">
<strong><i class="bi bi-exclamation-triangle-fill"></i> 가장 중요한 규칙 2가지</strong>
<ol class="mb-0 mt-2">
<li><strong>들여쓰기(띄어쓰기)</strong>: 절대 <code>Tab</code> 키를 쓰지 마세요! <span
class="badge bg-danger">스페이스바 2칸</span>을 사용해야 합니다.</li>
<li><strong>콜론(:) 다음 공백</strong>: <code>key:value</code> (X) -> <code>key: value</code>
(O) <br>콜론 뒤에는 반드시 한 칸을 띄워야 합니다.</li>
</ol>
</div>
<div class="card mb-3">
<div class="card-header fw-bold">구조 이해하기</div>
<div class="card-body">
<p class="card-text">설정은 크게 <strong>이름(Key)</strong><strong>값(Value)</strong>으로 나뉩니다.
</p>
<div class="row">
<div class="col-md-6">
<h6><i class="bi bi-list-ul"></i> 목록 (List) 작성법</h6>
<p class="small text-muted">여러 개의 값을 넣으려면 대쉬(<code>-</code>)를 사용합니다.</p>
<pre class="bg-dark text-light p-2 rounded small"><code># 과일 목록 예시
fruits:
- "사과" # 리스트 1
- "바나나" # 리스트 2
- "포도" # 리스트 3</code></pre>
</div>
<div class="col-md-6">
<h6><i class="bi bi-card-text"></i> 설정 (Object) 작성법</h6>
<p class="small text-muted">하위 설정을 넣으려면 들여쓰기를 합니다.</p>
<pre class="bg-dark text-light p-2 rounded small"><code># 자동차 설정 예시
car:
color: "Red" # 스페이스 2칸
speed: 100 # 스페이스 2칸
is_active: true # 스페이스 2칸</code></pre>
</div>
</div>
</div>
</div>
</div>
<!-- 2. MAC 수집 -->
<div class="tab-pane fade" id="guide-mac">
<h5 class="mb-3">MAC 주소 수집 프로파일</h5>
<p class="text-muted">서버의 네트워크 카드(NIC)의 물리적 주소(MAC)를 가져오는 설정입니다.</p>
<div class="card mb-3 border-primary">
<div class="card-header bg-primary text-white">복사해서 사용하세요</div>
<div class="card-body bg-dark text-light p-0">
<pre class="m-0 p-3"><code>description: "기본 MAC 수집 설정"
# 1. 수집할 NIC(네트워크 카드)의 이름 패턴
nic_patterns:
- "NIC.Integrated.1" # 메인보드 내장 랜카드 1번
- "NIC.Slot.*" # 슬롯에 꽂힌 모든 랜카드 (PCIe)
# 2. InfiniBand(고속 네트워크) 카드가 있다면 슬롯 번호를 적으세요
infiniband_slots:
- "PCIeSlot3" # 3번 슬롯에 꽂힌 카드
# 3. iDRAC 전용 포트의 MAC도 필요하면 true로 설정
include_idrac_mac: true</code></pre>
</div>
</div>
<h6 class="mt-4"><i class="bi bi-question-circle-fill"></i> 상세 설명</h6>
<table class="table table-sm table-bordered bg-white">
<thead class="table-light">
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>nic_patterns</code></td>
<td>가져올 랜카드의 이름입니다. <code>*</code>(별표)는 '모두'를 의미합니다.<br>예: <code>NIC.*</code>는 모든
랜카드를 다 가져옵니다.</td>
</tr>
<tr>
<td><code>infiniband_slots</code></td>
<td>InfiniBand는 일반 NIC와 달라서 슬롯 번호(예: PCIeSlot3)를 정확히 적어야 합니다.</td>
</tr>
</tbody>
</table>
</div>
<!-- 3. 서버 정보 -->
<div class="tab-pane fade" id="guide-server">
<h5 class="mb-3">서버 정보 수집 프로파일</h5>
<p class="text-muted">서버의 펌웨어, BIOS 설정, 하드웨어 상태 등을 확인하는 설정입니다.</p>
<div class="card mb-3 border-success">
<div class="card-header bg-success text-white">복사해서 사용하세요</div>
<div class="card-body bg-dark text-light p-0">
<pre class="m-0 p-3"><code>description: "서버 점검용 설정"
# 1. 버전 정보를 알고 싶은 펌웨어 이름들
firmware:
- "iDRAC"
- "BIOS"
- "NIC"
# 2. 확인하고 싶은 BIOS 설정값
bios_settings:
- "MemTest" # 메모리 테스트 옵션
- "LogicalProc" # 하이퍼스레딩(논리코어)
# 3. RAID 컨트롤러의 디스크 정보
raid_settings:
- "Disk.Bay.0" # 0번 베이의 하드디스크 정보
# 4. 기타 명령어 실행 (상급자용)
custom_items:
- command: "getsysinfo"
label: "전체 시스템 요약"</code></pre>
</div>
</div>
</div>
<!-- 4. GPU/GUID -->
<div class="tab-pane fade" id="guide-gpu">
<h5 class="mb-3">GPU 및 GUID 설정</h5>
<p class="text-muted">GPU의 시리얼 번호를 수집하거나, iDRAC MAC 주소를 기반으로 GUID를 생성할 때 사용합니다.</p>
<div class="card mb-3 border-danger">
<div class="card-header bg-danger text-white">복사해서 사용하세요</div>
<div class="card-body bg-dark text-light p-0">
<pre class="m-0 p-3"><code>description: "GPU 서버용 설정"
# 1. GPU 시리얼 수집 설정 (GPU가 있는 경우 필수)
gpu_settings:
target_slots:
- "Video.Slot.*" # 모든 비디오 카드 수집
sort_order: "slot_index" # 슬롯 순서대로 정렬
# 2. GUID 생성 시 슬롯 우선순위
# iDRAC MAC 말고, 특정 랜카드의 MAC을 GUID로 쓰고 싶을 때 사용
slot_priority:
- 38 # 38번 슬롯 카드의 MAC을 1순위로 사용
- 1 # 없으면 1번 슬롯 사용
output_format: "combined"</code></pre>
</div>
</div>
<div class="alert alert-light border">
<strong><i class="bi bi-lightbulb"></i> 팁:</strong> GPU 시리얼만 필요하다면
<code>slot_priority</code> 부분은 지워도 됩니다.
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
</div>
</div>
</div>
</div>
<!-- 프로파일 편집 모달 -->
<div class="modal fade" id="profileModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="profileModalTitle">프로파일 편집</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">프로파일 이름</label>
<input type="text" class="form-control" id="profileName" readonly>
</div>
<div class="mb-3">
<label class="form-label">설명</label>
<input type="text" class="form-control" id="profileDescription">
</div>
<div class="mb-3">
<label class="form-label">설정 (YAML)</label>
<textarea class="form-control font-monospace" id="profileConfig" rows="15"></textarea>
<small class="text-muted">YAML 형식으로 프로파일 설정을 편집할 수 있습니다.</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" onclick="saveProfile()">
<i class="bi bi-save"></i> 저장
</button>
</div>
</div>
</div>
</div>
<style>
.profile-card {
transition: transform 0.2s, box-shadow 0.2s;
}
.profile-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.profile-card .card-body {
min-height: 180px;
}
.border-dashed {
border-style: dashed !important;
}
/* 탭 가시성 강제 설정 */
.nav-tabs .nav-link {
color: #495057 !important;
font-weight: 500;
}
.nav-tabs .nav-link.active {
color: #fff !important;
background-color: #0d6efd !important;
}
.nav-tabs .nav-link:hover {
color: #0a58ca !important;
}
</style>
<script>
let currentCategory = 'mac';
let currentProfile = null;
let allProfiles = [];
// 페이지 로드 시 프로파일 목록 가져오기
document.addEventListener('DOMContentLoaded', function () {
loadProfiles();
});
// 프로파일 목록 로드
async function loadProfiles() {
// 로딩 표시
['macProfiles', 'serverProfiles', 'gpuProfiles'].forEach(id => {
const el = document.getElementById(id);
if (el) el.innerHTML = '<div class="col-12 text-center py-5"><div class="spinner-border text-primary" role="status"></div><p class="mt-2 text-muted">프로파일을 불러오는 중입니다...</p></div>';
});
try {
const response = await fetch('/api/profiles');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
allProfiles = await response.json();
if (!Array.isArray(allProfiles)) throw new Error('데이터 형식이 올바르지 않습니다.');
renderProfiles();
} catch (error) {
console.error('프로파일 로드 오류:', error);
// 에러 표시
['macProfiles', 'serverProfiles', 'gpuProfiles'].forEach(id => {
const el = document.getElementById(id);
if (el) el.innerHTML = `<div class="col-12 text-center py-5"><i class="bi bi-exclamation-triangle text-danger fs-1"></i><p class="mt-2 text-danger">프로파일을 불러오지 못했습니다.<br><small>${error.message}</small></p></div>`;
});
}
}
// 프로파일 렌더링
function renderProfiles() {
const gpuContainer = document.getElementById('gpuProfiles');
const categories = {
'mac': document.getElementById('macProfiles'),
'server_info': document.getElementById('serverProfiles'),
'guid': gpuContainer,
'gpu': gpuContainer
};
// 각 카테고리 초기화
Object.values(categories).forEach(el => { if (el) el.innerHTML = ''; });
// 프로파일 카드 생성
allProfiles.forEach(profile => {
const container = categories[profile.category];
if (!container) return;
const card = createProfileCard(profile);
container.insertAdjacentHTML('beforeend', card);
});
// 새 프로파일 추가 카드
const targets = [
{ cat: 'mac', el: document.getElementById('macProfiles') },
{ cat: 'server_info', el: document.getElementById('serverProfiles') },
{ cat: 'gpu', el: gpuContainer }
];
targets.forEach(t => {
if (t.el) t.el.insertAdjacentHTML('beforeend', createNewProfileCard(t.cat));
});
}
// 프로파일 카드 HTML 생성
function createProfileCard(profile) {
const badgeClass = profile.is_default ? 'bg-success' : 'bg-secondary';
const badgeText = profile.is_default ? '기본' : '사용자 정의';
return `
<div class="col-md-4">
<div class="card profile-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title">
${profile.is_default ? '<i class="bi bi-star-fill text-warning"></i>' : ''}
${profile.name}
</h5>
<p class="card-text text-muted small">${profile.description}</p>
</div>
<span class="badge ${badgeClass}">${badgeText}</span>
</div>
<div class="mt-3">
<small class="text-muted">수집 항목:</small>
<div class="mt-1">
<span class="badge bg-light text-dark">${profile.items}개</span>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-sm btn-outline-primary flex-fill" onclick="editProfile('${profile.category}', '${profile.name}')">
<i class="bi bi-pencil"></i> 편집
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="exportProfile('${profile.category}', '${profile.name}')">
<i class="bi bi-download"></i> 내보내기
</button>
${!profile.is_default ? `
<button class="btn btn-sm btn-outline-danger" onclick="deleteProfile('${profile.category}', '${profile.name}')">
<i class="bi bi-trash"></i>
</button>
` : ''}
</div>
</div>
</div>
</div>
`;
}
// 새 프로파일 추가 카드
function createNewProfileCard(category) {
return `
<div class="col-md-4">
<div class="card profile-card border-dashed" style="border: 2px dashed #dee2e6;">
<div class="card-body d-flex flex-column align-items-center justify-content-center" style="min-height: 180px;">
<i class="bi bi-plus-circle fs-1 text-muted mb-3"></i>
<button class="btn btn-outline-primary" onclick="createNewProfile('${category}')">
새 프로파일 만들기
</button>
</div>
</div>
</div>
`;
}
// 프로파일 편집
async function editProfile(category, name) {
try {
const response = await fetch(`/api/profiles/${category}/${name}`);
const profile = await response.json();
currentCategory = category;
currentProfile = name;
document.getElementById('profileName').value = name;
document.getElementById('profileDescription').value = profile.description;
document.getElementById('profileConfig').value = JSON.stringify(profile.config, null, 2);
const modal = new bootstrap.Modal(document.getElementById('profileModal'));
modal.show();
} catch (error) {
console.error('프로파일 로드 오류:', error);
alert('프로파일을 불러오는 중 오류가 발생했습니다.');
}
}
// 프로파일 저장
async function saveProfile() {
try {
const config = JSON.parse(document.getElementById('profileConfig').value);
const response = await fetch(`/api/profiles/${currentCategory}/${currentProfile}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
config: config
})
});
const result = await response.json();
if (result.success) {
alert('프로파일이 저장되었습니다.');
bootstrap.Modal.getInstance(document.getElementById('profileModal')).hide();
loadProfiles();
} else {
alert('저장 실패: ' + result.error);
}
} catch (error) {
console.error('저장 오류:', error);
alert('프로파일 저장 중 오류가 발생했습니다.');
}
}
// 프로파일 내보내기
function exportProfile(category, name) {
window.location.href = `/api/profiles/${category}/${name}/export`;
}
// 프로파일 삭제
async function deleteProfile(category, name) {
if (!confirm(`'${name}' 프로파일을 삭제하시겠습니까?`)) return;
try {
const response = await fetch(`/api/profiles/${category}/${name}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
});
const result = await response.json();
if (result.success) {
alert('프로파일이 삭제되었습니다.');
loadProfiles();
} else {
alert('삭제 실패: ' + result.error);
}
} catch (error) {
console.error('삭제 오류:', error);
alert('프로파일 삭제 중 오류가 발생했습니다.');
}
}
// 새 프로파일 만들기
function createNewProfile(category) {
const name = prompt('새 프로파일 이름을 입력하세요:');
if (!name) return;
// 기본 설정으로 프로파일 생성
const defaultConfig = {
description: "새 프로파일",
extends: "default"
};
fetch(`/api/profiles/${category}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
name: name,
config: defaultConfig
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert('프로파일이 생성되었습니다.');
loadProfiles();
} else {
alert('생성 실패: ' + result.error);
}
})
.catch(error => {
console.error('생성 오류:', error);
alert('프로파일 생성 중 오류가 발생했습니다.');
});
}
async function runRacadmCommand() {
const ip = document.getElementById('targetIp').value;
const user = document.getElementById('targetUser').value;
const pass = document.getElementById('targetPass').value;
const cmd = document.getElementById('targetCmd').value;
const outputPre = document.getElementById('cmdOutput');
const btn = document.getElementById('btnRunCommand');
if (!ip || !user || !pass || !cmd) {
alert('모든 필드를 입력하세요.');
return;
}
// UI Reset
outputPre.innerText = "Executing...";
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Running...';
try {
const response = await fetch('/api/test_racadm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
ip: ip,
user: user,
password: pass,
command: cmd
})
});
const result = await response.json();
if (result.success) {
// Return code가 0이 아니면 실패로 간주하거나, 그대로 출력
let displayText = `[Success]\n${result.output}`;
if (result.return_code !== 0) {
displayText = `[Error (Code: ${result.return_code})]\n${result.output}`;
}
outputPre.innerText = displayText;
} else {
outputPre.innerText = `[Error]\n${result.error}`;
}
} catch (error) {
console.error('Execution error:', error);
outputPre.innerText = `[System Error]\n${error.message}`;
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-play-fill me-1"></i> Run Command';
}
}
</script>
{% endblock %}
@@ -0,0 +1,48 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="input-group input-group-sm w-auto">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-search"></i></span>
<input type="text" class="form-control border-start-0" placeholder="파일명 검색..."
onkeyup="filterTable('{{ table_id }}', this.value)">
</div>
</div>
<div class="table-responsive bg-white border rounded">
<table class="table table-hover table-sm mb-0 align-middle font-monospace" id="{{ table_id }}"
style="font-size: 0.9rem;">
<thead class="bg-light sticky-top">
<tr>
<th class="ps-3" style="cursor:pointer;" onclick="sortTable('{{ table_id }}', 0)">파일명 <i
class="bi bi-arrow-down-up small text-muted"></i></th>
<th style="cursor:pointer; width: 100px;" onclick="sortTable('{{ table_id }}', 1)">크기 <i
class="bi bi-arrow-down-up small text-muted"></i></th>
<th style="cursor:pointer; width: 160px;" onclick="sortTable('{{ table_id }}', 2)">날짜 <i
class="bi bi-arrow-down-up small text-muted"></i></th>
<th class="text-end pe-3" style="width: 80px;">보기</th>
</tr>
</thead>
<tbody>
{% if files and files|length > 0 %}
{% for file in files %}
<tr>
<td class="ps-3">
<i class="bi bi-file-earmark text-secondary me-2"></i>{{ file.name }}
</td>
<td>{{ (file.size / 1024)|round(1) }} KB</td>
<td data-sort="{{ file.mtime }}">{{ file.date }}</td>
<td class="text-end pe-3">
<button type="button" class="btn btn-xs btn-link text-secondary p-0 btn-view-repo"
data-bs-toggle="modal" data-bs-target="#fileViewModal" data-folder="{{ folder }}"
data-filename="{{ file.name }}">
<i class="bi bi-eye"></i>
</button>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="text-center py-4 text-muted">{{ empty_msg }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>