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
+721
View File
@@ -0,0 +1,721 @@
/**
* BIOS Baseline 비교 JavaScript
* backend/static/js/bios_baseline.js
*/
// 전역 변수
let baselines = [];
let comparisonResults = [];
let showDiffOnly = true;
// ========================================
// 초기화
// ========================================
document.addEventListener('DOMContentLoaded', () => {
loadBaselines();
// IP 입력 카운트 업데이트
document.getElementById('ip-addresses').addEventListener('input', updateIPCount);
// Baseline 생성 폼 제출
document.getElementById('create-baseline-form').addEventListener('submit', handleCreateBaseline);
});
// ========================================
// Baseline 관리
// ========================================
async function loadBaselines() {
try {
const res = await fetch('/bios-baseline/api/baselines');
const data = await res.json();
if (data.success) {
baselines = data.baselines;
renderBaselineSelect();
}
} catch (e) {
console.error('Baseline 로드 실패:', e);
}
}
function renderBaselineSelect() {
const select = document.getElementById('baseline-select');
select.innerHTML = '<option value="">Baseline을 선택하세요</option>';
baselines.forEach(baseline => {
const option = document.createElement('option');
option.value = baseline.id;
option.textContent = `${baseline.name} (${baseline.server_model})`;
select.appendChild(option);
});
}
function showBaselineManageModal() {
const modal = new bootstrap.Modal(document.getElementById('baseline-manage-modal'));
modal.show();
loadBaselineList();
}
async function loadBaselineList() {
const container = document.getElementById('baseline-list');
container.innerHTML = '<div class="text-center text-muted py-4"><i class="bi bi-hourglass-split"></i> 로딩 중...</div>';
try {
const res = await fetch('/bios-baseline/api/baselines');
const data = await res.json();
if (data.success && data.baselines.length > 0) {
let html = '<div class="list-group">';
data.baselines.forEach(baseline => {
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">${baseline.name}</h6>
<p class="mb-1 text-muted small">
<i class="bi bi-server me-1"></i>${baseline.server_model}
${baseline.server_type ? `<span class="badge bg-secondary ms-2">${baseline.server_type}</span>` : ''}
</p>
<p class="mb-0 text-muted small">
${baseline.description || '설명 없음'}
</p>
<p class="mb-0 text-muted small">
<i class="bi bi-gear me-1"></i>${baseline.total_settings}개 설정
<i class="bi bi-calendar ms-2 me-1"></i>${new Date(baseline.created_at).toLocaleDateString()}
</p>
</div>
<div class="btn-group-vertical btn-group-sm">
<button class="btn btn-outline-secondary" onclick="editBaseline(${baseline.id})">
<i class="bi bi-pencil"></i> 편집
</button>
<button class="btn btn-outline-primary" onclick="duplicateBaseline(${baseline.id})">
<i class="bi bi-files"></i> 복사
</button>
<button class="btn btn-outline-danger" onclick="deleteBaseline(${baseline.id})">
<i class="bi bi-trash"></i> 삭제
</button>
</div>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
} else {
container.innerHTML = '<div class="text-center text-muted py-4">등록된 Baseline이 없습니다</div>';
}
} catch (e) {
container.innerHTML = '<div class="text-center text-danger py-4">로드 실패</div>';
}
}
async function handleCreateBaseline(e) {
e.preventDefault();
const data = {
ip_address: document.getElementById('create-ip').value,
username: document.getElementById('create-username').value,
password: document.getElementById('create-password').value,
name: document.getElementById('create-name').value,
server_type: document.getElementById('create-type').value,
description: document.getElementById('create-description').value,
created_by: 'admin'
};
try {
const res = await fetch('/bios-baseline/api/baselines/create-from-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
alert('✓ Baseline 생성 완료!');
document.getElementById('create-baseline-form').reset();
loadBaselines();
loadBaselineList();
} else {
alert('✗ 생성 실패: ' + result.message);
}
} catch (e) {
alert('✗ 오류: ' + e.message);
}
}
async function duplicateBaseline(baselineId) {
const newName = prompt('새 Baseline 이름을 입력하세요:');
if (!newName) return;
try {
const res = await fetch(`/bios-baseline/api/baselines/${baselineId}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_name: newName })
});
const result = await res.json();
if (result.success) {
alert('✓ Baseline 복사 완료!');
loadBaselines();
loadBaselineList();
} else {
alert('✗ 복사 실패: ' + result.message);
}
} catch (e) {
alert('✗ 오류: ' + e.message);
}
}
// Baseline 삭제
async function deleteBaseline(baselineId) {
if (!confirm('정말 이 Baseline을 삭제하시겠습니까?')) return;
try {
const res = await fetch(`/bios-baseline/api/baselines/${baselineId}`, {
method: 'DELETE'
});
const result = await res.json();
if (result.success) {
alert('✓ Baseline 삭제 완료!');
loadBaselines();
loadBaselineList();
} else {
alert('✗ 삭제 실패: ' + result.message);
}
} catch (e) {
alert('✗ 오류: ' + e.message);
}
}
// ========================================
// Baseline 상세 편집
// ========================================
async function editBaseline(baselineId) {
try {
const res = await fetch(`/bios-baseline/api/baselines/${baselineId}`);
const data = await res.json();
if (data.success) {
const baseline = data.baseline;
currentBaselineId = baselineId; // 전역 변수 설정
// 메타데이터 채우기
document.getElementById('edit-id').value = baseline.id;
document.getElementById('edit-name').value = baseline.name;
document.getElementById('edit-model').value = baseline.server_model;
document.getElementById('edit-type').value = baseline.server_type || '';
document.getElementById('edit-description').value = baseline.description || '';
document.getElementById('edit-created-at').textContent = baseline.created_at || '-';
document.getElementById('edit-source-ip').textContent = baseline.source_ip || '-';
// BIOS, iDRAC, Firmware Table 렌더링
renderEditTable('bios', baseline.bios_data || {});
renderEditTable('idrac', baseline.idrac_data || {});
renderEditTable('firmware', baseline.firmware_data || {});
// RAID, NIC, Boot는 JSON Textarea에 표시
document.getElementById('json-edit-raid').value = JSON.stringify(baseline.raid_data || {}, null, 4);
document.getElementById('json-edit-nic').value = JSON.stringify(baseline.nic_data || {}, null, 4);
document.getElementById('json-edit-boot').value = JSON.stringify(baseline.boot_data || {}, null, 4);
// 모달 표시
const modal = new bootstrap.Modal(document.getElementById('edit-baseline-modal'));
modal.show();
// 첫 번째 탭(BIOS) 활성화
const firstTab = new bootstrap.Tab(document.querySelector('#tab-edit-bios'));
firstTab.show();
} else {
alert('로드 실패: ' + data.message);
}
} catch (e) {
alert('오류: ' + e.message);
}
}
function renderEditTable(type, settings) {
const tbody = document.querySelector(`#table-edit-${type} tbody`);
tbody.innerHTML = '';
const entries = Object.entries(settings).sort((a, b) => a[0].localeCompare(b[0]));
document.getElementById(`edit-${type}-count`).textContent = entries.length;
entries.forEach(([key, value]) => {
addSettingRow(type, key, value);
});
}
function addSettingRow(type, key = '', value = '') {
const tbody = document.querySelector(`#table-edit-${type} tbody`);
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input type="text" class="form-control form-control-sm setting-key" value="${key}" placeholder="Key"></td>
<td><input type="text" class="form-control form-control-sm setting-value" value="${value}" placeholder="Value"></td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="removeSettingRow(this, '${type}')">
<i class="bi bi-trash"></i>
</button>
</td>
`;
// 새 행은 맨 위에 추가 (비어있을 경우) 혹은 맨 아래
if (!key) {
tbody.prepend(tr);
tr.querySelector('.setting-key').focus();
} else {
tbody.appendChild(tr);
}
}
function removeSettingRow(btn, type) {
btn.closest('tr').remove();
updateSettingsCount(type);
}
function updateSettingsCount(type) {
const count = document.querySelectorAll(`#table-edit-${type} tbody tr`).length;
document.getElementById(`edit-${type}-count`).textContent = count;
}
async function saveBaselineChanges() {
const id = document.getElementById('edit-id').value;
const name = document.getElementById('edit-name').value;
if (!name) {
alert('Baseline 이름을 입력하세요.');
return;
}
// Helper function to collect table data
const collectTableData = (type) => {
const data = {};
const rows = document.querySelectorAll(`#table-edit-${type} tbody tr`);
rows.forEach(row => {
const key = row.querySelector('.setting-key').value.trim();
const value = row.querySelector('.setting-value').value.trim();
if (key) data[key] = value;
});
return data;
};
const biosData = collectTableData('bios');
const idracData = collectTableData('idrac');
const firmwareData = collectTableData('firmware');
// JSON Data Parsing
let raidData = {};
let nicData = {};
let bootData = {};
try {
raidData = JSON.parse(document.getElementById('json-edit-raid').value || '{}');
} catch (e) {
alert('RAID 구성 JSON 형식이 올바르지 않습니다.');
return;
}
try {
nicData = JSON.parse(document.getElementById('json-edit-nic').value || '{}');
} catch (e) {
alert('NIC 설정 JSON 형식이 올바르지 않습니다.');
return;
}
try {
bootData = JSON.parse(document.getElementById('json-edit-boot').value || '{}');
} catch (e) {
alert('부팅 설정 JSON 형식이 올바르지 않습니다.');
return;
}
const payload = {
name: name,
server_type: document.getElementById('edit-type').value,
description: document.getElementById('edit-description').value,
bios_data: biosData,
idrac_data: idracData,
raid_data: raidData,
nic_data: nicData,
boot_data: bootData,
firmware_data: firmwareData
};
try {
const res = await fetch(`/bios-baseline/api/baselines/${currentBaselineId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await res.json();
if (result.success) {
alert('✓ 변경사항이 저장되었습니다!');
const modalEl = document.getElementById('edit-baseline-modal');
const modal = bootstrap.Modal.getInstance(modalEl);
modal.hide();
loadBaselines();
loadBaselineList();
} else {
alert('✗ 저장 실패: ' + result.message);
}
} catch (e) {
alert('✗ 오류: ' + e.message);
}
}
// ========================================
// 비교 기능
// ========================================
async function startComparison() {
const baselineId = document.getElementById('baseline-select').value;
const ipText = document.getElementById('ip-addresses').value;
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
if (!baselineId) {
alert('Baseline을 선택하세요');
return;
}
if (!ipText.trim()) {
alert('서버 IP를 입력하세요');
return;
}
if (!password) {
alert('비밀번호를 입력하세요');
return;
}
// IP 파싱
const ipAddresses = ipText.split(/[\n,\s]+/).filter(ip => ip.trim());
if (ipAddresses.length === 0) {
alert('유효한 IP 주소가 없습니다');
return;
}
// 진행률 표시
document.getElementById('progress-section').style.display = 'block';
document.getElementById('results-section').style.display = 'none';
updateProgress(0, ipAddresses.length);
try {
const res = await fetch('/bios-baseline/api/compare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip_addresses: ipAddresses,
baseline_id: parseInt(baselineId),
username: username,
password: password
})
});
const data = await res.json();
if (data.success) {
comparisonResults = data.results;
// Toggle 버튼 초기 상태 설정
const toggleBtn = document.getElementById('diff-only-btn');
if (showDiffOnly) {
toggleBtn.classList.add('active');
} else {
toggleBtn.classList.remove('active');
}
renderSummary(data.baseline, data.results);
renderResults(data.results);
document.getElementById('results-section').style.display = 'block';
} else {
alert('비교 실패: ' + data.message);
}
} catch (e) {
alert('오류: ' + e.message);
} finally {
document.getElementById('progress-section').style.display = 'none';
}
}
function updateProgress(current, total) {
const percent = Math.round((current / total) * 100);
const bar = document.getElementById('progress-bar');
bar.style.width = percent + '%';
bar.querySelector('span').textContent = percent + '%';
}
function renderSummary(baseline, results) {
const successResults = results.filter(r => r.status === 'success');
const totalServers = results.length;
const successCount = successResults.length;
const failedCount = totalServers - successCount;
let totalMatched = 0;
let totalMismatched = 0;
let perfectMatchCount = 0;
successResults.forEach(r => {
totalMatched += r.matched_items;
totalMismatched += r.mismatched_items;
if (r.mismatched_items === 0) perfectMatchCount++;
});
const avgMatchRate = successCount > 0
? Math.round((totalMatched / (totalMatched + totalMismatched)) * 100)
: 0;
const html = `
<div class="row g-3">
<div class="col-md-3">
<div class="card border-primary">
<div class="card-body text-center">
<i class="bi bi-server fs-1 text-primary"></i>
<h3 class="mt-2 mb-0">${totalServers}</h3>
<p class="text-muted small mb-0">총 서버</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body text-center">
<i class="bi bi-check-circle fs-1 text-success"></i>
<h3 class="mt-2 mb-0">${avgMatchRate}%</h3>
<p class="text-muted small mb-0">평균 일치율</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body text-center">
<i class="bi bi-exclamation-triangle fs-1 text-warning"></i>
<h3 class="mt-2 mb-0">${totalMismatched}</h3>
<p class="text-muted small mb-0">불일치 항목</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-info">
<div class="card-body text-center">
<i class="bi bi-patch-check fs-1 text-info"></i>
<h3 class="mt-2 mb-0">${perfectMatchCount}</h3>
<p class="text-muted small mb-0">완전 일치</p>
</div>
</div>
</div>
</div>
<div class="mt-3 p-3 bg-light rounded">
<p class="mb-0 small">
<strong>Baseline:</strong> ${baseline.name} (${baseline.server_model})
<span class="ms-3"><strong>총 설정:</strong> ${baseline.total_settings}개</span>
</p>
</div>
`;
document.getElementById('summary-section').innerHTML = html;
}
function renderResults(results) {
const container = document.getElementById('results-container');
if (results.length === 0) {
container.innerHTML = '<p class="text-muted text-center">결과가 없습니다</p>';
return;
}
let html = '<div class="accordion" id="results-accordion">';
results.forEach((result, index) => {
const statusClass = result.status === 'success'
? (result.mismatched_items === 0 ? 'success' : 'warning')
: 'danger';
const statusIcon = result.status === 'success'
? (result.mismatched_items === 0 ? 'check-circle-fill' : 'exclamation-triangle-fill')
: 'x-circle-fill';
html += `
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button ${index > 0 ? 'collapsed' : ''}" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse-${index}">
<i class="bi bi-${statusIcon} text-${statusClass} me-2"></i>
<strong>${result.ip}</strong>
${result.service_tag ? `<span class="badge bg-secondary ms-2">${result.service_tag}</span>` : ''}
${result.status === 'success' ? `
<span class="ms-auto me-3">
<span class="badge bg-${statusClass}">${result.match_rate}% 일치</span>
${result.mismatched_items > 0 ? `<span class="badge bg-danger ms-1">${result.mismatched_items}개 불일치</span>` : ''}
</span>
` : ''}
</button>
</h2>
<div id="collapse-${index}" class="accordion-collapse collapse ${index === 0 ? 'show' : ''}"
data-bs-parent="#results-accordion">
<div class="accordion-body">
${renderServerDetail(result)}
</div>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
function renderServerDetail(result) {
if (result.status !== 'success') {
return `<div class="alert alert-danger mb-0">${result.message}</div>`;
}
// 통계바
let html = `
<div class="mb-3">
<div class="row g-2">
<div class="col-md-4"><strong>모델:</strong> ${result.server_model}</div>
<div class="col-md-4"><strong>Service Tag:</strong> ${result.service_tag}</div>
<div class="col-md-4"><strong>BIOS:</strong> ${result.bios_version}</div>
</div>
<div class="progress mt-2" style="height: 20px;">
<div class="progress-bar bg-success" role="progressbar" style="width: ${result.match_rate}%">
${result.match_rate}% 일치
</div>
<div class="progress-bar bg-danger" role="progressbar" style="width: ${100 - result.match_rate}%">
${result.mismatched_items} 불일치
</div>
</div>
</div>
`;
// 탭 헤더 생성 (Nav Pills)
const uid = Math.random().toString(36).substr(2, 9); // 유니크 ID
html += `
<div class="nav-pills-container">
<ul class="nav nav-pills mb-0" id="result-tabs-${uid}" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-bios-${uid}" type="button">BIOS</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-idrac-${uid}" type="button">iDRAC</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-raid-${uid}" type="button">RAID</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-nic-${uid}" type="button">NIC</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-boot-${uid}" type="button">Boot</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-firmware-${uid}" type="button">Firmware</button>
</li>
</ul>
</div>
<div class="tab-content">
`;
// 섹션별 Diff 필터링
const diffs = result.differences || [];
// 탭 콘텐츠 생성 Helper
const renderTabPane = (section, id, isActive = false) => {
const sectionDiffs = diffs.filter(d => d.section === section);
// 섹션 이름이 영어로 저장되어 있다고 가정 (BIOS, iDRAC, RAID, NIC, Boot, Firmware)
return `
<div class="tab-pane fade ${isActive ? 'show active' : ''}" id="tab-${id}-${uid}" role="tabpanel">
${renderDiffTable(sectionDiffs, `${section} 설정이 기준과 일치합니다.`)}
</div>`;
};
html += renderTabPane('BIOS', 'bios', true);
html += renderTabPane('iDRAC', 'idrac');
html += renderTabPane('RAID', 'raid');
html += renderTabPane('NIC', 'nic');
html += renderTabPane('Boot', 'boot');
html += renderTabPane('Firmware', 'firmware');
html += `</div>`; // tab-content end
return html;
}
function renderDiffTable(diffs, emptyMsg) {
if (diffs.length === 0) {
return `<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-1"></i> ${emptyMsg}</div>`;
}
if (showDiffOnly && diffs.every(d => d.status !== 'mismatch')) {
return `<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-1"></i> ${emptyMsg} (필터링됨)</div>`;
}
let html = `
<table class="table table-sm table-bordered table-hover" style="table-layout: fixed;">
<thead class="table-light">
<tr>
<th width="40%">항목</th>
<th width="30%">기준값 (Baseline)</th>
<th width="30%">실제값 (Server)</th>
</tr>
</thead>
<tbody>
`;
diffs.forEach(diff => {
const isMismatch = diff.status === 'mismatch';
const rowClass = isMismatch ? 'table-danger' : '';
if (!showDiffOnly || isMismatch) {
html += `
<tr class="${rowClass}">
<td style="word-wrap: break-word;">
${diff.display_name ? `<strong>${diff.display_name}</strong><br><small class="text-muted" style="word-break: break-all;">${diff.setting_name}</small>` : `<code style="word-break: break-all;">${diff.setting_name}</code>`}
</td>
<td style="word-break: break-all;">${diff.expected}</td>
<td style="word-break: break-all;"><span class="${isMismatch ? '' : 'text-success'}">${diff.actual}</span></td>
</tr>
`;
}
});
html += '</tbody></table>';
return html;
}
function toggleDiffOnly() {
showDiffOnly = !showDiffOnly;
const btn = document.getElementById('diff-only-btn');
btn.classList.toggle('active');
renderResults(comparisonResults);
}
function downloadResults() {
alert('Excel 다운로드 기능은 곧 추가될 예정입니다.');
}
// ========================================
// 유틸리티
// ========================================
function updateIPCount() {
const ipText = document.getElementById('ip-addresses').value;
const ips = ipText.split(/[\n,\s]+/).filter(ip => ip.trim());
document.getElementById('ip-count').textContent = ips.length;
}
function clearIPs() {
document.getElementById('ip-addresses').value = '';
updateIPCount();
}
+392 -73
View File
@@ -15,8 +15,14 @@ document.addEventListener('DOMContentLoaded', () => {
const bar = document.getElementById('progressBar');
if (!bar) return;
const v = Math.max(0, Math.min(100, Number(val) || 0));
// 부모 컨테이너가 숨겨져 있다면 표시
// 부모 컨테이너(섹션)가 숨겨져 있다면 표시
const section = document.getElementById('progressSection');
if (section && section.style.display === 'none') {
section.style.display = 'block';
}
// (Legacy support) 부모 컨테이너가 숨겨져 있다면 표시
const progressContainer = bar.closest('.progress');
if (progressContainer && progressContainer.parentElement.classList.contains('d-none')) {
progressContainer.parentElement.classList.remove('d-none');
@@ -25,8 +31,87 @@ document.addEventListener('DOMContentLoaded', () => {
bar.style.width = v + '%';
bar.setAttribute('aria-valuenow', v);
bar.innerHTML = `<span class="fw-semibold small">${v}%</span>`;
// 100% 도달 시 애니메이션 효과 제어 등은 필요 시 추가
};
/**
* 테이블 정렬 함수
* @param {string} tableId - 테이블 ID
* @param {number} colIdx - 정렬할 컬럼 인덱스 (0부터 시작)
*/
window.sortTable = function (tableId, colIdx) {
const table = document.getElementById(tableId);
if (!table) return;
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
// 데이터가 없거나 1개 뿐이면 패스
if (rows.length <= 1) return;
if (rows[0].querySelector('td[colspan]')) return; // "파일이 없습니다" 행
const th = table.querySelectorAll('th')[colIdx];
let asc = !th.classList.contains('sorted-asc'); // 현재 상태의 반대로 토글
// 모든 헤더 초기화
table.querySelectorAll('th').forEach(h => {
h.classList.remove('sorted-asc', 'sorted-desc');
const icon = h.querySelector('i');
if (icon) icon.className = 'bi bi-arrow-down-up small text-muted';
});
// 현재 헤더 상태 업데이트
th.classList.add(asc ? 'sorted-asc' : 'sorted-desc');
const icon = th.querySelector('i');
if (icon) icon.className = asc ? 'bi bi-sort-down-alt text-primary' : 'bi bi-sort-up-alt text-primary';
// 정렬
rows.sort((rowA, rowB) => {
const cellA = rowA.children[colIdx];
const cellB = rowB.children[colIdx];
// data-sort 속성이 있으면 그것을 우선 사용 (날짜/숫자 등)
const valA = cellA.dataset.sort || cellA.textContent.trim();
const valB = cellB.dataset.sort || cellB.textContent.trim();
const isNum = !isNaN(parseFloat(valA)) && isFinite(valA) && !isNaN(parseFloat(valB)) && isFinite(valB);
if (isNum) {
return asc ? (parseFloat(valA) - parseFloat(valB)) : (parseFloat(valB) - parseFloat(valA));
} else {
return asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
}
});
// 다시 추가 (재정렬)
rows.forEach(row => tbody.appendChild(row));
};
/**
* 테이블 필터링 함수
* @param {string} tableId - 테이블 ID
* @param {string} text - 검색어
*/
window.filterTable = function (tableId, text) {
const table = document.getElementById(tableId);
if (!table) return;
const tbody = table.querySelector('tbody');
const rows = tbody.querySelectorAll('tr');
const query = text.toLowerCase().trim();
let visibleCount = 0;
rows.forEach(row => {
if (row.querySelector('td[colspan]')) return; // 메시지 행 제외
// 첫 번째 셀(파일명)만 검색 대상이라고 가정하거나, 전체 텍스트 검색
const rowText = row.textContent.toLowerCase();
if (rowText.includes(query)) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
});
// 검색 결과 없음 처리 (Optional)
};
// 줄 수 카운터 (script.js에서 가져옴)
@@ -88,7 +173,7 @@ document.addEventListener('DOMContentLoaded', () => {
// TomSelect 적용 전/후 모두 대응하기 위해 이벤트 리스너 등록
toggleXml();
scriptSelect.addEventListener('change', toggleXml);
// TomSelect 초기화
new TomSelect("#script", {
create: false,
@@ -135,9 +220,9 @@ document.addEventListener('DOMContentLoaded', () => {
// 진행바 표시 및 초기화
const progressContainer = progressBar.closest('.progress');
if (progressContainer && progressContainer.parentElement) {
// 이미 존재하는 row 등을 찾아서 보여주기 (필요시)
// 이미 존재하는 row 등을 찾아서 보여주기 (필요시)
}
window.updateProgress(100); // 스캔 중임을 알리기 위해 꽉 채움 (애니메이션)
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressBar.textContent = 'IP 스캔 중...';
@@ -185,6 +270,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (progressBar) {
window.updateProgress(0);
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
const section = document.getElementById('progressSection');
if (section) section.style.display = 'none';
}
}
});
@@ -194,11 +282,11 @@ document.addEventListener('DOMContentLoaded', () => {
const btnClear = document.getElementById('btnClearIps');
if (btnClear) {
btnClear.addEventListener('click', () => {
const ipsTextarea = document.getElementById('ips');
if (ipsTextarea && confirm('입력된 IP 목록을 모두 지우시겠습니까?')) {
ipsTextarea.value = '';
ipsTextarea.dispatchEvent(new Event('input'));
}
const ipsTextarea = document.getElementById('ips');
if (ipsTextarea && confirm('입력된 IP 목록을 모두 지우시겠습니까?')) {
ipsTextarea.value = '';
ipsTextarea.dispatchEvent(new Event('input'));
}
});
}
@@ -209,6 +297,15 @@ document.addEventListener('DOMContentLoaded', () => {
ev.preventDefault();
const formData = new FormData(ipForm);
// [NEW] GUID 작업 시 로컬 스토리지의 슬롯 우선순위 추가
const jobType = formData.get('job_type');
if (jobType === 'guid') {
const savedPriority = localStorage.getItem('guidSlotNumbers');
if (savedPriority) {
formData.append('slot_priority', savedPriority);
}
}
const btn = ipForm.querySelector('button[type="submit"]');
const originalHtml = btn.innerHTML;
btn.disabled = true;
@@ -226,7 +323,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
if (data.job_id) {
// 비동기 작업 시작됨 -> 폴링 시작
pollProgress(data.job_id);
@@ -323,7 +420,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!wasSelected) toggleSelection(item);
}
} else if (!e.target.closest('.backup-files-container')) {
// 배경 클릭 시 선택 해제? (UX에 따라 결정, 여기선 일단 패스)
// 배경 클릭 시 선택 해제? (UX에 따라 결정, 여기선 일단 패스)
}
});
@@ -365,7 +462,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Sortable 초기화
backupContainers.forEach(container => {
if (typeof Sortable === 'undefined') return;
new Sortable(container, {
group: 'backup-files',
animation: 150,
@@ -421,23 +518,23 @@ document.addEventListener('DOMContentLoaded', () => {
target_folder: targetFolder
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
// 속성 업데이트 (다운로드 링크 등)
const btn = item.querySelector('.btn-view-backup');
if (btn) btn.setAttribute('data-date', targetFolder);
.then(r => r.json())
.then(data => {
if (data.success) {
// 속성 업데이트 (다운로드 링크 등)
const btn = item.querySelector('.btn-view-backup');
if (btn) btn.setAttribute('data-date', targetFolder);
const link = item.querySelector('a[download]');
if (link && window.APP_CONFIG.downloadBaseUrl) {
const newHref = window.APP_CONFIG.downloadBaseUrl
.replace('PLACEHOLDER_DATE', targetFolder)
.replace('PLACEHOLDER_FILE', filename);
link.setAttribute('href', newHref);
const link = item.querySelector('a[download]');
if (link && window.APP_CONFIG.downloadBaseUrl) {
const newHref = window.APP_CONFIG.downloadBaseUrl
.replace('PLACEHOLDER_DATE', targetFolder)
.replace('PLACEHOLDER_FILE', filename);
link.setAttribute('href', newHref);
}
}
}
return data;
});
return data;
});
});
Promise.all(promises).then(results => {
@@ -520,9 +617,9 @@ document.addEventListener('DOMContentLoaded', () => {
<div>Slot ${slot}</div>
</div>
`).join('');
// 미리보기 Sortable
new Sortable(slotPreview, {
new Sortable(slotPreview, {
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
@@ -552,7 +649,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
slotNumbersInput.addEventListener('input', updateSlotPreview);
btnClearSlots.addEventListener('click', () => {
if (confirm('모두 지우시겠습니까?')) {
slotNumbersInput.value = '';
@@ -604,7 +701,7 @@ document.addEventListener('DOMContentLoaded', () => {
e.preventDefault();
const btn = form.querySelector('button[type="submit"]');
if (!btn || btn.disabled) return;
const originalContent = btn.innerHTML;
const executeMove = (overwrite = false) => {
@@ -620,48 +717,48 @@ document.addEventListener('DOMContentLoaded', () => {
},
body: JSON.stringify({ overwrite: overwrite })
})
.then(async response => {
const ct = response.headers.get("content-type");
if (!ct || !ct.includes("application/json")) {
// HTTP 200이지만 HTML이 올 경우 에러나 마찬가지 (로그인 리다이렉트 등)
if (response.ok && response.url.includes("login")) {
.then(async response => {
const ct = response.headers.get("content-type");
if (!ct || !ct.includes("application/json")) {
// HTTP 200이지만 HTML이 올 경우 에러나 마찬가지 (로그인 리다이렉트 등)
if (response.ok && response.url.includes("login")) {
window.location.reload();
return;
}
// "MAC 이동 중 오류: HTTP 200" 등 텍스트일 수 있음
const txt = await response.text();
if (response.ok) {
// 성공으로 간주하고 리로드 (가끔 백엔드가 JSON 대신 빈 성공 응답을 줄 때)
window.location.reload();
return;
}
throw new Error(txt);
}
return response.json();
})
.then(data => {
if (!data) return; // 위에서 처리됨
if (data.requires_confirmation) {
showDuplicateModal(data.duplicates, data.duplicate_count);
pendingAction = executeMove;
resetButton();
} else if (data.success) {
window.location.reload();
} else {
alert(data.error || '작업 실패');
resetButton();
}
})
.catch(err => {
console.error(err);
// HTTP 200 에러 억제 요청 반영
if (err.message && err.message.includes("200")) {
window.location.reload();
return;
}
// "MAC 이동 중 오류: HTTP 200" 등 텍스트일 수 있음
const txt = await response.text();
if (response.ok) {
// 성공으로 간주하고 리로드 (가끔 백엔드가 JSON 대신 빈 성공 응답을 줄 때)
window.location.reload();
return;
}
throw new Error(txt);
}
return response.json();
})
.then(data => {
if (!data) return; // 위에서 처리됨
if (data.requires_confirmation) {
showDuplicateModal(data.duplicates, data.duplicate_count);
pendingAction = executeMove;
alert('오류 발생: ' + err);
resetButton();
} else if (data.success) {
window.location.reload();
} else {
alert(data.error || '작업 실패');
resetButton();
}
})
.catch(err => {
console.error(err);
// HTTP 200 에러 억제 요청 반영
if (err.message && err.message.includes("200")) {
window.location.reload();
return;
}
alert('오류 발생: ' + err);
resetButton();
});
});
};
const resetButton = () => {
@@ -690,7 +787,7 @@ document.addEventListener('DOMContentLoaded', () => {
li.innerHTML = `<i class="bi bi-file-earmark text-secondary me-2"></i>${name}`;
listEl.appendChild(li);
});
if (duplicates.length > limit) {
if (moreEl) {
moreEl.style.display = 'block';
@@ -702,4 +799,226 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (dupModal) dupModal.show();
}
// ─────────────────────────────────────────────────────────────
// 6. 작업 유형 및 프로파일 처리 (process_ips 관련)
// ─────────────────────────────────────────────────────────────
const jobTypeSelect = document.getElementById('job_type');
const profileSelect = document.getElementById('profile');
const profileGroup = document.getElementById('profileGroup');
const profileDesc = document.getElementById('profileDesc');
const legacyScriptGroup = document.getElementById('legacyScriptGroup');
const scriptSelectLegacy = document.getElementById('script'); // 기존 스크립트 select
if (jobTypeSelect) {
jobTypeSelect.addEventListener('change', async () => {
const jobType = jobTypeSelect.value;
// 초기화
if (profileSelect) {
profileSelect.innerHTML = '<option value="">프로파일을 불러오는 중...</option>';
profileDesc.textContent = '';
}
if (jobType === 'legacy') {
// Legacy 모드
if (profileGroup) profileGroup.style.display = 'none';
if (legacyScriptGroup) legacyScriptGroup.style.display = 'block';
if (scriptSelectLegacy) scriptSelectLegacy.required = true;
if (profileSelect) profileSelect.required = false;
} else if (['mac', 'server_info', 'guid'].includes(jobType)) {
// 신규 모드
if (legacyScriptGroup) legacyScriptGroup.style.display = 'none';
if (profileGroup) profileGroup.style.display = 'block';
if (scriptSelectLegacy) scriptSelectLegacy.required = false;
if (profileSelect) profileSelect.required = true;
// 프로파일 로드
try {
const res = await fetch(`/api/profiles/${jobType}`);
if (!res.ok) throw new Error('프로파일 로드 실패');
const profiles = await res.json();
profileSelect.innerHTML = '<option value="">프로파일을 선택하세요</option>';
profiles.forEach(p => {
const opt = document.createElement('option');
opt.value = p.name;
opt.textContent = `${p.name} ${p.is_default ? '(기본)' : ''}`;
opt.dataset.desc = p.description;
profileSelect.appendChild(opt);
});
} catch (err) {
console.error(err);
profileSelect.innerHTML = '<option value="">오류 발생</option>';
}
} else {
// 선택 안함
if (legacyScriptGroup) legacyScriptGroup.style.display = 'none';
if (profileGroup) profileGroup.style.display = 'none';
}
});
// 프로파일 설명 표시
if (profileSelect) {
profileSelect.addEventListener('change', () => {
const selected = profileSelect.options[profileSelect.selectedIndex];
if (selected && selected.dataset.desc) {
profileDesc.textContent = selected.dataset.desc;
} else {
profileDesc.textContent = '';
}
});
}
}
// ─────────────────────────────────────────────────────────────
// 7. 탭 활성화 동기화 (외부 탭 활성화 시 내부 서브탭도 활성화)
// ─────────────────────────────────────────────────────────────
const repositoryTab = document.getElementById('repository-tab');
if (repositoryTab) {
repositoryTab.addEventListener('shown.bs.tab', function () {
// repository 탭이 활성화되면 첫 번째 서브탭(pills-backup)도 활성화
const firstPillTab = document.getElementById('pills-backup-tab');
const firstPillPane = document.getElementById('pills-backup');
if (firstPillTab && firstPillPane) {
// 버튼 활성화
document.querySelectorAll('#repoTabs .nav-link').forEach(btn => {
btn.classList.remove('active');
btn.setAttribute('aria-selected', 'false');
});
firstPillTab.classList.add('active');
firstPillTab.setAttribute('aria-selected', 'true');
// 패널 활성화
}
});
}
// ─────────────────────────────────────────────────────────────
// 7. 커스텀 탭 전환 로직
// ─────────────────────────────────────────────────────────────
// 메인 탭 전환
const mainTabButtons = document.querySelectorAll('#fileTabs button[data-custom-toggle="tab"]');
mainTabButtons.forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
// 모든 탭 버튼 비활성화
mainTabButtons.forEach(b => b.classList.remove('active'));
// 클릭한 탭 버튼 활성화
this.classList.add('active');
// 모든 탭 패널 숨기기
document.querySelectorAll('.custom-tab-panel').forEach(panel => {
panel.classList.remove('custom-active');
panel.style.display = 'none'; // 인라인 스타일로 강제 숨김
});
// 대상 탭 패널 표시
const targetId = this.getAttribute('data-bs-target');
const targetPanel = document.querySelector(targetId);
if (targetPanel) {
targetPanel.classList.add('custom-active');
targetPanel.style.display = 'block'; // 인라인 스타일도 변경
}
});
});
// 서브 탭 전환 (Pills)
const subTabButtons = document.querySelectorAll('#repoTabs button[data-custom-toggle="pill"]');
subTabButtons.forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
// 모든 서브 탭 버튼 비활성화
subTabButtons.forEach(b => b.classList.remove('active'));
// 모든 서브 탭 패널 숨기기
document.querySelectorAll('.custom-sub-panel').forEach(panel => {
panel.classList.remove('custom-sub-active');
panel.classList.add('d-none');
panel.style.display = 'none';
});
// subpanel-backup을 명시적으로 한 번 더 숨김
const backupPanel = document.getElementById('subpanel-backup');
if (backupPanel) {
backupPanel.classList.add('d-none');
backupPanel.style.setProperty('display', 'none', 'important');
}
// 대상 서브 탭 패널 표시
const targetId = this.getAttribute('data-bs-target');
const targetPanel = document.querySelector(targetId);
if (targetPanel) {
targetPanel.classList.add('custom-sub-active');
targetPanel.classList.remove('d-none');
targetPanel.classList.remove('display-none'); // 혹시 모를 클래스 제거
targetPanel.style.setProperty('display', 'block', 'important');
}
});
});
// ─────────────────────────────────────────────────────────────
// 8. URL 파라미터 기반 탭 활성화 (페이지네이션 지원)
// ─────────────────────────────────────────────────────────────
const urlParams = new URLSearchParams(window.location.search);
// 'backup_page' 파라미터가 있으면 '백업 및 저장소' -> '서버 정보' 탭 활성화
if (urlParams.has('backup_page')) {
const repoTabBtn = document.getElementById('repository-tab');
const backupSubTabBtn = document.getElementById('subpanel-backup-tab');
if (repoTabBtn) {
// 메인 탭 전환 트리거
repoTabBtn.click();
}
if (backupSubTabBtn) {
// 서브 탭 전환 트리거 (메인 탭 전환 후 실행)
setTimeout(() => {
backupSubTabBtn.click();
}, 50);
}
}
// ─────────────────────────────────────────────────────────────
// 9. 시스템 제어 패널 토글 아이콘 회전
// ─────────────────────────────────────────────────────────────
const systemControlPanel = document.getElementById('systemControlPanel');
const systemControlHint = document.getElementById('systemControlHint');
if (systemControlPanel) {
systemControlPanel.addEventListener('show.bs.collapse', () => {
const icon = document.querySelector('button[data-bs-target="#systemControlPanel"] i');
if (icon) {
icon.classList.remove('bi-chevron-down');
icon.classList.add('bi-chevron-up');
}
if (systemControlHint) {
systemControlHint.style.display = 'none';
}
});
systemControlPanel.addEventListener('hide.bs.collapse', () => {
const icon = document.querySelector('button[data-bs-target="#systemControlPanel"] i');
if (icon) {
icon.classList.remove('bi-chevron-up');
icon.classList.add('bi-chevron-down');
}
if (systemControlHint) {
systemControlHint.style.display = 'inline';
}
});
// 힌트 클릭 시 트리거
if (systemControlHint) {
systemControlHint.addEventListener('click', () => {
const btn = document.querySelector('button[data-bs-target="#systemControlPanel"]');
if (btn) btn.click();
});
}
}
});
+66 -681
View File
@@ -1,16 +1,19 @@
/**
* Dell iDRAC 멀티 서버 펌웨어 관리 JavaScript
* Dell iDRAC 멀티 서버 관리 JavaScript
* backend/static/js/idrac_main.js
*/
// SocketIO 연결
// SocketIO 연결 (혹시 모를 추후 사용을 위해 유지하나, 현재는 직접적 이벤트 없음)
const socket = io();
// 전역 변수
let servers = [];
let selectedServers = new Set();
// CSRF 토큰 가져오기
// ========================================
// 유틸리티 (CSRF, Fetch, Toast)
// ========================================
function getCSRFToken() {
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) return metaTag.getAttribute('content');
@@ -23,7 +26,6 @@ function getCSRFToken() {
return null;
}
// fetch 래퍼 함수
async function fetchWithCSRF(url, options = {}) {
const csrfToken = getCSRFToken();
if (!options.headers) options.headers = {};
@@ -49,6 +51,30 @@ async function fetchWithCSRF(url, options = {}) {
return fetch(url, options);
}
function showMessage(message, type = 'info') {
showToast(message, type);
console.log(`[${type}] ${message}`);
}
function showToast(message, type = 'info') {
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 100);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ========================================
// 초기화
// ========================================
@@ -57,19 +83,16 @@ document.addEventListener('DOMContentLoaded', function () {
console.log('iDRAC 멀티 서버 관리 시스템 시작');
refreshServers();
loadGroups();
setupSocketIO();
// 초기 탭 설정
showTab('servers');
});
// ========================================
// 서버 관리
// 서버 관리 (목록, 렌더링)
// ========================================
async function refreshServers() {
try {
const group = document.getElementById('group-filter').value;
const groupEl = document.getElementById('group-filter');
const group = groupEl ? groupEl.value : 'all';
const url = `/idrac/api/servers${group !== 'all' ? '?group=' + group : ''}`;
const response = await fetchWithCSRF(url);
const data = await response.json();
@@ -88,6 +111,8 @@ async function refreshServers() {
function renderServerList() {
const tbody = document.getElementById('server-list');
if (!tbody) return;
if (servers.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-message">등록된 서버가 없습니다</td></tr>';
return;
@@ -102,7 +127,7 @@ function renderServerList() {
<td><code>${server.ip_address}</code></td>
<td><span class="badge">${server.group_name || '-'}</span></td>
<td><span class="status status-${server.status}">${getStatusText(server.status)}</span></td>
<td>${server.current_bios || '-'}</td>
<td>${server.model || '-'}</td>
<td>${server.last_connected ? formatDateTime(server.last_connected) : '-'}</td>
<td class="action-buttons">
<button onclick="testConnection(${server.id})" class="btn-icon" title="연결 테스트">🔌</button>
@@ -119,6 +144,7 @@ function getStatusText(status) {
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('ko-KR', {
year: '2-digit', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
@@ -126,11 +152,12 @@ function formatDateTime(dateStr) {
}
function updateServerCount() {
document.getElementById('server-count').textContent = `${servers.length}`;
const el = document.getElementById('server-count');
if (el) el.textContent = `${servers.length}`;
}
// ========================================
// 서버 선택
// 서버 선택 관리
// ========================================
function toggleSelectAll() {
@@ -153,11 +180,13 @@ function toggleServerSelection(serverId) {
const all = document.querySelectorAll('.server-checkbox');
const checked = document.querySelectorAll('.server-checkbox:checked');
document.getElementById('select-all').checked = all.length === checked.length;
const selectAll = document.getElementById('select-all');
if (selectAll) selectAll.checked = all.length > 0 && all.length === checked.length;
}
function updateSelectedCount() {
document.getElementById('selected-count').textContent = `선택: ${selectedServers.size}`;
const el = document.getElementById('selected-count');
if (el) el.textContent = `선택: ${selectedServers.size}`;
}
function getSelectedServerIds() {
@@ -175,6 +204,8 @@ async function loadGroups() {
if (data.success) {
const select = document.getElementById('group-filter');
if (!select) return;
const currentValue = select.value;
select.innerHTML = '<option value="all">전체</option>';
data.groups.forEach(g => select.innerHTML += `<option value="${g}">${g}</option>`);
@@ -190,7 +221,7 @@ function filterByGroup() {
}
// ========================================
// 서버 추가/수정/삭제
// 서버 CRUD
// ========================================
function showAddServerModal() {
@@ -200,7 +231,7 @@ function showAddServerModal() {
function closeAddServerModal() {
const modal = document.getElementById('add-server-modal');
if (modal) modal.style.display = 'none';
setTimeout(() => clearServerForm(), 0); // DOM 안정 후 폼 초기화
setTimeout(() => clearServerForm(), 0);
}
function clearServerForm() {
@@ -214,12 +245,15 @@ function clearServerForm() {
if (el.type === 'checkbox') el.checked = false;
else if (el.type === 'file') el.value = null;
else el.value = '';
} else {
console.warn(`[clearServerForm] 요소 없음: #${id}`);
}
});
}
function editServer(serverId) {
showMessage('서버 수정 기능은 현재 준비 중입니다.', 'info');
// 추후 구현: 모달에 데이터 로드 후 띄우기
}
async function addServer() {
const serverData = {
name: document.getElementById('server-name').value,
@@ -269,7 +303,7 @@ async function deleteServer(serverId) {
}
// ========================================
// 연결 테스트
// 연결 테스트 및 제어
// ========================================
async function testConnection(serverId) {
@@ -283,295 +317,6 @@ async function testConnection(serverId) {
}
}
// ========================================
// 펌웨어 업로드
// ========================================
function showUploadModal() {
const ids = getSelectedServerIds();
if (ids.length === 0) return showMessage('서버를 선택하세요', 'warning');
document.getElementById('upload-server-count').textContent = `${ids.length}`;
document.getElementById('upload-modal').style.display = 'flex';
}
function closeUploadModal() {
document.getElementById('upload-modal').style.display = 'none';
document.getElementById('firmware-file').value = '';
}
async function startMultiUpload() {
const fileInput = document.getElementById('firmware-file');
const serverIds = getSelectedServerIds();
if (!fileInput.files[0]) {
showMessage('파일을 선택하세요', 'warning');
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('server_ids', serverIds.join(','));
try {
closeUploadModal();
showUploadProgress(serverIds);
const response = await fetchWithCSRF('/idrac/api/upload-multi', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showMessage(data.message, 'success');
} else {
showMessage(data.message, 'error');
hideUploadProgress();
}
} catch (error) {
showMessage('업로드 시작 실패: ' + error, 'error');
hideUploadProgress();
}
}
function showUploadProgress(serverIds) {
const section = document.getElementById('upload-progress-section');
const list = document.getElementById('upload-progress-list');
section.style.display = 'block';
// 각 서버별 진행 바 생성
list.innerHTML = serverIds.map(id => {
const server = servers.find(s => s.id === id);
return `
<div class="progress-item" id="progress-${id}">
<div class="progress-header">
<strong>${server.name}</strong> (${server.ip_address})
<span class="progress-status" id="status-${id}">대기중...</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" id="bar-${id}" style="width: 0%"></div>
</div>
<div class="progress-message" id="message-${id}"></div>
</div>
`;
}).join('');
// 요약 초기화
document.getElementById('progress-summary').innerHTML = `
<div class="summary-stats">
<span>전체: ${serverIds.length}대</span>
<span id="completed-count">완료: 0대</span>
<span id="failed-count">실패: 0대</span>
</div>
`;
}
function hideUploadProgress() {
document.getElementById('upload-progress-section').style.display = 'none';
}
// ========================================
// SocketIO 이벤트
// ========================================
function setupSocketIO() {
// 업로드 진행 상황
socket.on('upload_progress', function (data) {
console.log('Upload progress:', data);
const statusEl = document.getElementById(`status-${data.server_id}`);
const barEl = document.getElementById(`bar-${data.server_id}`);
const messageEl = document.getElementById(`message-${data.server_id}`);
if (statusEl) statusEl.textContent = data.message;
if (barEl) {
barEl.style.width = data.progress + '%';
barEl.className = `progress-bar progress-${data.status}`;
}
if (messageEl) messageEl.textContent = data.job_id ? `Job ID: ${data.job_id}` : '';
});
// 업로드 완료
socket.on('upload_complete', function (data) {
console.log('Upload complete:', data);
const summary = data.summary;
document.getElementById('completed-count').textContent = `완료: ${summary.success}`;
document.getElementById('failed-count').textContent = `실패: ${summary.failed}`;
showMessage(`업로드 완료: 성공 ${summary.success}대, 실패 ${summary.failed}`, 'success');
showResults(data.results, '업로드 결과');
refreshServers();
});
}
// ========================================
// 결과 표시
// ========================================
function showResults(results, title) {
const section = document.getElementById('result-section');
const content = document.getElementById('result-content');
section.style.display = 'block';
content.innerHTML = `
<h3>${title}</h3>
<table class="result-table">
<thead>
<tr>
<th>서버명</th>
<th>상태</th>
<th>메시지</th>
</tr>
</thead>
<tbody>
${results.map(r => `
<tr class="${r.success ? 'success' : 'failed'}">
<td><strong>${r.server_name}</strong></td>
<td>
<span class="result-badge ${r.success ? 'badge-success' : 'badge-failed'}">
${r.success ? '✓ 성공' : '✗ 실패'}
</span>
</td>
<td>${r.message}${r.job_id ? ` (Job: ${r.job_id})` : ''}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
// ========================================
// 재부팅
// ========================================
async function rebootSelected() {
const serverIds = getSelectedServerIds();
if (serverIds.length === 0) {
showMessage('서버를 선택하세요', 'warning');
return;
}
if (!confirm(`선택한 ${serverIds.length}대 서버를 재부팅하시겠습니까?\n\n⚠️ 업로드된 펌웨어가 적용됩니다!`)) {
return;
}
try {
const response = await fetchWithCSRF('/idrac/api/servers/reboot-multi', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
server_ids: serverIds,
type: 'GracefulRestart'
})
});
const data = await response.json();
if (data.success) {
const summary = data.summary;
showMessage(`재부팅 시작: 성공 ${summary.success}대, 실패 ${summary.failed}`, 'success');
showResults(data.results, '재부팅 결과');
} else {
showMessage(data.message, 'error');
}
refreshServers();
} catch (error) {
showMessage('재부팅 실패: ' + error, 'error');
}
}
// ========================================
// Excel 가져오기 (추후 구현)
// ========================================
function importExcel() {
showMessage('Excel 가져오기 기능은 개발 중입니다', 'info');
}
// ========================================
// 유틸리티
// ========================================
function showMessage(message, type = 'info') {
// 토스트 알림으로 변경
showToast(message, type);
console.log(`[${type}] ${message}`);
}
// ========================================
// 토스트 알림 시스템
// ========================================
function showToast(message, type = 'info') {
// 기존 토스트 제거
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
// 새 토스트 생성
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
// 애니메이션
setTimeout(() => toast.classList.add('show'), 100);
// 3초 후 제거
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// 편의 함수들
function editServer(serverId) {
showMessage('서버 수정 기능은 개발 중입니다', 'info');
}
function getSelectedFirmware() {
const serverIds = getSelectedServerIds();
if (serverIds.length === 0) {
showMessage('서버를 선택하세요', 'warning');
return;
}
showMessage('선택한 서버의 펌웨어 조회 중...', 'info');
// 각 서버별로 펌웨어 조회
serverIds.forEach(async (serverId) => {
try {
const response = await fetchWithCSRF(`/idrac/api/servers/${serverId}/firmware`);
const data = await response.json();
if (data.success) {
console.log(`Server ${serverId} firmware:`, data.data);
}
} catch (error) {
console.error(`Server ${serverId} firmware query failed:`, error);
}
});
// 새로고침
setTimeout(() => {
refreshServers();
}, 2000);
}
// ========================================
// 다중 연결 테스트
// ========================================
async function testSelectedConnections() {
const serverIds = getSelectedServerIds();
@@ -593,7 +338,6 @@ async function testSelectedConnections() {
if (data.success) {
const summary = data.summary;
showMessage(`연결 성공: ${summary.success}대 / 실패: ${summary.failed}`, 'success');
showResults(data.results, '연결 테스트 결과');
} else {
showMessage(data.message, 'error');
}
@@ -603,398 +347,39 @@ async function testSelectedConnections() {
}
}
// ========================================
// 다중 펌웨어 버전 비교
// ========================================
async function compareSelectedFirmware() {
async function rebootSelected() {
const serverIds = getSelectedServerIds();
if (serverIds.length === 0) {
showToast('서버를 선택하세요', 'warning');
showMessage('서버를 선택하세요', 'warning');
return;
}
// 비교 탭으로 전환
showTab('comparison');
// 로딩 표시
const loadingEl = document.getElementById('comparison-loading');
const resultEl = document.getElementById('comparison-result');
if (loadingEl) loadingEl.style.display = 'block';
if (resultEl) resultEl.innerHTML = '';
try {
const res = await fetchWithCSRF('/idrac/api/servers/firmware/compare-multi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_ids: serverIds })
});
const data = await res.json();
// 로딩 숨기기
if (loadingEl) loadingEl.style.display = 'none';
if (data.success) {
displayComparisonResults(data.results);
showToast(`${serverIds.length}대 서버 비교 완료`, 'success');
} else {
if (resultEl) {
resultEl.innerHTML = `<div class="warning-text">⚠️ ${data.message}</div>`;
}
showToast(data.message, 'error');
}
} catch (error) {
if (loadingEl) loadingEl.style.display = 'none';
if (resultEl) {
resultEl.innerHTML = `<div class="warning-text">⚠️ 버전 비교 실패: ${error}</div>`;
}
showToast('버전 비교 실패: ' + error, 'error');
}
}
// ========================================
// 비교 결과 표시 (개선된 버전)
// ========================================
function displayComparisonResults(results) {
const resultEl = document.getElementById('comparison-result');
if (!resultEl) return;
if (!results || results.length === 0) {
resultEl.innerHTML = '<div class="info-text">비교 결과가 없습니다</div>';
return;
}
let html = '<div class="comparison-grid">';
results.forEach(serverResult => {
html += `
<div class="server-comparison-section">
<h3 style="margin-bottom: 15px; color: #333;">
🖥️ ${serverResult.server_name}
<span style="font-size: 0.8em; color: #666;">(${serverResult.server_ip || ''})</span>
</h3>
`;
if (serverResult.success && serverResult.comparisons) {
serverResult.comparisons.forEach(comp => {
const statusClass = comp.status === 'outdated' ? 'outdated' :
comp.status === 'latest' ? 'latest' : 'unknown';
const statusIcon = comp.status === 'outdated' ? '⚠️' :
comp.status === 'latest' ? '✅' : '❓';
html += `
<div class="comparison-card ${statusClass}">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 10px;">
<strong style="font-size: 1.1em;">${comp.component_name}</strong>
<span style="font-size: 1.5em;">${statusIcon}</span>
</div>
<div style="margin-bottom: 8px;">
<span style="color: #666;">현재:</span>
<code style="background: #f0f0f0; padding: 2px 8px; border-radius: 4px;">${comp.current_version}</code>
</div>
<div style="margin-bottom: 8px;">
<span style="color: #666;">최신:</span>
<code style="background: #f0f0f0; padding: 2px 8px; border-radius: 4px;">${comp.latest_version || 'N/A'}</code>
</div>
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #ddd; font-size: 0.9em; color: #555;">
${comp.recommendation}
</div>
</div>
`;
});
} else {
html += `<div class="warning-text">⚠️ ${serverResult.message || '비교 실패'}</div>`;
}
html += '</div>';
});
html += '</div>';
resultEl.innerHTML = html;
}
// ========================================
// 펌웨어 버전 추가 모달
// ========================================
function showAddVersionModal() {
const modal = document.getElementById('add-version-modal');
if (modal) modal.style.display = 'flex';
}
function closeAddVersionModal() {
const modal = document.getElementById('add-version-modal');
if (modal) modal.style.display = 'none';
}
async function addFirmwareVersion() {
const data = {
component_name: document.getElementById('version-component').value,
latest_version: document.getElementById('version-latest').value,
server_model: document.getElementById('version-model').value,
vendor: document.getElementById('version-vendor').value,
release_date: document.getElementById('version-release-date').value,
download_url: document.getElementById('version-download-url').value,
notes: document.getElementById('version-notes').value,
is_critical: document.getElementById('version-critical').checked
};
if (!data.component_name || !data.latest_version) {
showMessage('컴포넌트명과 버전을 입력하세요', 'warning');
if (!confirm(`선택한 ${serverIds.length}대 서버를 재부팅하시겠습니까?`)) {
return;
}
try {
const response = await fetchWithCSRF('/idrac/api/firmware-versions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
showMessage(result.message, 'success');
closeAddVersionModal();
refreshFirmwareVersionList();
} else {
showMessage(result.message, 'error');
}
} catch (error) {
showMessage('버전 추가 실패: ' + error, 'error');
}
}
async function refreshFirmwareVersionList() {
try {
const response = await fetchWithCSRF('/idrac/api/firmware-versions');
const data = await response.json();
if (data.success) {
const tbody = document.getElementById('version-list');
tbody.innerHTML = data.versions.map(v => `
<tr>
<td>${v.component_name}</td>
<td>${v.latest_version}</td>
<td>${v.server_model || '-'}</td>
<td>${v.release_date || '-'}</td>
<td>${v.is_critical ? '⚠️' : ''}</td>
<td><button class="btn btn-danger" onclick="deleteFirmwareVersion(${v.id})">삭제</button></td>
</tr>
`).join('');
}
} catch (error) {
showMessage('버전 목록 로드 실패: ' + error, 'error');
}
}
// ========================================
// Dell Catalog에서 최신 버전 자동 가져오기
// ========================================
async function syncDellCatalog(model = "PowerEdge R750") {
showToast(`${model} 최신 버전 정보를 Dell에서 가져오는 중...`, "info");
try {
const response = await fetchWithCSRF("/catalog/sync", {
method: "POST",
body: { model }
});
const data = await response.json();
if (data.success) {
showToast(data.message, "success");
await refreshFirmwareVersionList();
} else {
showToast(data.message, "error");
}
} catch (error) {
showToast("버전 정보 동기화 실패: " + error, "error");
}
}
// ========================================
// DRM 리포지토리 동기화
// ========================================
async function syncFromDRM() {
// DRM 리포지토리 경로 입력 받기
const repositoryPath = prompt(
'DRM 리포지토리 경로를 입력하세요:\n예: C:\\Dell\\Repository 또는 \\\\network\\share\\DellRepo',
'C:\\Dell\\Repository'
);
if (!repositoryPath) return;
const model = prompt('서버 모델을 입력하세요:', 'PowerEdge R750');
if (!model) return;
showToast('DRM 리포지토리에서 펌웨어 정보 가져오는 중...', 'info');
try {
const response = await fetchWithCSRF('/drm/sync', {
const response = await fetchWithCSRF('/idrac/api/servers/reboot-multi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
repository_path: repositoryPath,
model: model
server_ids: serverIds,
type: 'GracefulRestart'
})
});
const data = await response.json();
if (data.success) {
showToast(`${data.message}`, 'success');
await refreshFirmwareVersionList();
const summary = data.summary;
showMessage(`재부팅 시작: 성공 ${summary.success}대, 실패 ${summary.failed}`, 'success');
} else {
showToast(data.message, 'error');
showMessage(data.message, 'error');
}
refreshServers();
} catch (error) {
showToast('DRM 동기화 실패: ' + error, 'error');
}
}
async function checkDRMRepository() {
const repositoryPath = prompt(
'DRM 리포지토리 경로를 입력하세요:',
'C:\\Dell\\Repository'
);
if (!repositoryPath) return;
try {
const response = await fetchWithCSRF('/drm/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repository_path: repositoryPath })
});
const data = await response.json();
if (data.success && data.info) {
const info = data.info;
let message = `DRM 리포지토리 정보:\n\n`;
message += `경로: ${info.path}\n`;
message += `카탈로그 파일: ${info.catalog_file || '없음'}\n`;
message += `총 패키지 수: ${info.total_packages || 0}\n`;
if (info.available_models && info.available_models.length > 0) {
message += `\n사용 가능한 모델 (${info.available_models.length}개):\n`;
message += info.available_models.slice(0, 10).join(', ');
if (info.available_models.length > 10) {
message += ` ... 외 ${info.available_models.length - 10}`;
}
}
alert(message);
} else {
showToast('DRM 리포지토리를 찾을 수 없습니다', 'error');
}
} catch (error) {
showToast('DRM 확인 실패: ' + error, 'error');
}
}
// ========================================
// 펌웨어 버전 목록 새로고침
// ========================================
async function refreshFirmwareVersionList() {
try {
const response = await fetchWithCSRF("/idrac/api/firmware-versions");
const data = await response.json();
if (data.success) {
const tbody = document.getElementById("version-list");
if (!tbody) return;
tbody.innerHTML = data.versions.length
? data.versions
.map(
(v) => `
<tr>
<td>${v.component_name}</td>
<td>${v.latest_version}</td>
<td>${v.server_model || "-"}</td>
<td>${v.release_date || "-"}</td>
<td>${v.is_critical ? "⚠️" : ""}</td>
<td>
<button class="btn btn-danger" onclick="deleteFirmwareVersion(${v.id})">삭제</button>
</td>
</tr>`
)
.join("")
: `<tr><td colspan="6" class="empty-message">등록된 버전 정보가 없습니다</td></tr>`;
} else {
showToast(data.message, "error");
}
} catch (error) {
showToast("버전 목록 로드 실패: " + error, "error");
}
}
// ========================================
// 탭 전환 기능
// ========================================
function showTab(tabName) {
// 모든 탭 콘텐츠 숨기기
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
// 모든 탭 버튼 비활성화
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
// 선택된 탭 표시
const selectedTab = document.getElementById(tabName + '-tab');
if (selectedTab) {
selectedTab.classList.add('active');
}
// 클릭된 버튼 활성화 (이벤트에서 호출된 경우)
if (event && event.target) {
event.target.classList.add('active');
} else {
// 직접 호출된 경우 해당 버튼 찾아서 활성화
const buttons = document.querySelectorAll('.tab-button');
buttons.forEach((btn, index) => {
if ((tabName === 'servers' && index === 0) ||
(tabName === 'versions' && index === 1) ||
(tabName === 'comparison' && index === 2)) {
btn.classList.add('active');
}
});
}
// 버전 탭 선택 시 목록 로드
if (tabName === 'versions') {
refreshFirmwareVersionList();
}
}
// ========================================
// 펌웨어 버전 삭제
// ========================================
async function deleteFirmwareVersion(versionId) {
if (!confirm('이 펌웨어 버전 정보를 삭제하시겠습니까?')) return;
try {
const response = await fetchWithCSRF(`/idrac/api/firmware-versions/${versionId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
refreshFirmwareVersionList();
} else {
showToast(data.message, 'error');
}
} catch (error) {
showToast('삭제 실패: ' + error, 'error');
showMessage('재부팅 실패: ' + error, 'error');
}
}
+324
View File
@@ -0,0 +1,324 @@
let allServers = [];
let analysisData = {};
document.addEventListener('DOMContentLoaded', () => {
loadServers();
document.getElementById('check-all').addEventListener('change', (e) => {
const checked = e.target.checked;
document.querySelectorAll('.server-checkbox').forEach(cb => cb.checked = checked);
});
});
async function loadServers() {
try {
const res = await axios.get('/idrac/api/servers');
if (res.data.success) {
allServers = res.data.servers;
renderServerList(allServers);
} else {
alert('서버 목록 로드 실패: ' + res.data.message);
}
} catch (e) {
console.error(e);
alert('서버 목록 로드 중 오류가 발생했습니다.');
}
}
function renderServerList(servers) {
const tbody = document.querySelector('#selection-table tbody');
tbody.innerHTML = '';
if (servers.length === 0) {
tbody.innerHTML = '<tr><td colspan="5">등록된 서버가 없습니다.</td></tr>';
return;
}
servers.forEach(server => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input type="checkbox" class="server-checkbox" value="${server.id}"></td>
<td>${server.name}</td>
<td>${server.ip_address}</td>
<td>${server.model || '-'}</td>
<td>${server.status || 'Unknown'}</td>
`;
tbody.appendChild(tr);
});
}
// 시작: 서버 선택 후 동기화 (iDRAC -> DB)
async function syncSelectedServers() {
const selectedIds = Array.from(document.querySelectorAll('.server-checkbox:checked')).map(cb => parseInt(cb.value));
if (selectedIds.length === 0) {
alert('동기화할 서버를 하나 이상 선택해주세요.');
return;
}
if (!confirm(`선택한 ${selectedIds.length}대 서버의 설정을 iDRAC에서 가져와 DB에 저장하시겠습니까?`)) {
return;
}
document.getElementById('loading').style.display = 'flex';
updateLoadingMessage('설정 데이터 동기화 중...');
try {
const res = await axios.post('/server-config/api/sync', {
server_ids: selectedIds,
categories: ['bios', 'system', 'idrac']
});
if (res.data.success) {
const failed = res.data.results.failed;
if (failed.length > 0) {
const failedNames = failed.map(f => f.name || f.id).join(', ');
alert(`일부 서버 동기화 실패:\n${failedNames}\n\n성공한 서버는 DB에 저장되었습니다.`);
} else {
alert('모든 서버 동기화가 완료되었습니다. 이제 분석을 수행할 수 있습니다.');
}
// 만약 이미 결과 화면이 보이고 있다면 자동 갱신
if (document.getElementById('result-card').style.display !== 'none') {
startAnalysis();
}
} else {
alert('동기화 실패: ' + res.data.message);
}
} catch (e) {
console.error(e);
alert('동기화 요청 중 오류가 발생했습니다: ' + e.message);
} finally {
document.getElementById('loading').style.display = 'none';
}
}
// 시작: DB 데이터 기반 분석 (DB -> Web)
async function startAnalysis() {
const selectedIds = Array.from(document.querySelectorAll('.server-checkbox:checked')).map(cb => parseInt(cb.value));
if (selectedIds.length === 0) {
alert('분석할 서버를 하나 이상 선택해주세요.');
return;
}
document.getElementById('loading').style.display = 'flex';
document.getElementById('result-card').style.display = 'none';
updateLoadingMessage('데이터 분석 중...');
try {
const res = await axios.post('/server-config/api/fetch', {
server_ids: selectedIds,
categories: ['bios', 'system', 'idrac']
});
if (res.data.success) {
analysisData = res.data.results;
renderComparison();
document.getElementById('result-card').style.display = 'block';
} else {
alert('데이터 조회 실패: ' + res.data.message);
}
} catch (e) {
console.error(e);
alert('데이터 조회 중 오류가 발생했습니다.');
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function updateLoadingMessage(msg) {
const loadingText = document.getElementById('loading-text');
if (loadingText) loadingText.textContent = msg;
}
function renderComparison() {
const container = document.getElementById('result-container');
const showDiffOnly = document.getElementById('show-diff-only').checked;
container.innerHTML = '';
const serverIds = Object.keys(analysisData);
if (serverIds.length === 0) {
container.innerHTML = '<p>데이터가 없습니다.</p>';
return;
}
// 통합된 모든 키(Attribute Name) 수집
const allKeys = new Set();
const serverNames = {};
const serverUpdated = {}; // 마지막 업데이트 시간
serverIds.forEach(id => {
const data = analysisData[id];
serverNames[id] = data.name;
// 가장 최근 업데이트 시간 찾기
const times = [data.bios_updated, data.idrac_updated, data.system_updated].filter(t => t);
if (times.length > 0) {
times.sort();
serverUpdated[id] = times[times.length - 1]; // 가장 최신
} else {
serverUpdated[id] = 'N/A';
}
// BIOS
if (data.bios) Object.keys(data.bios).forEach(k => allKeys.add('BIOS: ' + k));
// iDRAC
if (data.idrac) Object.keys(data.idrac).forEach(k => allKeys.add('iDRAC: ' + k));
// System
if (data.system) Object.keys(flattenObject(data.system)).forEach(k => allKeys.add('System: ' + k));
});
const sortedKeys = Array.from(allKeys).sort();
// 테이블 생성
const table = document.createElement('table');
table.className = 'comparison-table';
// 헤더
const thead = document.createElement('thead');
let headerHtml = '<tr><th style="min-width: 200px;">Attribute</th>';
serverIds.forEach(id => {
headerHtml += `<th>${serverNames[id]}</th>`;
});
headerHtml += '</tr>';
// 업데이트 시간 행 추가
headerHtml += '<tr style="background-color: #fafafa; font-size: 0.8em; color: #666;"><th>Local DB Updated</th>';
serverIds.forEach(id => {
const timeStr = serverUpdated[id] !== 'N/A' ? new Date(serverUpdated[id]).toLocaleString() : 'Never';
headerHtml += `<th>${timeStr}</th>`;
});
headerHtml += '</tr>';
thead.innerHTML = headerHtml;
table.appendChild(thead);
// 바디
const tbody = document.createElement('tbody');
let hasDiff = false;
sortedKeys.forEach(key => {
const row = document.createElement('tr');
let rowHtml = `<td>${key}</td>`;
// 값 비교를 위한 배열
const values = [];
serverIds.forEach(id => {
const val = getValueByKey(analysisData[id], key);
values.push(val);
});
// 값들이 모두 같은지 확인
const firstVal = values[0];
const isDiff = !values.every(v => v === firstVal);
if (isDiff) {
row.classList.add('diff-row');
hasDiff = true;
}
if (showDiffOnly && !isDiff) {
return; // Diff Only 모드이고 차이가 없으면 건너뜀
}
values.forEach(val => {
const displayVal = (val === undefined || val === null) ? '<span style="color:#ccc">N/A</span>' : val;
const cellClass = (isDiff && val !== firstVal) ? 'diff-highlight' : ''; // 첫번째 서버(기준)와 다르면 강조
rowHtml += `<td class="${cellClass}">${displayVal}</td>`;
});
row.innerHTML = rowHtml;
tbody.appendChild(row);
});
if (showDiffOnly && !hasDiff) {
// 차이점이 하나도 없는데 차이점만 보기 체크된 경우 빈 row가 아니라 메시지 표시가 나을지 고민
// tbody가 비어있음
}
if (tbody.children.length === 0) {
tbody.innerHTML = '<tr><td colspan="' + (serverIds.length + 1) + '">표시할 항목이 없습니다.</td></tr>';
}
table.appendChild(tbody);
container.appendChild(table);
}
// 헬퍼: 키(Category: Name)로 값 추출
function getValueByKey(data, compositeKey) {
if (!data) return undefined;
const [category, ...rest] = compositeKey.split(': ');
const realKey = rest.join(': '); // 혹시 키 안에 콜론이 있을 경우 복구
if (category === 'BIOS') return data.bios ? data.bios[realKey] : undefined;
if (category === 'iDRAC') return data.idrac ? data.idrac[realKey] : undefined;
if (category === 'System') {
const flatSys = flattenObject(data.system);
return flatSys ? flatSys[realKey] : undefined;
}
return undefined;
}
// 객체 평탄화 (Nested Object -> Flat Key)
function flattenObject(obj, prefix = '') {
if (!obj) return {};
return Object.keys(obj).reduce((acc, k) => {
const pre = prefix.length ? prefix + '.' : '';
if (typeof obj[k] === 'object' && obj[k] !== null) {
Object.assign(acc, flattenObject(obj[k], pre + k));
} else {
acc[pre + k] = obj[k];
}
return acc;
}, {});
}
function downloadReport() {
const table = document.querySelector('.comparison-table');
if (!table) {
alert('다운로드할 데이터가 없습니다. 먼저 분석을 진행해주세요.');
return;
}
let csvContent = "\uFEFF"; // BOM for Excel UTF-8
const rows = table.querySelectorAll('tr');
rows.forEach(row => {
const cols = row.querySelectorAll('th, td');
const rowData = [];
cols.forEach(col => {
// 따옴표 처리 및 텍스트 추출
let text = col.innerText || col.textContent;
text = text.replace(/"/g, '""'); // 따옴표 이스케이프
// 줄바꿈 제거
text = text.replace(/(\r\n|\n|\r)/gm, " ");
if (text.includes(',') || text.includes('"')) {
text = `"${text}"`;
}
rowData.push(text);
});
csvContent += rowData.join(',') + "\r\n";
});
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
link.setAttribute('href', url);
link.setAttribute('download', `server_config_comparison_${timestamp}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}