Initial project upload

This commit is contained in:
Kim.KANGHEE
2026-05-05 17:14:11 +09:00
commit 122b73d254
282 changed files with 72135 additions and 0 deletions
+51
View File
@@ -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;
}
// 제출 허용 (서버측에서도 반드시 검증)
});
})();
+721
View File
@@ -0,0 +1,721 @@
/**
* BIOS Baseline 비교 JavaScript
* backend/static/js/bios_baseline.js
*/
// 전역 변수
let baselines = [];
let comparisonResults = [];
let showDiffOnly = true;
// ========================================
// 초기화
// ========================================
document.addEventListener('DOMContentLoaded', () => {
loadBaselines();
// IP 입력 카운트 업데이트
document.getElementById('ip-addresses').addEventListener('input', updateIPCount);
// Baseline 생성 폼 제출
document.getElementById('create-baseline-form').addEventListener('submit', handleCreateBaseline);
});
// ========================================
// Baseline 관리
// ========================================
async function loadBaselines() {
try {
const res = await fetch('/bios-baseline/api/baselines');
const data = await res.json();
if (data.success) {
baselines = data.baselines;
renderBaselineSelect();
}
} catch (e) {
console.error('Baseline 로드 실패:', e);
}
}
function renderBaselineSelect() {
const select = document.getElementById('baseline-select');
select.innerHTML = '<option value="">Baseline을 선택하세요</option>';
baselines.forEach(baseline => {
const option = document.createElement('option');
option.value = baseline.id;
option.textContent = `${baseline.name} (${baseline.server_model})`;
select.appendChild(option);
});
}
function showBaselineManageModal() {
const modal = new bootstrap.Modal(document.getElementById('baseline-manage-modal'));
modal.show();
loadBaselineList();
}
async function loadBaselineList() {
const container = document.getElementById('baseline-list');
container.innerHTML = '<div class="text-center text-muted py-4"><i class="bi bi-hourglass-split"></i> 로딩 중...</div>';
try {
const res = await fetch('/bios-baseline/api/baselines');
const data = await res.json();
if (data.success && data.baselines.length > 0) {
let html = '<div class="list-group">';
data.baselines.forEach(baseline => {
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">${baseline.name}</h6>
<p class="mb-1 text-muted small">
<i class="bi bi-server me-1"></i>${baseline.server_model}
${baseline.server_type ? `<span class="badge bg-secondary ms-2">${baseline.server_type}</span>` : ''}
</p>
<p class="mb-0 text-muted small">
${baseline.description || '설명 없음'}
</p>
<p class="mb-0 text-muted small">
<i class="bi bi-gear me-1"></i>${baseline.total_settings}개 설정
<i class="bi bi-calendar ms-2 me-1"></i>${new Date(baseline.created_at).toLocaleDateString()}
</p>
</div>
<div class="btn-group-vertical btn-group-sm">
<button class="btn btn-outline-secondary" onclick="editBaseline(${baseline.id})">
<i class="bi bi-pencil"></i> 편집
</button>
<button class="btn btn-outline-primary" onclick="duplicateBaseline(${baseline.id})">
<i class="bi bi-files"></i> 복사
</button>
<button class="btn btn-outline-danger" onclick="deleteBaseline(${baseline.id})">
<i class="bi bi-trash"></i> 삭제
</button>
</div>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
} else {
container.innerHTML = '<div class="text-center text-muted py-4">등록된 Baseline이 없습니다</div>';
}
} catch (e) {
container.innerHTML = '<div class="text-center text-danger py-4">로드 실패</div>';
}
}
async function handleCreateBaseline(e) {
e.preventDefault();
const data = {
ip_address: document.getElementById('create-ip').value,
username: document.getElementById('create-username').value,
password: document.getElementById('create-password').value,
name: document.getElementById('create-name').value,
server_type: document.getElementById('create-type').value,
description: document.getElementById('create-description').value,
created_by: 'admin'
};
try {
const res = await fetch('/bios-baseline/api/baselines/create-from-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
alert('✓ Baseline 생성 완료!');
document.getElementById('create-baseline-form').reset();
loadBaselines();
loadBaselineList();
} else {
alert('✗ 생성 실패: ' + result.message);
}
} catch (e) {
alert('✗ 오류: ' + e.message);
}
}
async function duplicateBaseline(baselineId) {
const newName = prompt('새 Baseline 이름을 입력하세요:');
if (!newName) return;
try {
const res = await fetch(`/bios-baseline/api/baselines/${baselineId}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_name: newName })
});
const result = await res.json();
if (result.success) {
alert('✓ Baseline 복사 완료!');
loadBaselines();
loadBaselineList();
} else {
alert('✗ 복사 실패: ' + result.message);
}
} catch (e) {
alert('✗ 오류: ' + e.message);
}
}
// Baseline 삭제
async function deleteBaseline(baselineId) {
if (!confirm('정말 이 Baseline을 삭제하시겠습니까?')) return;
try {
const res = await fetch(`/bios-baseline/api/baselines/${baselineId}`, {
method: 'DELETE'
});
const result = await res.json();
if (result.success) {
alert('✓ Baseline 삭제 완료!');
loadBaselines();
loadBaselineList();
} else {
alert('✗ 삭제 실패: ' + result.message);
}
} catch (e) {
alert('✗ 오류: ' + e.message);
}
}
// ========================================
// Baseline 상세 편집
// ========================================
async function editBaseline(baselineId) {
try {
const res = await fetch(`/bios-baseline/api/baselines/${baselineId}`);
const data = await res.json();
if (data.success) {
const baseline = data.baseline;
currentBaselineId = baselineId; // 전역 변수 설정
// 메타데이터 채우기
document.getElementById('edit-id').value = baseline.id;
document.getElementById('edit-name').value = baseline.name;
document.getElementById('edit-model').value = baseline.server_model;
document.getElementById('edit-type').value = baseline.server_type || '';
document.getElementById('edit-description').value = baseline.description || '';
document.getElementById('edit-created-at').textContent = baseline.created_at || '-';
document.getElementById('edit-source-ip').textContent = baseline.source_ip || '-';
// BIOS, iDRAC, Firmware Table 렌더링
renderEditTable('bios', baseline.bios_data || {});
renderEditTable('idrac', baseline.idrac_data || {});
renderEditTable('firmware', baseline.firmware_data || {});
// RAID, NIC, Boot는 JSON Textarea에 표시
document.getElementById('json-edit-raid').value = JSON.stringify(baseline.raid_data || {}, null, 4);
document.getElementById('json-edit-nic').value = JSON.stringify(baseline.nic_data || {}, null, 4);
document.getElementById('json-edit-boot').value = JSON.stringify(baseline.boot_data || {}, null, 4);
// 모달 표시
const modal = new bootstrap.Modal(document.getElementById('edit-baseline-modal'));
modal.show();
// 첫 번째 탭(BIOS) 활성화
const firstTab = new bootstrap.Tab(document.querySelector('#tab-edit-bios'));
firstTab.show();
} else {
alert('로드 실패: ' + data.message);
}
} catch (e) {
alert('오류: ' + e.message);
}
}
function renderEditTable(type, settings) {
const tbody = document.querySelector(`#table-edit-${type} tbody`);
tbody.innerHTML = '';
const entries = Object.entries(settings).sort((a, b) => a[0].localeCompare(b[0]));
document.getElementById(`edit-${type}-count`).textContent = entries.length;
entries.forEach(([key, value]) => {
addSettingRow(type, key, value);
});
}
function addSettingRow(type, key = '', value = '') {
const tbody = document.querySelector(`#table-edit-${type} tbody`);
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input type="text" class="form-control form-control-sm setting-key" value="${key}" placeholder="Key"></td>
<td><input type="text" class="form-control form-control-sm setting-value" value="${value}" placeholder="Value"></td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="removeSettingRow(this, '${type}')">
<i class="bi bi-trash"></i>
</button>
</td>
`;
// 새 행은 맨 위에 추가 (비어있을 경우) 혹은 맨 아래
if (!key) {
tbody.prepend(tr);
tr.querySelector('.setting-key').focus();
} else {
tbody.appendChild(tr);
}
}
function removeSettingRow(btn, type) {
btn.closest('tr').remove();
updateSettingsCount(type);
}
function updateSettingsCount(type) {
const count = document.querySelectorAll(`#table-edit-${type} tbody tr`).length;
document.getElementById(`edit-${type}-count`).textContent = count;
}
async function saveBaselineChanges() {
const id = document.getElementById('edit-id').value;
const name = document.getElementById('edit-name').value;
if (!name) {
alert('Baseline 이름을 입력하세요.');
return;
}
// Helper function to collect table data
const collectTableData = (type) => {
const data = {};
const rows = document.querySelectorAll(`#table-edit-${type} tbody tr`);
rows.forEach(row => {
const key = row.querySelector('.setting-key').value.trim();
const value = row.querySelector('.setting-value').value.trim();
if (key) data[key] = value;
});
return data;
};
const biosData = collectTableData('bios');
const idracData = collectTableData('idrac');
const firmwareData = collectTableData('firmware');
// JSON Data Parsing
let raidData = {};
let nicData = {};
let bootData = {};
try {
raidData = JSON.parse(document.getElementById('json-edit-raid').value || '{}');
} catch (e) {
alert('RAID 구성 JSON 형식이 올바르지 않습니다.');
return;
}
try {
nicData = JSON.parse(document.getElementById('json-edit-nic').value || '{}');
} catch (e) {
alert('NIC 설정 JSON 형식이 올바르지 않습니다.');
return;
}
try {
bootData = JSON.parse(document.getElementById('json-edit-boot').value || '{}');
} catch (e) {
alert('부팅 설정 JSON 형식이 올바르지 않습니다.');
return;
}
const payload = {
name: name,
server_type: document.getElementById('edit-type').value,
description: document.getElementById('edit-description').value,
bios_data: biosData,
idrac_data: idracData,
raid_data: raidData,
nic_data: nicData,
boot_data: bootData,
firmware_data: firmwareData
};
try {
const res = await fetch(`/bios-baseline/api/baselines/${currentBaselineId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await res.json();
if (result.success) {
alert('✓ 변경사항이 저장되었습니다!');
const modalEl = document.getElementById('edit-baseline-modal');
const modal = bootstrap.Modal.getInstance(modalEl);
modal.hide();
loadBaselines();
loadBaselineList();
} else {
alert('✗ 저장 실패: ' + result.message);
}
} catch (e) {
alert('✗ 오류: ' + e.message);
}
}
// ========================================
// 비교 기능
// ========================================
async function startComparison() {
const baselineId = document.getElementById('baseline-select').value;
const ipText = document.getElementById('ip-addresses').value;
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
if (!baselineId) {
alert('Baseline을 선택하세요');
return;
}
if (!ipText.trim()) {
alert('서버 IP를 입력하세요');
return;
}
if (!password) {
alert('비밀번호를 입력하세요');
return;
}
// IP 파싱
const ipAddresses = ipText.split(/[\n,\s]+/).filter(ip => ip.trim());
if (ipAddresses.length === 0) {
alert('유효한 IP 주소가 없습니다');
return;
}
// 진행률 표시
document.getElementById('progress-section').style.display = 'block';
document.getElementById('results-section').style.display = 'none';
updateProgress(0, ipAddresses.length);
try {
const res = await fetch('/bios-baseline/api/compare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip_addresses: ipAddresses,
baseline_id: parseInt(baselineId),
username: username,
password: password
})
});
const data = await res.json();
if (data.success) {
comparisonResults = data.results;
// Toggle 버튼 초기 상태 설정
const toggleBtn = document.getElementById('diff-only-btn');
if (showDiffOnly) {
toggleBtn.classList.add('active');
} else {
toggleBtn.classList.remove('active');
}
renderSummary(data.baseline, data.results);
renderResults(data.results);
document.getElementById('results-section').style.display = 'block';
} else {
alert('비교 실패: ' + data.message);
}
} catch (e) {
alert('오류: ' + e.message);
} finally {
document.getElementById('progress-section').style.display = 'none';
}
}
function updateProgress(current, total) {
const percent = Math.round((current / total) * 100);
const bar = document.getElementById('progress-bar');
bar.style.width = percent + '%';
bar.querySelector('span').textContent = percent + '%';
}
function renderSummary(baseline, results) {
const successResults = results.filter(r => r.status === 'success');
const totalServers = results.length;
const successCount = successResults.length;
const failedCount = totalServers - successCount;
let totalMatched = 0;
let totalMismatched = 0;
let perfectMatchCount = 0;
successResults.forEach(r => {
totalMatched += r.matched_items;
totalMismatched += r.mismatched_items;
if (r.mismatched_items === 0) perfectMatchCount++;
});
const avgMatchRate = successCount > 0
? Math.round((totalMatched / (totalMatched + totalMismatched)) * 100)
: 0;
const html = `
<div class="row g-3">
<div class="col-md-3">
<div class="card border-primary">
<div class="card-body text-center">
<i class="bi bi-server fs-1 text-primary"></i>
<h3 class="mt-2 mb-0">${totalServers}</h3>
<p class="text-muted small mb-0">총 서버</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body text-center">
<i class="bi bi-check-circle fs-1 text-success"></i>
<h3 class="mt-2 mb-0">${avgMatchRate}%</h3>
<p class="text-muted small mb-0">평균 일치율</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body text-center">
<i class="bi bi-exclamation-triangle fs-1 text-warning"></i>
<h3 class="mt-2 mb-0">${totalMismatched}</h3>
<p class="text-muted small mb-0">불일치 항목</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-info">
<div class="card-body text-center">
<i class="bi bi-patch-check fs-1 text-info"></i>
<h3 class="mt-2 mb-0">${perfectMatchCount}</h3>
<p class="text-muted small mb-0">완전 일치</p>
</div>
</div>
</div>
</div>
<div class="mt-3 p-3 bg-light rounded">
<p class="mb-0 small">
<strong>Baseline:</strong> ${baseline.name} (${baseline.server_model})
<span class="ms-3"><strong>총 설정:</strong> ${baseline.total_settings}개</span>
</p>
</div>
`;
document.getElementById('summary-section').innerHTML = html;
}
function renderResults(results) {
const container = document.getElementById('results-container');
if (results.length === 0) {
container.innerHTML = '<p class="text-muted text-center">결과가 없습니다</p>';
return;
}
let html = '<div class="accordion" id="results-accordion">';
results.forEach((result, index) => {
const statusClass = result.status === 'success'
? (result.mismatched_items === 0 ? 'success' : 'warning')
: 'danger';
const statusIcon = result.status === 'success'
? (result.mismatched_items === 0 ? 'check-circle-fill' : 'exclamation-triangle-fill')
: 'x-circle-fill';
html += `
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button ${index > 0 ? 'collapsed' : ''}" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse-${index}">
<i class="bi bi-${statusIcon} text-${statusClass} me-2"></i>
<strong>${result.ip}</strong>
${result.service_tag ? `<span class="badge bg-secondary ms-2">${result.service_tag}</span>` : ''}
${result.status === 'success' ? `
<span class="ms-auto me-3">
<span class="badge bg-${statusClass}">${result.match_rate}% 일치</span>
${result.mismatched_items > 0 ? `<span class="badge bg-danger ms-1">${result.mismatched_items}개 불일치</span>` : ''}
</span>
` : ''}
</button>
</h2>
<div id="collapse-${index}" class="accordion-collapse collapse ${index === 0 ? 'show' : ''}"
data-bs-parent="#results-accordion">
<div class="accordion-body">
${renderServerDetail(result)}
</div>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
function renderServerDetail(result) {
if (result.status !== 'success') {
return `<div class="alert alert-danger mb-0">${result.message}</div>`;
}
// 통계바
let html = `
<div class="mb-3">
<div class="row g-2">
<div class="col-md-4"><strong>모델:</strong> ${result.server_model}</div>
<div class="col-md-4"><strong>Service Tag:</strong> ${result.service_tag}</div>
<div class="col-md-4"><strong>BIOS:</strong> ${result.bios_version}</div>
</div>
<div class="progress mt-2" style="height: 20px;">
<div class="progress-bar bg-success" role="progressbar" style="width: ${result.match_rate}%">
${result.match_rate}% 일치
</div>
<div class="progress-bar bg-danger" role="progressbar" style="width: ${100 - result.match_rate}%">
${result.mismatched_items} 불일치
</div>
</div>
</div>
`;
// 탭 헤더 생성 (Nav Pills)
const uid = Math.random().toString(36).substr(2, 9); // 유니크 ID
html += `
<div class="nav-pills-container">
<ul class="nav nav-pills mb-0" id="result-tabs-${uid}" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-bios-${uid}" type="button">BIOS</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-idrac-${uid}" type="button">iDRAC</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-raid-${uid}" type="button">RAID</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-nic-${uid}" type="button">NIC</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-boot-${uid}" type="button">Boot</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-firmware-${uid}" type="button">Firmware</button>
</li>
</ul>
</div>
<div class="tab-content">
`;
// 섹션별 Diff 필터링
const diffs = result.differences || [];
// 탭 콘텐츠 생성 Helper
const renderTabPane = (section, id, isActive = false) => {
const sectionDiffs = diffs.filter(d => d.section === section);
// 섹션 이름이 영어로 저장되어 있다고 가정 (BIOS, iDRAC, RAID, NIC, Boot, Firmware)
return `
<div class="tab-pane fade ${isActive ? 'show active' : ''}" id="tab-${id}-${uid}" role="tabpanel">
${renderDiffTable(sectionDiffs, `${section} 설정이 기준과 일치합니다.`)}
</div>`;
};
html += renderTabPane('BIOS', 'bios', true);
html += renderTabPane('iDRAC', 'idrac');
html += renderTabPane('RAID', 'raid');
html += renderTabPane('NIC', 'nic');
html += renderTabPane('Boot', 'boot');
html += renderTabPane('Firmware', 'firmware');
html += `</div>`; // tab-content end
return html;
}
function renderDiffTable(diffs, emptyMsg) {
if (diffs.length === 0) {
return `<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-1"></i> ${emptyMsg}</div>`;
}
if (showDiffOnly && diffs.every(d => d.status !== 'mismatch')) {
return `<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-1"></i> ${emptyMsg} (필터링됨)</div>`;
}
let html = `
<table class="table table-sm table-bordered table-hover" style="table-layout: fixed;">
<thead class="table-light">
<tr>
<th width="40%">항목</th>
<th width="30%">기준값 (Baseline)</th>
<th width="30%">실제값 (Server)</th>
</tr>
</thead>
<tbody>
`;
diffs.forEach(diff => {
const isMismatch = diff.status === 'mismatch';
const rowClass = isMismatch ? 'table-danger' : '';
if (!showDiffOnly || isMismatch) {
html += `
<tr class="${rowClass}">
<td style="word-wrap: break-word;">
${diff.display_name ? `<strong>${diff.display_name}</strong><br><small class="text-muted" style="word-break: break-all;">${diff.setting_name}</small>` : `<code style="word-break: break-all;">${diff.setting_name}</code>`}
</td>
<td style="word-break: break-all;">${diff.expected}</td>
<td style="word-break: break-all;"><span class="${isMismatch ? '' : 'text-success'}">${diff.actual}</span></td>
</tr>
`;
}
});
html += '</tbody></table>';
return html;
}
function toggleDiffOnly() {
showDiffOnly = !showDiffOnly;
const btn = document.getElementById('diff-only-btn');
btn.classList.toggle('active');
renderResults(comparisonResults);
}
function downloadResults() {
alert('Excel 다운로드 기능은 곧 추가될 예정입니다.');
}
// ========================================
// 유틸리티
// ========================================
function updateIPCount() {
const ipText = document.getElementById('ip-addresses').value;
const ips = ipText.split(/[\n,\s]+/).filter(ip => ip.trim());
document.getElementById('ip-count').textContent = ips.length;
}
function clearIPs() {
document.getElementById('ip-addresses').value = '';
updateIPCount();
}
File diff suppressed because it is too large Load Diff
+385
View File
@@ -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');
}
}
+419
View File
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
}[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);
}
});
+30
View File
@@ -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)}`;
}
+324
View File
@@ -0,0 +1,324 @@
let allServers = [];
let analysisData = {};
document.addEventListener('DOMContentLoaded', () => {
loadServers();
document.getElementById('check-all').addEventListener('change', (e) => {
const checked = e.target.checked;
document.querySelectorAll('.server-checkbox').forEach(cb => cb.checked = checked);
});
});
async function loadServers() {
try {
const res = await axios.get('/idrac/api/servers');
if (res.data.success) {
allServers = res.data.servers;
renderServerList(allServers);
} else {
alert('서버 목록 로드 실패: ' + res.data.message);
}
} catch (e) {
console.error(e);
alert('서버 목록 로드 중 오류가 발생했습니다.');
}
}
function renderServerList(servers) {
const tbody = document.querySelector('#selection-table tbody');
tbody.innerHTML = '';
if (servers.length === 0) {
tbody.innerHTML = '<tr><td colspan="5">등록된 서버가 없습니다.</td></tr>';
return;
}
servers.forEach(server => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input type="checkbox" class="server-checkbox" value="${server.id}"></td>
<td>${server.name}</td>
<td>${server.ip_address}</td>
<td>${server.model || '-'}</td>
<td>${server.status || 'Unknown'}</td>
`;
tbody.appendChild(tr);
});
}
// 시작: 서버 선택 후 동기화 (iDRAC -> DB)
async function syncSelectedServers() {
const selectedIds = Array.from(document.querySelectorAll('.server-checkbox:checked')).map(cb => parseInt(cb.value));
if (selectedIds.length === 0) {
alert('동기화할 서버를 하나 이상 선택해주세요.');
return;
}
if (!confirm(`선택한 ${selectedIds.length}대 서버의 설정을 iDRAC에서 가져와 DB에 저장하시겠습니까?`)) {
return;
}
document.getElementById('loading').style.display = 'flex';
updateLoadingMessage('설정 데이터 동기화 중...');
try {
const res = await axios.post('/server-config/api/sync', {
server_ids: selectedIds,
categories: ['bios', 'system', 'idrac']
});
if (res.data.success) {
const failed = res.data.results.failed;
if (failed.length > 0) {
const failedNames = failed.map(f => f.name || f.id).join(', ');
alert(`일부 서버 동기화 실패:\n${failedNames}\n\n성공한 서버는 DB에 저장되었습니다.`);
} else {
alert('모든 서버 동기화가 완료되었습니다. 이제 분석을 수행할 수 있습니다.');
}
// 만약 이미 결과 화면이 보이고 있다면 자동 갱신
if (document.getElementById('result-card').style.display !== 'none') {
startAnalysis();
}
} else {
alert('동기화 실패: ' + res.data.message);
}
} catch (e) {
console.error(e);
alert('동기화 요청 중 오류가 발생했습니다: ' + e.message);
} finally {
document.getElementById('loading').style.display = 'none';
}
}
// 시작: DB 데이터 기반 분석 (DB -> Web)
async function startAnalysis() {
const selectedIds = Array.from(document.querySelectorAll('.server-checkbox:checked')).map(cb => parseInt(cb.value));
if (selectedIds.length === 0) {
alert('분석할 서버를 하나 이상 선택해주세요.');
return;
}
document.getElementById('loading').style.display = 'flex';
document.getElementById('result-card').style.display = 'none';
updateLoadingMessage('데이터 분석 중...');
try {
const res = await axios.post('/server-config/api/fetch', {
server_ids: selectedIds,
categories: ['bios', 'system', 'idrac']
});
if (res.data.success) {
analysisData = res.data.results;
renderComparison();
document.getElementById('result-card').style.display = 'block';
} else {
alert('데이터 조회 실패: ' + res.data.message);
}
} catch (e) {
console.error(e);
alert('데이터 조회 중 오류가 발생했습니다.');
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function updateLoadingMessage(msg) {
const loadingText = document.getElementById('loading-text');
if (loadingText) loadingText.textContent = msg;
}
function renderComparison() {
const container = document.getElementById('result-container');
const showDiffOnly = document.getElementById('show-diff-only').checked;
container.innerHTML = '';
const serverIds = Object.keys(analysisData);
if (serverIds.length === 0) {
container.innerHTML = '<p>데이터가 없습니다.</p>';
return;
}
// 통합된 모든 키(Attribute Name) 수집
const allKeys = new Set();
const serverNames = {};
const serverUpdated = {}; // 마지막 업데이트 시간
serverIds.forEach(id => {
const data = analysisData[id];
serverNames[id] = data.name;
// 가장 최근 업데이트 시간 찾기
const times = [data.bios_updated, data.idrac_updated, data.system_updated].filter(t => t);
if (times.length > 0) {
times.sort();
serverUpdated[id] = times[times.length - 1]; // 가장 최신
} else {
serverUpdated[id] = 'N/A';
}
// BIOS
if (data.bios) Object.keys(data.bios).forEach(k => allKeys.add('BIOS: ' + k));
// iDRAC
if (data.idrac) Object.keys(data.idrac).forEach(k => allKeys.add('iDRAC: ' + k));
// System
if (data.system) Object.keys(flattenObject(data.system)).forEach(k => allKeys.add('System: ' + k));
});
const sortedKeys = Array.from(allKeys).sort();
// 테이블 생성
const table = document.createElement('table');
table.className = 'comparison-table';
// 헤더
const thead = document.createElement('thead');
let headerHtml = '<tr><th style="min-width: 200px;">Attribute</th>';
serverIds.forEach(id => {
headerHtml += `<th>${serverNames[id]}</th>`;
});
headerHtml += '</tr>';
// 업데이트 시간 행 추가
headerHtml += '<tr style="background-color: #fafafa; font-size: 0.8em; color: #666;"><th>Local DB Updated</th>';
serverIds.forEach(id => {
const timeStr = serverUpdated[id] !== 'N/A' ? new Date(serverUpdated[id]).toLocaleString() : 'Never';
headerHtml += `<th>${timeStr}</th>`;
});
headerHtml += '</tr>';
thead.innerHTML = headerHtml;
table.appendChild(thead);
// 바디
const tbody = document.createElement('tbody');
let hasDiff = false;
sortedKeys.forEach(key => {
const row = document.createElement('tr');
let rowHtml = `<td>${key}</td>`;
// 값 비교를 위한 배열
const values = [];
serverIds.forEach(id => {
const val = getValueByKey(analysisData[id], key);
values.push(val);
});
// 값들이 모두 같은지 확인
const firstVal = values[0];
const isDiff = !values.every(v => v === firstVal);
if (isDiff) {
row.classList.add('diff-row');
hasDiff = true;
}
if (showDiffOnly && !isDiff) {
return; // Diff Only 모드이고 차이가 없으면 건너뜀
}
values.forEach(val => {
const displayVal = (val === undefined || val === null) ? '<span style="color:#ccc">N/A</span>' : val;
const cellClass = (isDiff && val !== firstVal) ? 'diff-highlight' : ''; // 첫번째 서버(기준)와 다르면 강조
rowHtml += `<td class="${cellClass}">${displayVal}</td>`;
});
row.innerHTML = rowHtml;
tbody.appendChild(row);
});
if (showDiffOnly && !hasDiff) {
// 차이점이 하나도 없는데 차이점만 보기 체크된 경우 빈 row가 아니라 메시지 표시가 나을지 고민
// tbody가 비어있음
}
if (tbody.children.length === 0) {
tbody.innerHTML = '<tr><td colspan="' + (serverIds.length + 1) + '">표시할 항목이 없습니다.</td></tr>';
}
table.appendChild(tbody);
container.appendChild(table);
}
// 헬퍼: 키(Category: Name)로 값 추출
function getValueByKey(data, compositeKey) {
if (!data) return undefined;
const [category, ...rest] = compositeKey.split(': ');
const realKey = rest.join(': '); // 혹시 키 안에 콜론이 있을 경우 복구
if (category === 'BIOS') return data.bios ? data.bios[realKey] : undefined;
if (category === 'iDRAC') return data.idrac ? data.idrac[realKey] : undefined;
if (category === 'System') {
const flatSys = flattenObject(data.system);
return flatSys ? flatSys[realKey] : undefined;
}
return undefined;
}
// 객체 평탄화 (Nested Object -> Flat Key)
function flattenObject(obj, prefix = '') {
if (!obj) return {};
return Object.keys(obj).reduce((acc, k) => {
const pre = prefix.length ? prefix + '.' : '';
if (typeof obj[k] === 'object' && obj[k] !== null) {
Object.assign(acc, flattenObject(obj[k], pre + k));
} else {
acc[pre + k] = obj[k];
}
return acc;
}, {});
}
function downloadReport() {
const table = document.querySelector('.comparison-table');
if (!table) {
alert('다운로드할 데이터가 없습니다. 먼저 분석을 진행해주세요.');
return;
}
let csvContent = "\uFEFF"; // BOM for Excel UTF-8
const rows = table.querySelectorAll('tr');
rows.forEach(row => {
const cols = row.querySelectorAll('th, td');
const rowData = [];
cols.forEach(col => {
// 따옴표 처리 및 텍스트 추출
let text = col.innerText || col.textContent;
text = text.replace(/"/g, '""'); // 따옴표 이스케이프
// 줄바꿈 제거
text = text.replace(/(\r\n|\n|\r)/gm, " ");
if (text.includes(',') || text.includes('"')) {
text = `"${text}"`;
}
rowData.push(text);
});
csvContent += rowData.join(',') + "\r\n";
});
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
link.setAttribute('href', url);
link.setAttribute('download', `server_config_comparison_${timestamp}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}