update
This commit is contained in:
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user