This commit is contained in:
2025-11-29 11:13:55 +09:00
parent c0d3312bca
commit 19798cca66
12 changed files with 2094 additions and 255 deletions

View File

@@ -27,7 +27,7 @@ header {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
margin-bottom: 30px;
text-align: center;
}
@@ -48,7 +48,7 @@ header .subtitle {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
margin-bottom: 30px;
}
@@ -290,7 +290,7 @@ header .subtitle {
top: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
background: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
}
@@ -302,7 +302,7 @@ header .subtitle {
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.modal-header {
@@ -423,8 +423,15 @@ header .subtitle {
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.progress-message {
@@ -531,20 +538,20 @@ footer {
flex-direction: column;
align-items: flex-start;
}
.header-actions {
margin-top: 10px;
width: 100%;
}
.header-actions button {
flex: 1;
}
.bulk-actions {
flex-wrap: wrap;
}
.modal-content {
width: 95%;
}
@@ -556,3 +563,217 @@ input[type="checkbox"] {
height: 18px;
cursor: pointer;
}
/* ========================================
탭 메뉴 스타일
======================================== */
.tab-menu {
display: flex;
gap: 10px;
margin-bottom: 20px;
background: white;
padding: 15px;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.tab-button {
flex: 1;
padding: 12px 24px;
border: 2px solid #ddd;
background: white;
border-radius: 8px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.tab-button.active {
background: #667eea;
color: white;
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.tab-button:hover:not(.active) {
background: #f8f9fa;
border-color: #667eea;
transform: translateY(-2px);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ========================================
토스트 알림 스타일
======================================== */
.toast {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
border-radius: 8px;
color: white;
font-weight: 600;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
transform: translateX(400px);
transition: transform 0.3s ease-out;
z-index: 2000;
max-width: 400px;
word-wrap: break-word;
}
.toast.show {
transform: translateX(0);
}
.toast-success {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
}
.toast-error {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
}
.toast-warning {
background: linear-gradient(135deg, #ffc107 0%, #ff9800 100%);
color: #333;
}
.toast-info {
background: linear-gradient(135deg, #17a2b8 0%, #138496 100%);
}
/* ========================================
로딩 스피너
======================================== */
.loading-spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid rgba(102, 126, 234, 0.2);
border-radius: 50%;
border-top-color: #667eea;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
#comparison-loading {
text-align: center;
padding: 60px 20px;
}
#comparison-loading p {
margin-top: 20px;
color: #666;
font-size: 1.1em;
}
/* ========================================
비교 결과 개선 스타일
======================================== */
.comparison-grid {
display: grid;
gap: 30px;
margin-top: 20px;
}
.server-comparison-section {
background: #f8f9fa;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.server-comparison-section h3 {
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #ddd;
}
.comparison-card {
background: white;
padding: 20px;
border-radius: 10px;
border-left: 4px solid #667eea;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
margin-bottom: 15px;
transition: all 0.3s;
}
.comparison-card:hover {
transform: translateX(5px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12);
}
.comparison-card.outdated {
border-left-color: #dc3545;
background: linear-gradient(to right, #fff5f5 0%, white 100%);
}
.comparison-card.latest {
border-left-color: #28a745;
background: linear-gradient(to right, #f0fff4 0%, white 100%);
}
.comparison-card.unknown {
border-left-color: #ffc107;
background: linear-gradient(to right, #fffbf0 0%, white 100%);
}
.comparison-card code {
font-family: 'Courier New', monospace;
font-size: 0.95em;
}
/* ========================================
반응형 개선
======================================== */
@media (max-width: 768px) {
.tab-menu {
flex-direction: column;
}
.tab-button {
width: 100%;
}
.toast {
right: 10px;
left: 10px;
max-width: none;
}
.comparison-card {
margin-bottom: 10px;
}
}

View File

@@ -58,6 +58,9 @@ document.addEventListener('DOMContentLoaded', function () {
refreshServers();
loadGroups();
setupSocketIO();
// 초기 탭 설정
showTab('servers');
});
// ========================================
@@ -299,27 +302,27 @@ function closeUploadModal() {
async function startMultiUpload() {
const fileInput = document.getElementById('firmware-file');
const serverIds = getSelectedServerIds();
if (!fileInput.files[0]) {
showMessage('파일을 선택하세요', 'warning');
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('server_ids', serverIds.join(','));
try {
closeUploadModal();
showUploadProgress(serverIds);
const response = await fetchWithCSRF('/idrac/api/upload-multi', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showMessage(data.message, 'success');
} else {
@@ -335,9 +338,9 @@ async function startMultiUpload() {
function showUploadProgress(serverIds) {
const section = document.getElementById('upload-progress-section');
const list = document.getElementById('upload-progress-list');
section.style.display = 'block';
// 각 서버별 진행 바 생성
list.innerHTML = serverIds.map(id => {
const server = servers.find(s => s.id === id);
@@ -354,7 +357,7 @@ function showUploadProgress(serverIds) {
</div>
`;
}).join('');
// 요약 초기화
document.getElementById('progress-summary').innerHTML = `
<div class="summary-stats">
@@ -375,13 +378,13 @@ function hideUploadProgress() {
function setupSocketIO() {
// 업로드 진행 상황
socket.on('upload_progress', function(data) {
socket.on('upload_progress', function (data) {
console.log('Upload progress:', data);
const statusEl = document.getElementById(`status-${data.server_id}`);
const barEl = document.getElementById(`bar-${data.server_id}`);
const messageEl = document.getElementById(`message-${data.server_id}`);
if (statusEl) statusEl.textContent = data.message;
if (barEl) {
barEl.style.width = data.progress + '%';
@@ -389,18 +392,18 @@ function setupSocketIO() {
}
if (messageEl) messageEl.textContent = data.job_id ? `Job ID: ${data.job_id}` : '';
});
// 업로드 완료
socket.on('upload_complete', function(data) {
socket.on('upload_complete', function (data) {
console.log('Upload complete:', data);
const summary = data.summary;
document.getElementById('completed-count').textContent = `완료: ${summary.success}`;
document.getElementById('failed-count').textContent = `실패: ${summary.failed}`;
showMessage(`업로드 완료: 성공 ${summary.success}대, 실패 ${summary.failed}`, 'success');
showResults(data.results, '업로드 결과');
refreshServers();
});
}
@@ -412,9 +415,9 @@ function setupSocketIO() {
function showResults(results, title) {
const section = document.getElementById('result-section');
const content = document.getElementById('result-content');
section.style.display = 'block';
content.innerHTML = `
<h3>${title}</h3>
<table class="result-table">
@@ -448,30 +451,30 @@ function showResults(results, title) {
async function rebootSelected() {
const serverIds = getSelectedServerIds();
if (serverIds.length === 0) {
showMessage('서버를 선택하세요', 'warning');
return;
}
if (!confirm(`선택한 ${serverIds.length}대 서버를 재부팅하시겠습니까?\n\n⚠️ 업로드된 펌웨어가 적용됩니다!`)) {
return;
}
try {
const response = await fetchWithCSRF('/idrac/api/servers/reboot-multi', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
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');
@@ -479,7 +482,7 @@ async function rebootSelected() {
} else {
showMessage(data.message, 'error');
}
refreshServers();
} catch (error) {
showMessage('재부팅 실패: ' + error, 'error');
@@ -499,11 +502,38 @@ function importExcel() {
// ========================================
function showMessage(message, type = 'info') {
// 간단한 알림 표시
alert(message);
// 토스트 알림으로 변경
showToast(message, type);
console.log(`[${type}] ${message}`);
}
// ========================================
// 토스트 알림 시스템
// ========================================
function showToast(message, type = 'info') {
// 기존 토스트 제거
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
// 새 토스트 생성
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
// 애니메이션
setTimeout(() => toast.classList.add('show'), 100);
// 3초 후 제거
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// 편의 함수들
function editServer(serverId) {
showMessage('서버 수정 기능은 개발 중입니다', 'info');
@@ -511,20 +541,20 @@ function editServer(serverId) {
function getSelectedFirmware() {
const serverIds = getSelectedServerIds();
if (serverIds.length === 0) {
showMessage('서버를 선택하세요', 'warning');
return;
}
showMessage('선택한 서버의 펌웨어 조회 중...', 'info');
// 각 서버별로 펌웨어 조회
serverIds.forEach(async (serverId) => {
try {
const response = await fetchWithCSRF(`/idrac/api/servers/${serverId}/firmware`);
const data = await response.json();
if (data.success) {
console.log(`Server ${serverId} firmware:`, data.data);
}
@@ -532,7 +562,7 @@ function getSelectedFirmware() {
console.error(`Server ${serverId} firmware query failed:`, error);
}
});
// 새로고침
setTimeout(() => {
refreshServers();
@@ -580,11 +610,19 @@ async function compareSelectedFirmware() {
const serverIds = getSelectedServerIds();
if (serverIds.length === 0) {
showMessage('서버를 선택하세요', 'warning');
showToast('서버를 선택하세요', 'warning');
return;
}
showMessage(`선택된 ${serverIds.length}대 서버 버전 비교 중...`, 'info');
// 비교 탭으로 전환
showTab('comparison');
// 로딩 표시
const loadingEl = document.getElementById('comparison-loading');
const resultEl = document.getElementById('comparison-result');
if (loadingEl) loadingEl.style.display = 'block';
if (resultEl) resultEl.innerHTML = '';
try {
const res = await fetchWithCSRF('/idrac/api/servers/firmware/compare-multi', {
@@ -594,16 +632,90 @@ async function compareSelectedFirmware() {
});
const data = await res.json();
// 로딩 숨기기
if (loadingEl) loadingEl.style.display = 'none';
if (data.success) {
showResults(data.results, '버전 비교 결과');
displayComparisonResults(data.results);
showToast(`${serverIds.length}대 서버 비교 완료`, 'success');
} else {
showMessage(data.message, 'error');
if (resultEl) {
resultEl.innerHTML = `<div class="warning-text">⚠️ ${data.message}</div>`;
}
showToast(data.message, 'error');
}
} catch (error) {
showMessage('버전 비교 실패: ' + error, 'error');
if (loadingEl) loadingEl.style.display = 'none';
if (resultEl) {
resultEl.innerHTML = `<div class="warning-text">⚠️ 버전 비교 실패: ${error}</div>`;
}
showToast('버전 비교 실패: ' + error, 'error');
}
}
// ========================================
// 비교 결과 표시 (개선된 버전)
// ========================================
function displayComparisonResults(results) {
const resultEl = document.getElementById('comparison-result');
if (!resultEl) return;
if (!results || results.length === 0) {
resultEl.innerHTML = '<div class="info-text">비교 결과가 없습니다</div>';
return;
}
let html = '<div class="comparison-grid">';
results.forEach(serverResult => {
html += `
<div class="server-comparison-section">
<h3 style="margin-bottom: 15px; color: #333;">
🖥️ ${serverResult.server_name}
<span style="font-size: 0.8em; color: #666;">(${serverResult.server_ip || ''})</span>
</h3>
`;
if (serverResult.success && serverResult.comparisons) {
serverResult.comparisons.forEach(comp => {
const statusClass = comp.status === 'outdated' ? 'outdated' :
comp.status === 'latest' ? 'latest' : 'unknown';
const statusIcon = comp.status === 'outdated' ? '⚠️' :
comp.status === 'latest' ? '✅' : '❓';
html += `
<div class="comparison-card ${statusClass}">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 10px;">
<strong style="font-size: 1.1em;">${comp.component_name}</strong>
<span style="font-size: 1.5em;">${statusIcon}</span>
</div>
<div style="margin-bottom: 8px;">
<span style="color: #666;">현재:</span>
<code style="background: #f0f0f0; padding: 2px 8px; border-radius: 4px;">${comp.current_version}</code>
</div>
<div style="margin-bottom: 8px;">
<span style="color: #666;">최신:</span>
<code style="background: #f0f0f0; padding: 2px 8px; border-radius: 4px;">${comp.latest_version || 'N/A'}</code>
</div>
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #ddd; font-size: 0.9em; color: #555;">
${comp.recommendation}
</div>
</div>
`;
});
} else {
html += `<div class="warning-text">⚠️ ${serverResult.message || '비교 실패'}</div>`;
}
html += '</div>';
});
html += '</div>';
resultEl.innerHTML = html;
}
// ========================================
// 펌웨어 버전 추가 모달
// ========================================
@@ -681,7 +793,7 @@ async function refreshFirmwareVersionList() {
// Dell Catalog에서 최신 버전 자동 가져오기
// ========================================
async function syncDellCatalog(model = "PowerEdge R750") {
showMessage(`${model} 최신 버전 정보를 Dell에서 가져오는 중...`, "info");
showToast(`${model} 최신 버전 정보를 Dell에서 가져오는 중...`, "info");
try {
const response = await fetchWithCSRF("/catalog/sync", {
@@ -692,13 +804,95 @@ async function syncDellCatalog(model = "PowerEdge R750") {
const data = await response.json();
if (data.success) {
showMessage(data.message, "success");
showToast(data.message, "success");
await refreshFirmwareVersionList();
} else {
showMessage(data.message, "error");
showToast(data.message, "error");
}
} catch (error) {
showMessage("버전 정보 동기화 실패: " + error, "error");
showToast("버전 정보 동기화 실패: " + error, "error");
}
}
// ========================================
// DRM 리포지토리 동기화
// ========================================
async function syncFromDRM() {
// DRM 리포지토리 경로 입력 받기
const repositoryPath = prompt(
'DRM 리포지토리 경로를 입력하세요:\n예: C:\\Dell\\Repository 또는 \\\\network\\share\\DellRepo',
'C:\\Dell\\Repository'
);
if (!repositoryPath) return;
const model = prompt('서버 모델을 입력하세요:', 'PowerEdge R750');
if (!model) return;
showToast('DRM 리포지토리에서 펌웨어 정보 가져오는 중...', 'info');
try {
const response = await fetchWithCSRF('/drm/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
repository_path: repositoryPath,
model: model
})
});
const data = await response.json();
if (data.success) {
showToast(`${data.message}`, 'success');
await refreshFirmwareVersionList();
} else {
showToast(data.message, 'error');
}
} catch (error) {
showToast('DRM 동기화 실패: ' + error, 'error');
}
}
async function checkDRMRepository() {
const repositoryPath = prompt(
'DRM 리포지토리 경로를 입력하세요:',
'C:\\Dell\\Repository'
);
if (!repositoryPath) return;
try {
const response = await fetchWithCSRF('/drm/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repository_path: repositoryPath })
});
const data = await response.json();
if (data.success && data.info) {
const info = data.info;
let message = `DRM 리포지토리 정보:\n\n`;
message += `경로: ${info.path}\n`;
message += `카탈로그 파일: ${info.catalog_file || '없음'}\n`;
message += `총 패키지 수: ${info.total_packages || 0}\n`;
if (info.available_models && info.available_models.length > 0) {
message += `\n사용 가능한 모델 (${info.available_models.length}개):\n`;
message += info.available_models.slice(0, 10).join(', ');
if (info.available_models.length > 10) {
message += ` ... 외 ${info.available_models.length - 10}`;
}
}
alert(message);
} else {
showToast('DRM 리포지토리를 찾을 수 없습니다', 'error');
}
} catch (error) {
showToast('DRM 확인 실패: ' + error, 'error');
}
}
@@ -716,8 +910,8 @@ async function refreshFirmwareVersionList() {
tbody.innerHTML = data.versions.length
? data.versions
.map(
(v) => `
.map(
(v) => `
<tr>
<td>${v.component_name}</td>
<td>${v.latest_version}</td>
@@ -728,13 +922,79 @@ async function refreshFirmwareVersionList() {
<button class="btn btn-danger" onclick="deleteFirmwareVersion(${v.id})">삭제</button>
</td>
</tr>`
)
.join("")
)
.join("")
: `<tr><td colspan="6" class="empty-message">등록된 버전 정보가 없습니다</td></tr>`;
} else {
showMessage(data.message, "error");
showToast(data.message, "error");
}
} catch (error) {
showMessage("버전 목록 로드 실패: " + error, "error");
showToast("버전 목록 로드 실패: " + error, "error");
}
}
// ========================================
// 탭 전환 기능
// ========================================
function showTab(tabName) {
// 모든 탭 콘텐츠 숨기기
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
// 모든 탭 버튼 비활성화
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
// 선택된 탭 표시
const selectedTab = document.getElementById(tabName + '-tab');
if (selectedTab) {
selectedTab.classList.add('active');
}
// 클릭된 버튼 활성화 (이벤트에서 호출된 경우)
if (event && event.target) {
event.target.classList.add('active');
} else {
// 직접 호출된 경우 해당 버튼 찾아서 활성화
const buttons = document.querySelectorAll('.tab-button');
buttons.forEach((btn, index) => {
if ((tabName === 'servers' && index === 0) ||
(tabName === 'versions' && index === 1) ||
(tabName === 'comparison' && index === 2)) {
btn.classList.add('active');
}
});
}
// 버전 탭 선택 시 목록 로드
if (tabName === 'versions') {
refreshFirmwareVersionList();
}
}
// ========================================
// 펌웨어 버전 삭제
// ========================================
async function deleteFirmwareVersion(versionId) {
if (!confirm('이 펌웨어 버전 정보를 삭제하시겠습니까?')) return;
try {
const response = await fetchWithCSRF(`/idrac/api/firmware-versions/${versionId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
refreshFirmwareVersionList();
} else {
showToast(data.message, 'error');
}
} catch (error) {
showToast('삭제 실패: ' + error, 'error');
}
}