Initial project upload
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
(function () {
|
||||
// Bootstrap 5을 사용한다고 가정. data-bs-* 이벤트로 처리.
|
||||
const changePasswordModal = document.getElementById('changePasswordModal');
|
||||
const modalUserInfo = document.getElementById('modalUserInfo');
|
||||
const changePasswordForm = document.getElementById('changePasswordForm');
|
||||
const newPasswordInput = document.getElementById('newPasswordInput');
|
||||
const confirmPasswordInput = document.getElementById('confirmPasswordInput');
|
||||
const pwMismatch = document.getElementById('pwMismatch');
|
||||
|
||||
if (!changePasswordModal) return;
|
||||
|
||||
changePasswordModal.addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget; // 버튼 that triggered the modal
|
||||
const userId = button.getAttribute('data-user-id');
|
||||
const username = button.getAttribute('data-username') || ('ID ' + userId);
|
||||
|
||||
// 표시 텍스트 세팅
|
||||
modalUserInfo.textContent = username + ' (ID: ' + userId + ')';
|
||||
|
||||
// 폼 action 동적 설정: admin.reset_password 라우트 기대
|
||||
// 예: /admin/users/123/reset_password
|
||||
// Note: This assumes the URL pattern exists. Adjust if needed.
|
||||
const baseUrl = changePasswordForm.getAttribute('data-base-url') || '/admin/users/0/reset_password';
|
||||
changePasswordForm.action = baseUrl.replace('/0/', '/' + userId + '/');
|
||||
|
||||
// 폼 내부 비밀번호 필드 초기화
|
||||
newPasswordInput.value = '';
|
||||
confirmPasswordInput.value = '';
|
||||
confirmPasswordInput.classList.remove('is-invalid');
|
||||
pwMismatch.style.display = 'none';
|
||||
});
|
||||
|
||||
// 폼 제출 전 클라이언트에서 비밀번호 일치 검사
|
||||
changePasswordForm.addEventListener('submit', function (e) {
|
||||
const a = newPasswordInput.value || '';
|
||||
const b = confirmPasswordInput.value || '';
|
||||
if (a.length < 8) {
|
||||
newPasswordInput.focus();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (a !== b) {
|
||||
e.preventDefault();
|
||||
confirmPasswordInput.classList.add('is-invalid');
|
||||
pwMismatch.style.display = 'block';
|
||||
confirmPasswordInput.focus();
|
||||
return;
|
||||
}
|
||||
// 제출 허용 (서버측에서도 반드시 검증)
|
||||
});
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Dell iDRAC 멀티 서버 관리 JavaScript
|
||||
* backend/static/js/idrac_main.js
|
||||
*/
|
||||
|
||||
// SocketIO 연결 (혹시 모를 추후 사용을 위해 유지하나, 현재는 직접적 이벤트 없음)
|
||||
const socket = io();
|
||||
|
||||
// 전역 변수
|
||||
let servers = [];
|
||||
let selectedServers = new Set();
|
||||
|
||||
// ========================================
|
||||
// 유틸리티 (CSRF, Fetch, Toast)
|
||||
// ========================================
|
||||
|
||||
function getCSRFToken() {
|
||||
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
||||
if (metaTag) return metaTag.getAttribute('content');
|
||||
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === 'csrf_token') return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchWithCSRF(url, options = {}) {
|
||||
const csrfToken = getCSRFToken();
|
||||
if (!options.headers) options.headers = {};
|
||||
|
||||
if (csrfToken) {
|
||||
options.headers['X-CSRFToken'] = csrfToken;
|
||||
options.headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
if (options.body) {
|
||||
if (options.body instanceof FormData) {
|
||||
// 자동 처리
|
||||
} else if (typeof options.body === 'string') {
|
||||
if (!options.headers['Content-Type']) {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
} else if (typeof options.body === 'object') {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
options.body = JSON.stringify(options.body);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 초기화
|
||||
// ========================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
console.log('iDRAC 멀티 서버 관리 시스템 시작');
|
||||
refreshServers();
|
||||
loadGroups();
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// 서버 관리 (목록, 렌더링)
|
||||
// ========================================
|
||||
|
||||
async function refreshServers() {
|
||||
try {
|
||||
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();
|
||||
|
||||
if (data.success) {
|
||||
servers = data.servers;
|
||||
renderServerList();
|
||||
updateServerCount();
|
||||
} else {
|
||||
showMessage(data.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('서버 목록 로드 실패: ' + error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
tbody.innerHTML = servers.map(server => `
|
||||
<tr data-server-id="${server.id}">
|
||||
<td><input type="checkbox" class="server-checkbox" value="${server.id}"
|
||||
${selectedServers.has(server.id) ? 'checked' : ''}
|
||||
onchange="toggleServerSelection(${server.id})"></td>
|
||||
<td><strong>${server.name}</strong></td>
|
||||
<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.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>
|
||||
<button onclick="editServer(${server.id})" class="btn-icon" title="수정">✏️</button>
|
||||
<button onclick="deleteServer(${server.id})" class="btn-icon" title="삭제">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
const map = { registered: '등록됨', online: '온라인', offline: '오프라인', updating: '업데이트중' };
|
||||
return map[status] || 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'
|
||||
});
|
||||
}
|
||||
|
||||
function updateServerCount() {
|
||||
const el = document.getElementById('server-count');
|
||||
if (el) el.textContent = `${servers.length}대`;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 서버 선택 관리
|
||||
// ========================================
|
||||
|
||||
function toggleSelectAll() {
|
||||
const checkbox = document.getElementById('select-all');
|
||||
const checkboxes = document.querySelectorAll('.server-checkbox');
|
||||
selectedServers.clear();
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
if (checkbox.checked) selectedServers.add(parseInt(cb.value));
|
||||
});
|
||||
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function toggleServerSelection(serverId) {
|
||||
if (selectedServers.has(serverId)) selectedServers.delete(serverId);
|
||||
else selectedServers.add(serverId);
|
||||
updateSelectedCount();
|
||||
|
||||
const all = document.querySelectorAll('.server-checkbox');
|
||||
const checked = document.querySelectorAll('.server-checkbox:checked');
|
||||
const selectAll = document.getElementById('select-all');
|
||||
if (selectAll) selectAll.checked = all.length > 0 && all.length === checked.length;
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
const el = document.getElementById('selected-count');
|
||||
if (el) el.textContent = `선택: ${selectedServers.size}대`;
|
||||
}
|
||||
|
||||
function getSelectedServerIds() {
|
||||
return Array.from(selectedServers);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 그룹 관리
|
||||
// ========================================
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const response = await fetchWithCSRF('/idrac/api/groups');
|
||||
const data = await response.json();
|
||||
|
||||
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>`);
|
||||
if (currentValue) select.value = currentValue;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('그룹 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function filterByGroup() {
|
||||
refreshServers();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 서버 CRUD
|
||||
// ========================================
|
||||
|
||||
function showAddServerModal() {
|
||||
document.getElementById('add-server-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAddServerModal() {
|
||||
const modal = document.getElementById('add-server-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
setTimeout(() => clearServerForm(), 0);
|
||||
}
|
||||
|
||||
function clearServerForm() {
|
||||
const fieldIds = [
|
||||
'server-name', 'server-ip', 'server-username',
|
||||
'server-password', 'server-group', 'server-model'
|
||||
];
|
||||
fieldIds.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
if (el.type === 'checkbox') el.checked = false;
|
||||
else if (el.type === 'file') el.value = null;
|
||||
else el.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function editServer(serverId) {
|
||||
showMessage('서버 수정 기능은 현재 준비 중입니다.', 'info');
|
||||
// 추후 구현: 모달에 데이터 로드 후 띄우기
|
||||
}
|
||||
|
||||
async function addServer() {
|
||||
const serverData = {
|
||||
name: document.getElementById('server-name').value,
|
||||
ip_address: document.getElementById('server-ip').value,
|
||||
username: document.getElementById('server-username').value,
|
||||
password: document.getElementById('server-password').value,
|
||||
group_name: document.getElementById('server-group').value,
|
||||
model: document.getElementById('server-model').value
|
||||
};
|
||||
|
||||
if (!serverData.name || !serverData.ip_address || !serverData.password) {
|
||||
showMessage('필수 필드를 입력하세요 (서버명, IP, 비밀번호)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithCSRF('/idrac/api/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(serverData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showMessage(data.message, 'success');
|
||||
closeAddServerModal();
|
||||
refreshServers();
|
||||
loadGroups();
|
||||
} else showMessage(data.message, 'error');
|
||||
} catch (e) {
|
||||
showMessage('서버 추가 실패: ' + e, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteServer(serverId) {
|
||||
if (!confirm('이 서버를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const res = await fetchWithCSRF(`/idrac/api/servers/${serverId}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showMessage(data.message, 'success');
|
||||
refreshServers();
|
||||
} else showMessage(data.message, 'error');
|
||||
} catch (e) {
|
||||
showMessage('서버 삭제 실패: ' + e, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 연결 테스트 및 제어
|
||||
// ========================================
|
||||
|
||||
async function testConnection(serverId) {
|
||||
try {
|
||||
const res = await fetchWithCSRF(`/idrac/api/servers/${serverId}/test`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showMessage(data.message, data.success ? 'success' : 'error');
|
||||
refreshServers();
|
||||
} catch (e) {
|
||||
showMessage('연결 테스트 실패: ' + e, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function testSelectedConnections() {
|
||||
const serverIds = getSelectedServerIds();
|
||||
|
||||
if (serverIds.length === 0) {
|
||||
showMessage('서버를 선택하세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage(`선택된 ${serverIds.length}대 서버 연결 테스트 중...`, 'info');
|
||||
|
||||
try {
|
||||
const res = await fetchWithCSRF('/idrac/api/servers/test-multi', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server_ids: serverIds })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const summary = data.summary;
|
||||
showMessage(`연결 성공: ${summary.success}대 / 실패: ${summary.failed}대`, 'success');
|
||||
} else {
|
||||
showMessage(data.message, 'error');
|
||||
}
|
||||
refreshServers();
|
||||
} catch (error) {
|
||||
showMessage('연결 테스트 실패: ' + error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function rebootSelected() {
|
||||
const serverIds = getSelectedServerIds();
|
||||
|
||||
if (serverIds.length === 0) {
|
||||
showMessage('서버를 선택하세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`선택한 ${serverIds.length}대 서버를 재부팅하시겠습니까?`)) {
|
||||
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');
|
||||
} else {
|
||||
showMessage(data.message, 'error');
|
||||
}
|
||||
|
||||
refreshServers();
|
||||
} catch (error) {
|
||||
showMessage('재부팅 실패: ' + error, 'error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// Note: This script expects csrfToken to be available globally
|
||||
// It should be set in the HTML template before this script loads
|
||||
|
||||
// ========== 전역 변수 ==========
|
||||
let CONFIG = {
|
||||
grace_minutes: 60,
|
||||
recency_hours: 24,
|
||||
poll_interval_ms: 10000
|
||||
};
|
||||
|
||||
let monitoringOn = false;
|
||||
let pollTimer = null;
|
||||
let lastRenderHash = "";
|
||||
|
||||
// ========== Elements ==========
|
||||
const $ = id => document.getElementById(id);
|
||||
const $body = $('jobs-body');
|
||||
const $last = $('last-updated');
|
||||
const $loading = $('loading-indicator');
|
||||
const $btn = $('btn-refresh');
|
||||
const $auto = $('autoRefreshSwitch');
|
||||
const $ipInput = $('ipInput');
|
||||
const $ipCount = $('ip-count');
|
||||
const $btnLoad = $('btn-load-file');
|
||||
const $btnApply = $('btn-apply');
|
||||
const $monSw = $('monitorSwitch');
|
||||
const $statusDot = $('status-dot');
|
||||
const $statusText = $('status-text');
|
||||
const $showCompleted = $('showCompletedSwitch');
|
||||
const $pollInterval = $('poll-interval');
|
||||
|
||||
// Stats
|
||||
const $statTotal = $('stat-total');
|
||||
const $statRunning = $('stat-running');
|
||||
const $statCompleted = $('stat-completed');
|
||||
const $statError = $('stat-error');
|
||||
|
||||
// ========== LocalStorage Keys ==========
|
||||
const LS_IPS = 'idrac_job_ips';
|
||||
const LS_MON = 'idrac_monitor_on';
|
||||
const LS_AUTO = 'idrac_monitor_auto';
|
||||
const LS_SHOW_COMPLETED = 'idrac_show_completed';
|
||||
|
||||
// ========== 유틸리티 ==========
|
||||
function parseIps(text) {
|
||||
if (!text) return [];
|
||||
const raw = text.replace(/[,;]+/g, '\n');
|
||||
const out = [], seen = new Set();
|
||||
raw.split('\n').forEach(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
parts.forEach(p => {
|
||||
if (!p || p.startsWith('#')) return;
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
out.push(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function getIpsFromUI() { return parseIps($ipInput.value); }
|
||||
function setIpsToUI(ips) {
|
||||
$ipInput.value = (ips || []).join('\n');
|
||||
updateIpCount();
|
||||
}
|
||||
function updateIpCount() {
|
||||
const ips = getIpsFromUI();
|
||||
$ipCount.textContent = ips.length;
|
||||
}
|
||||
function saveIps() {
|
||||
localStorage.setItem(LS_IPS, JSON.stringify(getIpsFromUI()));
|
||||
updateIpCount();
|
||||
}
|
||||
function loadIps() {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(LS_IPS) || "[]");
|
||||
return Array.isArray(v) ? v : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, m => ({
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
}[m]));
|
||||
}
|
||||
|
||||
function progressBar(pc) {
|
||||
const n = parseInt(String(pc ?? "").toString().replace('%', '').trim(), 10);
|
||||
if (isNaN(n)) return `<span class="text-muted small">${escapeHtml(pc ?? "")}</span>`;
|
||||
|
||||
let bgClass = 'bg-info';
|
||||
if (n === 100) bgClass = 'bg-success';
|
||||
else if (n < 30) bgClass = 'bg-warning';
|
||||
|
||||
return `<div class="progress" style="height:8px;">
|
||||
<div class="progress-bar ${bgClass}" role="progressbar"
|
||||
style="width:${n}%;" aria-valuenow="${n}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
<small class="text-muted">${n}%</small>`;
|
||||
}
|
||||
|
||||
function badgeStatus(status, pc, recently = false) {
|
||||
const raw = String(status || "");
|
||||
const s = raw.toLowerCase();
|
||||
let cls = "bg-secondary";
|
||||
let icon = "info-circle";
|
||||
|
||||
if (recently) {
|
||||
cls = "bg-success";
|
||||
icon = "check-circle";
|
||||
} else if (s.includes("completed")) {
|
||||
cls = "bg-success";
|
||||
icon = "check-circle";
|
||||
} else if (s.includes("running") || s.includes("progress")) {
|
||||
cls = "bg-info";
|
||||
icon = "arrow-repeat";
|
||||
} else if (s.includes("scheduled") || s.includes("pending")) {
|
||||
cls = "bg-warning text-dark";
|
||||
icon = "clock";
|
||||
} else if (s.includes("failed") || s.includes("error")) {
|
||||
cls = "bg-danger";
|
||||
icon = "x-circle";
|
||||
}
|
||||
|
||||
const pct = parseInt(String(pc ?? "").toString().replace('%', '').trim(), 10);
|
||||
const pctText = isNaN(pct) ? "" : ` (${pct}%)`;
|
||||
const text = recently ? `${raw || "Completed"} (최근${pctText})` : `${raw || "-"}${pctText}`;
|
||||
|
||||
return `<span class="badge ${cls}">
|
||||
<i class="bi bi-${icon}"></i> ${escapeHtml(text)}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function updateStats(items) {
|
||||
let total = 0, running = 0, completed = 0, error = 0;
|
||||
|
||||
items.forEach(it => {
|
||||
if (!it.ok) {
|
||||
error++;
|
||||
return;
|
||||
}
|
||||
if (it.jobs && it.jobs.length) {
|
||||
total++;
|
||||
it.jobs.forEach(j => {
|
||||
const s = (j.Status || "").toLowerCase();
|
||||
if (s.includes("running") || s.includes("progress") || s.includes("starting")) {
|
||||
running++;
|
||||
} else if (s.includes("completed") || s.includes("success")) {
|
||||
completed++;
|
||||
} else if (s.includes("failed") || s.includes("error")) {
|
||||
error++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$statTotal.textContent = items.length;
|
||||
$statRunning.textContent = running;
|
||||
$statCompleted.textContent = completed;
|
||||
$statError.textContent = error;
|
||||
}
|
||||
|
||||
// ========== 렌더링 ==========
|
||||
function renderTable(items) {
|
||||
if (!items) return;
|
||||
|
||||
const hash = JSON.stringify(items);
|
||||
if (hash === lastRenderHash) return;
|
||||
lastRenderHash = hash;
|
||||
|
||||
if (!items.length) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
|
||||
현재 모니터링 중인 Job이 없습니다.
|
||||
</td></tr>`;
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const it of items) {
|
||||
if (!it.ok) {
|
||||
rows.push(`<tr class="table-danger">
|
||||
<td><code>${escapeHtml(it.ip)}</code></td>
|
||||
<td colspan="6">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
오류: ${escapeHtml(it.error || "Unknown")}
|
||||
</td>
|
||||
</tr>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!it.jobs || !it.jobs.length) continue;
|
||||
|
||||
for (const j of it.jobs) {
|
||||
const recent = !!j.RecentlyCompleted;
|
||||
const timeText = j.CompletedAt
|
||||
? `완료: ${escapeHtml(j.CompletedAt.split('T')[1]?.split('.')[0] || j.CompletedAt)}`
|
||||
: escapeHtml(j.LastUpdateTime || "");
|
||||
|
||||
rows.push(`<tr ${recent ? 'class="table-success"' : ''}>
|
||||
<td><code>${escapeHtml(it.ip)}</code></td>
|
||||
<td><small class="font-monospace">${escapeHtml(j.JID || "")}</small></td>
|
||||
<td><strong>${escapeHtml(j.Name || "")}</strong></td>
|
||||
<td>${badgeStatus(j.Status || "", j.PercentComplete || "", recent)}</td>
|
||||
<td>${progressBar(j.PercentComplete || "0")}</td>
|
||||
<td><small>${escapeHtml(j.Message || "")}</small></td>
|
||||
<td><small class="text-muted">${timeText}</small></td>
|
||||
</tr>`);
|
||||
}
|
||||
}
|
||||
|
||||
$body.innerHTML = rows.length
|
||||
? rows.join("")
|
||||
: `<tr><td colspan="7" class="text-center text-success py-4">
|
||||
<i class="bi bi-check-circle fs-2 d-block mb-2"></i>
|
||||
현재 진행 중인 Job이 없습니다. ✅
|
||||
</td></tr>`;
|
||||
|
||||
updateStats(items);
|
||||
}
|
||||
|
||||
// ========== 서버 요청 ==========
|
||||
async function fetchJobs(auto = false) {
|
||||
if (!monitoringOn) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="bi bi-power fs-2 d-block mb-2"></i>
|
||||
모니터링이 꺼져 있습니다. 상단 스위치를 켜면 조회가 시작됩니다.
|
||||
</td></tr>`;
|
||||
$last.textContent = "";
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const ips = getIpsFromUI();
|
||||
if (!ips.length) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-warning py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>
|
||||
IP 목록이 비어 있습니다.
|
||||
</td></tr>`;
|
||||
$last.textContent = "";
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$loading.classList.remove('d-none');
|
||||
$last.textContent = "조회 중… " + new Date().toLocaleTimeString();
|
||||
|
||||
const res = await fetch("/jobs/scan", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ips,
|
||||
method: "redfish", // Redfish 사용
|
||||
recency_hours: CONFIG.recency_hours,
|
||||
grace_minutes: CONFIG.grace_minutes,
|
||||
include_tracked_done: $showCompleted.checked
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || "Scan failed");
|
||||
|
||||
renderTable(data.items);
|
||||
$last.textContent = "업데이트: " + new Date().toLocaleString();
|
||||
} catch (e) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-danger text-center py-4">
|
||||
<i class="bi bi-exclamation-circle fs-2 d-block mb-2"></i>
|
||||
로드 실패: ${escapeHtml(e.message)}
|
||||
<br><button class="btn btn-sm btn-outline-primary mt-2" onclick="fetchJobs(false)">
|
||||
<i class="bi bi-arrow-clockwise"></i> 재시도
|
||||
</button>
|
||||
</td></tr>`;
|
||||
$last.textContent = "에러: " + new Date().toLocaleString();
|
||||
console.error(e);
|
||||
} finally {
|
||||
$loading.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 모니터링 제어 ==========
|
||||
function startAuto() {
|
||||
stopAuto();
|
||||
pollTimer = setInterval(() => fetchJobs(true), CONFIG.poll_interval_ms);
|
||||
}
|
||||
|
||||
function stopAuto() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateMonitorUI() {
|
||||
$btn.disabled = !monitoringOn;
|
||||
$auto.disabled = !monitoringOn;
|
||||
|
||||
if (monitoringOn) {
|
||||
$statusDot.classList.add('active');
|
||||
$statusText.textContent = '모니터링 중';
|
||||
} else {
|
||||
$statusDot.classList.remove('active');
|
||||
$statusText.textContent = '모니터링 꺼짐';
|
||||
}
|
||||
}
|
||||
|
||||
async function setMonitoring(on) {
|
||||
monitoringOn = !!on;
|
||||
localStorage.setItem(LS_MON, monitoringOn ? "1" : "0");
|
||||
updateMonitorUI();
|
||||
|
||||
if (!monitoringOn) {
|
||||
stopAuto();
|
||||
$last.textContent = "";
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="bi bi-power fs-2 d-block mb-2"></i>
|
||||
모니터링이 꺼져 있습니다.
|
||||
</td></tr>`;
|
||||
lastRenderHash = "";
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchJobs(false);
|
||||
if ($auto.checked) startAuto();
|
||||
}
|
||||
|
||||
// ========== 초기화 ==========
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 설정 로드
|
||||
try {
|
||||
const res = await fetch('/jobs/config');
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
CONFIG = data.config;
|
||||
$pollInterval.textContent = Math.round(CONFIG.poll_interval_ms / 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load config:", e);
|
||||
}
|
||||
|
||||
// IP 복원
|
||||
const savedIps = loadIps();
|
||||
if (savedIps.length) {
|
||||
setIpsToUI(savedIps);
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch('/jobs/iplist', {
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok && data.ips) {
|
||||
setIpsToUI(data.ips);
|
||||
saveIps();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 설정 복원
|
||||
const savedMon = localStorage.getItem(LS_MON);
|
||||
const savedAuto = localStorage.getItem(LS_AUTO);
|
||||
const savedShowCompleted = localStorage.getItem(LS_SHOW_COMPLETED);
|
||||
|
||||
$monSw.checked = savedMon === "1";
|
||||
$auto.checked = savedAuto === "1";
|
||||
$showCompleted.checked = savedShowCompleted !== "0";
|
||||
|
||||
// 이벤트
|
||||
$ipInput.addEventListener('input', updateIpCount);
|
||||
$btn.addEventListener('click', () => { if (monitoringOn) fetchJobs(false); });
|
||||
$auto.addEventListener('change', e => {
|
||||
localStorage.setItem(LS_AUTO, e.target.checked ? "1" : "0");
|
||||
if (!monitoringOn) return;
|
||||
if (e.target.checked) startAuto();
|
||||
else stopAuto();
|
||||
});
|
||||
$monSw.addEventListener('click', e => setMonitoring(e.target.checked));
|
||||
$showCompleted.addEventListener('change', e => {
|
||||
localStorage.setItem(LS_SHOW_COMPLETED, e.target.checked ? "1" : "0");
|
||||
if (monitoringOn) fetchJobs(false);
|
||||
});
|
||||
$btnLoad.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch('/jobs/iplist', {
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setIpsToUI(data.ips || []);
|
||||
saveIps();
|
||||
if (monitoringOn) await fetchJobs(false);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('IP 목록을 불러오는 중 오류가 발생했습니다: ' + e.message);
|
||||
}
|
||||
});
|
||||
$btnApply.addEventListener('click', () => {
|
||||
saveIps();
|
||||
if (monitoringOn) fetchJobs(false);
|
||||
});
|
||||
|
||||
// 초기 상태
|
||||
updateMonitorUI();
|
||||
updateIpCount();
|
||||
|
||||
if ($monSw.checked) {
|
||||
setMonitoring(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
function updateFileName(input) {
|
||||
const fileName = input.files[0]?.name || '파일 선택';
|
||||
document.getElementById('fileLabel').textContent = fileName;
|
||||
}
|
||||
|
||||
function openDeployModal(filename) {
|
||||
document.getElementById('deployFilename').value = filename;
|
||||
var myModal = new bootstrap.Modal(document.getElementById('deployModal'));
|
||||
myModal.show();
|
||||
}
|
||||
|
||||
function compareSelected() {
|
||||
const checkboxes = document.querySelectorAll('.file-selector:checked');
|
||||
if (checkboxes.length !== 2) {
|
||||
alert('비교할 파일을 정확히 2개 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
const file1 = checkboxes[0].value;
|
||||
const file2 = checkboxes[1].value;
|
||||
|
||||
// HTML 버튼의 data-url 속성에서 base URL을 가져옴
|
||||
const btn = document.getElementById('compareBtn');
|
||||
if (!btn) {
|
||||
console.error('compareBtn not found');
|
||||
return;
|
||||
}
|
||||
const baseUrl = btn.dataset.url;
|
||||
|
||||
window.location.href = `${baseUrl}?file1=${encodeURIComponent(file1)}&file2=${encodeURIComponent(file2)}`;
|
||||
}
|
||||
@@ -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