update
This commit is contained in:
18
backend/static/css/admin_settings.css
Normal file
18
backend/static/css/admin_settings.css
Normal file
@@ -0,0 +1,18 @@
|
||||
/* Hover and Transition Effects */
|
||||
.hover-shadow:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15) !important;
|
||||
}
|
||||
|
||||
.transition-all {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Text Truncation */
|
||||
.text-truncate-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
55
backend/static/css/edit_xml.css
Normal file
55
backend/static/css/edit_xml.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/* Scrollbar Styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #888;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #888 #f1f1f1;
|
||||
}
|
||||
|
||||
/* Textarea Styles */
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #ccc;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
textarea:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
/* XML List Item Styles */
|
||||
.xml-list-item {
|
||||
padding: 10px;
|
||||
background-color: #ffffff;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.xml-list-item:hover {
|
||||
background-color: #e9ecef;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn {
|
||||
margin-top: 20px;
|
||||
}
|
||||
120
backend/static/css/index.css
Normal file
120
backend/static/css/index.css
Normal file
@@ -0,0 +1,120 @@
|
||||
/* ===== 공통 파일 카드 컴팩트 스타일 ===== */
|
||||
.file-card-compact {
|
||||
transition: all 0.2s ease;
|
||||
background: #fff;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.file-card-compact:hover {
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.file-card-compact a {
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
/* ===== 목록별 버튼 분리 규칙 ===== */
|
||||
|
||||
/* 처리된 파일 목록 전용 컨테이너(보기/삭제 2열) */
|
||||
.processed-list .file-card-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
/* 보기(처리된) */
|
||||
.processed-list .btn-view-processed {
|
||||
border-color: #3b82f6;
|
||||
color: #1d4ed8;
|
||||
padding: .425rem .6rem;
|
||||
font-size: .8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.processed-list .btn-view-processed:hover {
|
||||
background: rgba(59, 130, 246, .08);
|
||||
}
|
||||
|
||||
/* 삭제(처리된) — 더 작게 */
|
||||
.processed-list .btn-delete-processed {
|
||||
border-color: #ef4444;
|
||||
color: #b91c1c;
|
||||
padding: .3rem .5rem;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.processed-list .btn-delete-processed:hover {
|
||||
background: rgba(239, 68, 68, .08);
|
||||
}
|
||||
|
||||
/* 백업 파일 목록 전용 컨테이너(단일 버튼) */
|
||||
.backup-list .file-card-single-button {
|
||||
display: flex;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
/* 보기(백업) — 강조 색상 */
|
||||
.backup-list .btn-view-backup {
|
||||
width: 100%;
|
||||
border-color: #10b981;
|
||||
color: #047857;
|
||||
padding: .45rem .75rem;
|
||||
font-size: .8125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.backup-list .btn-view-backup:hover {
|
||||
background: rgba(16, 185, 129, .08);
|
||||
}
|
||||
|
||||
/* ===== 백업 파일 날짜 헤더 ===== */
|
||||
.list-group-item .bg-light {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.list-group-item:hover .bg-light {
|
||||
background-color: #e9ecef !important;
|
||||
}
|
||||
|
||||
/* ===== 진행바 애니메이션 ===== */
|
||||
.progress {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
/* ===== 반응형 텍스트 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.card-body {
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 스크롤바 스타일링(모달) ===== */
|
||||
.modal-body pre::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.modal-body pre::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal-body pre::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal-body pre::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
86
backend/static/css/jobs.css
Normal file
86
backend/static/css/jobs.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* Status Dot Styles */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background-color: #198754;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table Text Handling */
|
||||
#jobs-table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#jobs-table td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Column Width Fixed */
|
||||
#jobs-table td:nth-child(1) {
|
||||
max-width: 110px;
|
||||
}
|
||||
|
||||
/* IP */
|
||||
#jobs-table td:nth-child(2) {
|
||||
max-width: 160px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* JID */
|
||||
#jobs-table td:nth-child(3) {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* 작업명 */
|
||||
#jobs-table td:nth-child(4) {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
/* 상태 */
|
||||
#jobs-table td:nth-child(5) {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* 진행률 */
|
||||
#jobs-table td:nth-child(6) {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* 메시지 */
|
||||
#jobs-table td:nth-child(7) {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
/* 시간 */
|
||||
|
||||
/* Hover to Show Full Text */
|
||||
#jobs-table td:hover {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
296
backend/static/css/scp.css
Normal file
296
backend/static/css/scp.css
Normal file
@@ -0,0 +1,296 @@
|
||||
/* Custom styles for XML Management (manage_xml.html) */
|
||||
.main-title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.card-header-custom {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* File Input Styling */
|
||||
.custom-file {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.custom-file-input {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: calc(1.5em + .75rem + 2px);
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.custom-file-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
height: calc(1.5em + .75rem + 2px);
|
||||
padding: .375rem .75rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: .25rem;
|
||||
}
|
||||
|
||||
.custom-file-label::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
height: calc(1.5em + .75rem);
|
||||
padding: .375rem .75rem;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
content: "Browse";
|
||||
background-color: #e9ecef;
|
||||
border-left: inherit;
|
||||
border-radius: 0 .25rem .25rem 0;
|
||||
}
|
||||
|
||||
/* 아이콘 + 뱃지 스타일 */
|
||||
.file-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
padding-right: 5px;
|
||||
/* 스크롤바 공간 확보 */
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.icon-badge-item {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
/* 둥글게 */
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.2s ease-in-out;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.icon-badge-item:hover {
|
||||
background-color: #f1f8ff;
|
||||
/* 아주 연한 파랑 */
|
||||
border-color: #cce5ff;
|
||||
transform: translateY(-2px);
|
||||
/* 살짝 위로 */
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.icon-badge-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* 텍스트 말줄임 필수 */
|
||||
margin-right: 15px;
|
||||
/* 버튼과 간격 확보 */
|
||||
}
|
||||
|
||||
.select-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-icon-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #007bff, #0056b3);
|
||||
/* 그라데이션 */
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 123, 255, 0.2);
|
||||
}
|
||||
|
||||
.file-name-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 이름과 뱃지를 위아래로 */
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-name-badge {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.badge-custom {
|
||||
background-color: #e7f3ff;
|
||||
color: #007bff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
/* 절대 줄어들지 않음 */
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 버튼 스타일 개선 */
|
||||
.action-buttons .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-buttons .btn i {
|
||||
margin-right: 4px;
|
||||
/* 아이콘과 텍스트 사이 간격 */
|
||||
}
|
||||
|
||||
/* 모바일 대응: 화면이 좁을 땐 텍스트 숨기기 */
|
||||
@media (max-width: 768px) {
|
||||
.action-buttons .btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-buttons .btn i {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.action-buttons .btn {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px;
|
||||
font-size: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #ddd;
|
||||
}
|
||||
|
||||
/* Diff View Styles (scp_diff.html) */
|
||||
.diff-container {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9rem;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.diff-add {
|
||||
background-color: #e6ffec;
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
.diff-del {
|
||||
background-color: #ffebe9;
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
.diff-header {
|
||||
color: #6f42c1;
|
||||
font-weight: bold;
|
||||
}
|
||||
51
backend/static/js/admin.js
Normal file
51
backend/static/js/admin.js
Normal 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;
|
||||
}
|
||||
// 제출 허용 (서버측에서도 반드시 검증)
|
||||
});
|
||||
})();
|
||||
163
backend/static/js/index.js
Normal file
163
backend/static/js/index.js
Normal file
@@ -0,0 +1,163 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 스크립트 선택 시 XML 드롭다운 토글
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const TARGET_SCRIPT = "02-set_config.py";
|
||||
const scriptSelect = document.getElementById('script');
|
||||
const xmlGroup = document.getElementById('xmlFileGroup');
|
||||
|
||||
function toggleXml() {
|
||||
if (!scriptSelect || !xmlGroup) return;
|
||||
if (scriptSelect.value === TARGET_SCRIPT) {
|
||||
xmlGroup.style.display = 'block';
|
||||
xmlGroup.classList.add('fade-in');
|
||||
} else {
|
||||
xmlGroup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (scriptSelect) {
|
||||
toggleXml();
|
||||
scriptSelect.addEventListener('change', toggleXml);
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 파일 보기 모달
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const modalEl = document.getElementById('fileViewModal');
|
||||
const titleEl = document.getElementById('fileViewModalLabel');
|
||||
const contentEl = document.getElementById('fileViewContent');
|
||||
|
||||
if (modalEl) {
|
||||
modalEl.addEventListener('show.bs.modal', async (ev) => {
|
||||
const btn = ev.relatedTarget;
|
||||
const folder = btn?.getAttribute('data-folder') || '';
|
||||
const date = btn?.getAttribute('data-date') || '';
|
||||
const filename = btn?.getAttribute('data-filename') || '';
|
||||
|
||||
titleEl.innerHTML = `<i class="bi bi-file-text me-2"></i>${filename || '파일'}`;
|
||||
contentEl.textContent = '불러오는 중...';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (folder) params.set('folder', folder);
|
||||
if (date) params.set('date', date);
|
||||
if (filename) params.set('filename', filename);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/view_file?${params.toString()}`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
|
||||
const data = await res.json();
|
||||
contentEl.textContent = data?.content ?? '(빈 파일)';
|
||||
} catch (e) {
|
||||
contentEl.textContent = '파일을 불러오지 못했습니다: ' + (e?.message || e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 진행바 업데이트
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
window.updateProgress = function (val) {
|
||||
const bar = document.getElementById('progressBar');
|
||||
if (!bar) return;
|
||||
const v = Math.max(0, Math.min(100, Number(val) || 0));
|
||||
bar.style.width = v + '%';
|
||||
bar.setAttribute('aria-valuenow', v);
|
||||
bar.innerHTML = `<span class="fw-semibold">${v}%</span>`;
|
||||
};
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// CSRF 토큰
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value || '';
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 공통 POST 함수
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
async function postFormAndHandle(url) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Accept': 'application/json, text/html;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
});
|
||||
|
||||
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
||||
|
||||
if (ct.includes('application/json')) {
|
||||
const data = await res.json();
|
||||
if (data.success === false) {
|
||||
throw new Error(data.error || ('HTTP ' + res.status));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return { success: true, html: true };
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// MAC 파일 이동
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const macForm = document.getElementById('macMoveForm');
|
||||
if (macForm) {
|
||||
macForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = macForm.querySelector('button');
|
||||
const originalHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>처리 중...';
|
||||
try {
|
||||
await postFormAndHandle(macForm.action);
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
alert('MAC 이동 중 오류: ' + (err?.message || err));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GUID 파일 이동
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const guidForm = document.getElementById('guidMoveForm');
|
||||
if (guidForm) {
|
||||
guidForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = guidForm.querySelector('button');
|
||||
const originalHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>처리 중...';
|
||||
try {
|
||||
await postFormAndHandle(guidForm.action);
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
alert('GUID 이동 중 오류: ' + (err?.message || err));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 알림 자동 닫기
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.alert').forEach(alert => {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
});
|
||||
419
backend/static/js/jobs.js
Normal file
419
backend/static/js/jobs.js
Normal 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 => ({
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
}[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
backend/static/js/scp.js
Normal file
30
backend/static/js/scp.js
Normal 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user