/**
* dashboard.js
* 통합된 대시보드 관리 스크립트
* (script.js + index.js + index_custom.js 통합)
*/
document.addEventListener('DOMContentLoaded', () => {
// ─────────────────────────────────────────────────────────────
// 1. 공통 유틸리티 & 설정
// ─────────────────────────────────────────────────────────────
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value || '';
// 진행바 업데이트 (전역 함수로 등록하여 다른 곳에서 호출 가능)
window.updateProgress = function (val) {
const bar = document.getElementById('progressBar');
if (!bar) return;
const v = Math.max(0, Math.min(100, Number(val) || 0));
// 부모 컨테이너(섹션)가 숨겨져 있다면 표시
const section = document.getElementById('progressSection');
if (section && section.style.display === 'none') {
section.style.display = 'block';
}
// (Legacy support) 부모 컨테이너가 숨겨져 있다면 표시
const progressContainer = bar.closest('.progress');
if (progressContainer && progressContainer.parentElement.classList.contains('d-none')) {
progressContainer.parentElement.classList.remove('d-none');
}
bar.style.width = v + '%';
bar.setAttribute('aria-valuenow', v);
bar.innerHTML = `${v}%`;
};
/**
* 테이블 정렬 함수
* @param {string} tableId - 테이블 ID
* @param {number} colIdx - 정렬할 컬럼 인덱스 (0부터 시작)
*/
window.sortTable = function (tableId, colIdx) {
const table = document.getElementById(tableId);
if (!table) return;
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
// 데이터가 없거나 1개 뿐이면 패스
if (rows.length <= 1) return;
if (rows[0].querySelector('td[colspan]')) return; // "파일이 없습니다" 행
const th = table.querySelectorAll('th')[colIdx];
let asc = !th.classList.contains('sorted-asc'); // 현재 상태의 반대로 토글
// 모든 헤더 초기화
table.querySelectorAll('th').forEach(h => {
h.classList.remove('sorted-asc', 'sorted-desc');
const icon = h.querySelector('i');
if (icon) icon.className = 'bi bi-arrow-down-up small text-muted';
});
// 현재 헤더 상태 업데이트
th.classList.add(asc ? 'sorted-asc' : 'sorted-desc');
const icon = th.querySelector('i');
if (icon) icon.className = asc ? 'bi bi-sort-down-alt text-primary' : 'bi bi-sort-up-alt text-primary';
// 정렬
rows.sort((rowA, rowB) => {
const cellA = rowA.children[colIdx];
const cellB = rowB.children[colIdx];
// data-sort 속성이 있으면 그것을 우선 사용 (날짜/숫자 등)
const valA = cellA.dataset.sort || cellA.textContent.trim();
const valB = cellB.dataset.sort || cellB.textContent.trim();
const isNum = !isNaN(parseFloat(valA)) && isFinite(valA) && !isNaN(parseFloat(valB)) && isFinite(valB);
if (isNum) {
return asc ? (parseFloat(valA) - parseFloat(valB)) : (parseFloat(valB) - parseFloat(valA));
} else {
return asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
}
});
// 다시 추가 (재정렬)
rows.forEach(row => tbody.appendChild(row));
};
/**
* 테이블 필터링 함수
* @param {string} tableId - 테이블 ID
* @param {string} text - 검색어
*/
window.filterTable = function (tableId, text) {
const table = document.getElementById(tableId);
if (!table) return 0;
const tbody = table.querySelector('tbody');
const rows = tbody.querySelectorAll('tr');
const query = text.toLowerCase().trim();
let visibleCount = 0;
rows.forEach(row => {
if (row.querySelector('td[colspan]')) return; // 메시지 행 제외
// 첫 번째 셀(파일명)만 검색 대상이라고 가정하거나, 전체 텍스트 검색
const rowText = row.textContent.toLowerCase();
if (rowText.includes(query)) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
});
// 검색 결과 없음 처리 (Optional)
return visibleCount;
};
// 줄 수 카운터 (script.js에서 가져옴)
function updateLineCount(textareaId, badgeId) {
const textarea = document.getElementById(textareaId);
const badge = document.getElementById(badgeId);
if (!textarea || !badge) return;
const updateCount = () => {
const text = textarea.value.trim();
if (text === '') {
badge.textContent = '0';
return;
}
// 빈 줄 제외하고 카운트
const lines = text.split('\n').filter(line => line.trim().length > 0);
badge.textContent = lines.length; // UI 간소화를 위해 '줄' 텍스트 제외하거나 포함 가능
};
updateCount();
['input', 'change', 'keyup', 'paste'].forEach(evt => {
textarea.addEventListener(evt, () => setTimeout(updateCount, 10));
});
}
// 초기화
updateLineCount('ips', 'ipLineCount');
updateLineCount('server_list_content', 'serverLineCount');
// 알림 자동 닫기
setTimeout(() => {
document.querySelectorAll('.alert').forEach(alert => {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
});
}, 5000);
// ─────────────────────────────────────────────────────────────
// 2. IP 처리 및 스캔 로직
// ─────────────────────────────────────────────────────────────
// 2-1. 스크립트 선택 시 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) {
// TomSelect 적용 전/후 모두 대응하기 위해 이벤트 리스너 등록
toggleXml();
scriptSelect.addEventListener('change', toggleXml);
// TomSelect 초기화
new TomSelect("#script", {
create: false,
sortField: { field: "text", direction: "asc" },
placeholder: "스크립트를 검색하거나 선택하세요...",
plugins: ['clear_button'],
allowEmptyOption: true,
onChange: toggleXml // TomSelect 변경 시에도 호출
});
}
// 2-2. IP 입력 데이터 보존 (Local Storage)
const ipTextarea = document.getElementById('ips');
const STORAGE_KEY_IP = 'ip_input_draft';
if (ipTextarea) {
const savedIps = localStorage.getItem(STORAGE_KEY_IP);
if (savedIps) {
ipTextarea.value = savedIps;
// 강제 이벤트 트리거하여 줄 수 카운트 업데이트
ipTextarea.dispatchEvent(new Event('input'));
}
ipTextarea.addEventListener('input', () => {
localStorage.setItem(STORAGE_KEY_IP, ipTextarea.value);
});
}
// 2-3. IP 스캔 (Modal / AJAX)
const btnScan = document.getElementById('btnStartScan');
if (btnScan) {
btnScan.addEventListener('click', async () => {
const startIp = '10.10.0.2';
const endIp = '10.10.0.255';
const ipsTextarea = document.getElementById('ips');
const progressBar = document.getElementById('progressBar');
// UI 잠금 및 로딩 표시
const originalIcon = btnScan.innerHTML;
btnScan.disabled = true;
btnScan.innerHTML = '';
if (progressBar) {
// 진행바 표시 및 초기화
const progressContainer = progressBar.closest('.progress');
if (progressContainer && progressContainer.parentElement) {
// 이미 존재하는 row 등을 찾아서 보여주기 (필요시)
}
window.updateProgress(100); // 스캔 중임을 알리기 위해 꽉 채움 (애니메이션)
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressBar.textContent = 'IP 스캔 중...';
}
try {
const res = await fetch('/utils/scan_network', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ start_ip: startIp, end_ip: endIp })
});
if (res.redirected) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.reload();
return;
}
const contentType = res.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const text = await res.text();
throw new Error(`서버 응답 오류: ${text.substring(0, 100)}...`);
}
const data = await res.json();
if (data.success) {
if (data.active_ips && data.active_ips.length > 0) {
ipsTextarea.value = data.active_ips.join('\n');
ipsTextarea.dispatchEvent(new Event('input')); // 저장 및 카운트 갱신
const btnCopy = document.getElementById('btnCopyIps');
if (btnCopy) btnCopy.disabled = false;
alert(`스캔 완료: ${data.active_ips.length}개의 활성 IP를 찾았습니다.`);
} else {
alert('활성 IP를 발견하지 못했습니다.');
}
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (err) {
console.error(err);
alert('오류가 발생했습니다: ' + (err.message || err));
} finally {
// 복구
btnScan.disabled = false;
btnScan.innerHTML = originalIcon;
if (progressBar) {
window.updateProgress(0);
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
const section = document.getElementById('progressSection');
if (section) section.style.display = 'none';
}
}
});
}
// 2-4. IP 복사 버튼
const btnCopyIps = document.getElementById('btnCopyIps');
if (btnCopyIps) {
btnCopyIps.addEventListener('click', async () => {
const ipsTextarea = document.getElementById('ips');
if (!ipsTextarea || !ipsTextarea.value.trim()) return;
try {
await navigator.clipboard.writeText(ipsTextarea.value.trim());
const original = btnCopyIps.innerHTML;
btnCopyIps.innerHTML = '복사됨';
btnCopyIps.classList.replace('btn-outline-secondary', 'btn-outline-success');
setTimeout(() => {
btnCopyIps.innerHTML = original;
btnCopyIps.classList.replace('btn-outline-success', 'btn-outline-secondary');
}, 1500);
} catch {
ipsTextarea.select();
document.execCommand('copy');
}
});
}
// 2-5. IP 지우기 버튼
const btnClear = document.getElementById('btnClearIps');
if (btnClear) {
btnClear.addEventListener('click', () => {
const ipsTextarea = document.getElementById('ips');
if (ipsTextarea && confirm('입력된 IP 목록을 모두 지우시겠습니까?')) {
ipsTextarea.value = '';
ipsTextarea.dispatchEvent(new Event('input'));
const btnCopy = document.getElementById('btnCopyIps');
if (btnCopy) btnCopy.disabled = true;
}
});
}
// 2-5. 메인 IP 폼 제출 및 진행률 폴링 (script.js 로직)
const ipForm = document.getElementById("ipForm");
if (ipForm) {
ipForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const formData = new FormData(ipForm);
// [NEW] GUID 작업 시 로컬 스토리지의 슬롯 우선순위 추가
const jobType = formData.get('job_type');
if (jobType === 'guid') {
const savedPriority = localStorage.getItem('guidSlotNumbers');
if (savedPriority) {
formData.append('slot_priority', savedPriority);
}
}
const btn = ipForm.querySelector('button[type="submit"]');
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '처리 중...';
// 진행바 초기화
window.updateProgress(0);
try {
const res = await fetch(ipForm.action, {
method: "POST",
body: formData
});
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
if (data.job_id) {
// 비동기 작업 시작됨 -> 폴링 시작
pollProgress(data.job_id);
} else {
// 동기 처리 완료
window.updateProgress(100);
setTimeout(() => location.reload(), 1000);
}
} catch (err) {
console.error("처리 중 오류:", err);
alert("처리 중 오류 발생: " + err.message);
btn.disabled = false;
btn.innerHTML = originalHtml;
}
});
}
// 폴링 함수
function pollProgress(jobId) {
const interval = setInterval(async () => {
try {
const res = await fetch(`/progress_status/${jobId}`);
if (!res.ok) {
clearInterval(interval);
return;
}
const data = await res.json();
if (data.progress !== undefined) {
window.updateProgress(data.progress);
}
if (data.progress >= 100) {
clearInterval(interval);
window.updateProgress(100);
setTimeout(() => location.reload(), 1500);
}
} catch (err) {
console.error('진행률 확인 중 오류:', err);
clearInterval(interval);
}
}, 500);
}
// ─────────────────────────────────────────────────────────────
// 3. 파일 관련 기능 (백업 이동, 파일 보기 등)
// ─────────────────────────────────────────────────────────────
// 3-1. 파일 보기 모달 (index.js)
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 = `${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);
}
});
}
// 3-2. 백업 파일 드래그 앤 드롭 이동 (index_custom.js)
let selectedItems = new Set();
const backupContainers = document.querySelectorAll('.backup-files-container');
// 다중 선택 처리
document.addEventListener('click', function (e) {
const item = e.target.closest('.backup-file-item');
if (item && !e.target.closest('a') && !e.target.closest('button')) {
if (e.ctrlKey || e.metaKey) {
toggleSelection(item);
} else {
const wasSelected = item.classList.contains('selected');
clearSelection();
if (!wasSelected) toggleSelection(item);
}
} else if (!e.target.closest('.backup-files-container')) {
// 배경 클릭 시 선택 해제? (UX에 따라 결정, 여기선 일단 패스)
}
});
// 빈 공간 클릭 시 선택 해제
document.addEventListener('mousedown', function (e) {
if (!e.target.closest('.backup-file-item') && !e.target.closest('.backup-files-container')) {
clearSelection();
}
});
function toggleSelection(item) {
if (item.classList.contains('selected')) {
item.classList.remove('selected');
selectedItems.delete(item);
} else {
item.classList.add('selected');
selectedItems.add(item);
}
}
function clearSelection() {
document.querySelectorAll('.backup-file-item.selected').forEach(el => el.classList.remove('selected'));
selectedItems.clear();
}
function updateFolderCount(folderDate) {
const container = document.querySelector(`.backup-files-container[data-folder="${folderDate}"]`);
if (container) {
const accordionItem = container.closest('.accordion-item');
if (accordionItem) {
const badge = accordionItem.querySelector('.accordion-button .badge');
if (badge) {
const visibleRows = Array.from(container.children).filter(row => row.matches('.backup-file-item')).length;
badge.textContent = `${visibleRows} 파일`;
}
}
}
}
// Sortable 초기화
backupContainers.forEach(container => {
if (typeof Sortable === 'undefined') return;
new Sortable(container, {
group: 'backup-files',
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
handle: '.backup-drag-handle',
delay: 100,
delayOnTouchOnly: true,
onStart: function (evt) {
if (!evt.item.classList.contains('selected')) {
clearSelection();
toggleSelection(evt.item);
}
},
onEnd: function (evt) {
if (evt.to === evt.from) return;
const sourceFolder = evt.from.getAttribute('data-folder');
const targetFolder = evt.to.getAttribute('data-folder');
if (!sourceFolder || !targetFolder) {
alert('잘못된 이동 요청입니다.');
location.reload();
return;
}
let itemsToMove = Array.from(selectedItems).filter(item => {
return item.closest('.backup-files-container')?.getAttribute('data-folder') === sourceFolder;
});
if (itemsToMove.length === 0) itemsToMove = [evt.item];
else if (!itemsToMove.includes(evt.item)) itemsToMove.push(evt.item);
// UI 상 이동 처리 (Sortable이 하나는 처리해주지만 다중 선택은 직접 옮겨야 함)
itemsToMove.forEach(item => {
if (item !== evt.item) evt.to.appendChild(item);
});
// 서버 요청
if (!window.APP_CONFIG) {
console.error("Window APP_CONFIG not found!");
return;
}
const promises = itemsToMove.map(item => {
const filename = item.getAttribute('data-filename');
if (!filename) return Promise.resolve();
return fetch(window.APP_CONFIG.moveBackupUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': window.APP_CONFIG.csrfToken
},
body: JSON.stringify({
filename: filename,
source_folder: sourceFolder,
target_folder: targetFolder
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
// 속성 업데이트 (다운로드 링크 등)
const btn = item.querySelector('.btn-view-backup');
if (btn) btn.setAttribute('data-date', targetFolder);
const link = item.querySelector('a[download]');
if (link && window.APP_CONFIG.downloadBaseUrl) {
const newHref = window.APP_CONFIG.downloadBaseUrl
.replace('PLACEHOLDER_DATE', targetFolder)
.replace('PLACEHOLDER_FILE', filename);
link.setAttribute('href', newHref);
}
}
return data;
});
});
Promise.all(promises).then(results => {
updateFolderCount(sourceFolder);
updateFolderCount(targetFolder);
clearSelection();
const failed = results.filter(r => r && !r.success);
if (failed.length > 0) {
alert(failed.length + '개의 파일 이동 실패. 새로고침이 필요합니다.');
location.reload();
}
}).catch(err => {
console.error(err);
alert('이동 중 통신 오류 발생');
});
}
});
});
// ─────────────────────────────────────────────────────────────
// 4. 슬롯 우선순위 설정 모달 (index_custom.js)
// ─────────────────────────────────────────────────────────────
const slotPriorityModal = document.getElementById('slotPriorityModal');
if (slotPriorityModal) {
const slotNumbersInput = document.getElementById('slotNumbersInput');
const slotCountDisplay = document.getElementById('slotCountDisplay');
const slotPreview = document.getElementById('slotPreview');
const slotPriorityInput = document.getElementById('slot_priority_input');
const modalServerListContent = document.getElementById('modal_server_list_content');
const serverListTextarea = document.getElementById('server_list_content');
const slotPriorityForm = document.getElementById('slotPriorityForm');
const btnClearSlots = document.getElementById('btnClearSlots');
const presetCountInput = document.getElementById('presetCountInput');
const btnApplyPreset = document.getElementById('btnApplyPreset');
const defaultPriority = ['38', '39', '37', '36', '32', '33', '34', '35', '31', '40'];
function loadSlots() {
const saved = localStorage.getItem('guidSlotNumbers');
slotNumbersInput.value = saved ? saved : defaultPriority.join(', ');
if (presetCountInput) presetCountInput.value = 10;
updateSlotPreview();
}
function saveSlots() {
localStorage.setItem('guidSlotNumbers', slotNumbersInput.value);
}
function parseSlots(input) {
if (!input || !input.trim()) return [];
return input.split(/[,\s\n]+/)
.map(s => s.trim())
.filter(s => s !== '' && /^\d+$/.test(s))
.filter((v, i, a) => a.indexOf(v) === i); // Unique
}
function updateSlotPreview() {
const slots = parseSlots(slotNumbersInput.value);
const count = slots.length;
slotCountDisplay.textContent = `${count}개`;
slotCountDisplay.className = count > 0
? 'badge bg-primary text-white border border-primary rounded-pill px-3 py-1'
: 'badge bg-white text-dark border rounded-pill px-3 py-1';
if (count === 0) {
slotPreview.style.display = 'flex';
slotPreview.innerHTML = `
프리셋을 선택하거나 번호를 입력하세요.
`;
} else {
slotPreview.style.display = 'grid';
slotPreview.innerHTML = slots.map((slot, index) => `
${index + 1}
Slot ${slot}
`).join('');
// 미리보기 Sortable
new Sortable(slotPreview, {
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
onEnd: function () {
const items = slotPreview.querySelectorAll('.slot-badge');
const newSlots = Array.from(items).map(item => item.getAttribute('data-slot'));
slotNumbersInput.value = newSlots.join(', ');
items.forEach((item, index) => {
const idx = item.querySelector('.slot-index');
if (idx) idx.textContent = index + 1;
});
saveSlots();
}
});
}
saveSlots();
}
if (btnApplyPreset) {
btnApplyPreset.addEventListener('click', () => {
let count = parseInt(presetCountInput.value) || 10;
count = Math.max(1, Math.min(10, count));
presetCountInput.value = count;
slotNumbersInput.value = defaultPriority.slice(0, count).join(', ');
updateSlotPreview();
});
}
slotNumbersInput.addEventListener('input', updateSlotPreview);
btnClearSlots.addEventListener('click', () => {
if (confirm('모두 지우시겠습니까?')) {
slotNumbersInput.value = '';
updateSlotPreview();
}
});
slotPriorityModal.addEventListener('show.bs.modal', () => {
if (serverListTextarea) modalServerListContent.value = serverListTextarea.value;
loadSlots();
});
slotPriorityForm.addEventListener('submit', (e) => {
const slots = parseSlots(slotNumbersInput.value);
if (slots.length === 0) {
e.preventDefault();
alert('최소 1개 이상의 슬롯을 입력하세요.');
return;
}
slotPriorityInput.value = slots.join(',');
saveSlots();
});
}
// ─────────────────────────────────────────────────────────────
// 5. Quick Move (중복체크 포함) (index_custom.js)
// ─────────────────────────────────────────────────────────────
const quickMoveForms = ['macMoveForm', 'guidMoveForm', 'gpuMoveForm'];
let pendingAction = null;
const dupModalEl = document.getElementById('duplicateCheckModal');
const dupModal = dupModalEl ? new bootstrap.Modal(dupModalEl) : null;
const btnConfirmOverwrite = document.getElementById('btnConfirmOverwrite');
if (btnConfirmOverwrite) {
btnConfirmOverwrite.addEventListener('click', () => {
if (pendingAction) {
dupModal.hide();
pendingAction(true); // overwrite=true
pendingAction = null;
}
});
}
quickMoveForms.forEach(id => {
const form = document.getElementById(id);
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
const btn = form.querySelector('button[type="submit"]');
if (!btn || btn.disabled) return;
const originalContent = btn.innerHTML;
const executeMove = (overwrite = false) => {
btn.classList.add('disabled');
btn.disabled = true;
btn.innerHTML = '';
fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken // 전역 csrfToken 사용
},
body: JSON.stringify({ overwrite: overwrite })
})
.then(async response => {
const ct = response.headers.get("content-type");
if (!ct || !ct.includes("application/json")) {
// HTTP 200이지만 HTML이 올 경우 에러나 마찬가지 (로그인 리다이렉트 등)
if (response.ok && response.url.includes("login")) {
window.location.reload();
return;
}
// "MAC 이동 중 오류: HTTP 200" 등 텍스트일 수 있음
const txt = await response.text();
if (response.ok) {
// 성공으로 간주하고 리로드 (가끔 백엔드가 JSON 대신 빈 성공 응답을 줄 때)
window.location.reload();
return;
}
throw new Error(txt);
}
return response.json();
})
.then(data => {
if (!data) return; // 위에서 처리됨
if (data.requires_confirmation) {
showDuplicateModal(data.duplicates, data.duplicate_count);
pendingAction = executeMove;
resetButton();
} else if (data.success) {
window.location.reload();
} else {
alert(data.error || '작업 실패');
resetButton();
}
})
.catch(err => {
console.error(err);
// HTTP 200 에러 억제 요청 반영
if (err.message && err.message.includes("200")) {
window.location.reload();
return;
}
alert('오류 발생: ' + err);
resetButton();
});
};
const resetButton = () => {
btn.classList.remove('disabled');
btn.disabled = false;
btn.innerHTML = originalContent;
};
executeMove(false);
});
}
});
function showDuplicateModal(duplicates, count) {
const listEl = document.getElementById('dupList');
const countEl = document.getElementById('dupCount');
const moreEl = document.getElementById('dupMore');
const moreCountEl = document.getElementById('dupMoreCount');
if (countEl) countEl.textContent = count;
if (listEl) {
listEl.innerHTML = '';
const limit = 10;
duplicates.slice(0, limit).forEach(name => {
const li = document.createElement('li');
li.innerHTML = `${name}`;
listEl.appendChild(li);
});
if (duplicates.length > limit) {
if (moreEl) {
moreEl.style.display = 'block';
moreCountEl.textContent = duplicates.length - limit;
}
} else {
if (moreEl) moreEl.style.display = 'none';
}
}
if (dupModal) dupModal.show();
}
// ─────────────────────────────────────────────────────────────
// 6. 작업 유형 및 프로파일 처리 (process_ips 관련)
// ─────────────────────────────────────────────────────────────
const jobTypeSelect = document.getElementById('job_type');
const profileSelect = document.getElementById('profile');
const profileGroup = document.getElementById('profileGroup');
const profileDesc = document.getElementById('profileDesc');
const legacyScriptGroup = document.getElementById('legacyScriptGroup');
const scriptSelectLegacy = document.getElementById('script'); // 기존 스크립트 select
if (jobTypeSelect) {
jobTypeSelect.addEventListener('change', async () => {
const jobType = jobTypeSelect.value;
// 초기화
if (profileSelect) {
profileSelect.innerHTML = '';
profileDesc.textContent = '';
}
if (jobType === 'legacy') {
// Legacy 모드
if (profileGroup) profileGroup.style.display = 'none';
if (legacyScriptGroup) legacyScriptGroup.style.display = 'block';
if (scriptSelectLegacy) scriptSelectLegacy.required = true;
if (profileSelect) profileSelect.required = false;
} else if (['mac', 'server_info', 'guid'].includes(jobType)) {
// 신규 모드
if (legacyScriptGroup) legacyScriptGroup.style.display = 'none';
if (profileGroup) profileGroup.style.display = 'block';
if (scriptSelectLegacy) scriptSelectLegacy.required = false;
if (profileSelect) profileSelect.required = true;
// 프로파일 로드
try {
const res = await fetch(`/api/profiles/${jobType}`);
if (!res.ok) throw new Error('프로파일 로드 실패');
const profiles = await res.json();
profileSelect.innerHTML = '';
profiles.forEach(p => {
const opt = document.createElement('option');
opt.value = p.name;
opt.textContent = `${p.name} ${p.is_default ? '(기본)' : ''}`;
opt.dataset.desc = p.description;
profileSelect.appendChild(opt);
});
} catch (err) {
console.error(err);
profileSelect.innerHTML = '';
}
} else {
// 선택 안함
if (legacyScriptGroup) legacyScriptGroup.style.display = 'none';
if (profileGroup) profileGroup.style.display = 'none';
}
});
// 프로파일 설명 표시
if (profileSelect) {
profileSelect.addEventListener('change', () => {
const selected = profileSelect.options[profileSelect.selectedIndex];
if (selected && selected.dataset.desc) {
profileDesc.textContent = selected.dataset.desc;
} else {
profileDesc.textContent = '';
}
});
}
}
// ─────────────────────────────────────────────────────────────
// 7. 탭 활성화 동기화 (외부 탭 활성화 시 내부 서브탭도 활성화)
// ─────────────────────────────────────────────────────────────
const repositoryTab = document.getElementById('repository-tab');
if (repositoryTab) {
repositoryTab.addEventListener('shown.bs.tab', function () {
// repository 탭이 활성화되면 첫 번째 서브탭(pills-backup)도 활성화
const firstPillTab = document.getElementById('pills-backup-tab');
const firstPillPane = document.getElementById('pills-backup');
if (firstPillTab && firstPillPane) {
// 버튼 활성화
document.querySelectorAll('#repoTabs .nav-link').forEach(btn => {
btn.classList.remove('active');
btn.setAttribute('aria-selected', 'false');
});
firstPillTab.classList.add('active');
firstPillTab.setAttribute('aria-selected', 'true');
// 패널 활성화
}
});
}
// ─────────────────────────────────────────────────────────────
// 7. 커스텀 탭 전환 로직
// ─────────────────────────────────────────────────────────────
const globalFileSearch = document.getElementById('globalFileSearch');
const repoTabs = document.getElementById('repoTabs');
const activeFileModeBadge = document.getElementById('activeFileModeBadge');
const fileToolbarHint = document.getElementById('fileToolbarHint');
const fileSearchScopeText = document.getElementById('fileSearchScopeText');
const clearFileSearchButton = document.getElementById('btnClearFileSearch');
const kpiButtons = document.querySelectorAll('.file-kpi-button');
function filterBackupAccordion(text) {
const accordion = document.getElementById('backupAccordion');
const backupSearchEmpty = document.getElementById('backupSearchEmpty');
if (!accordion) return 0;
const query = text.toLowerCase().trim();
const items = accordion.querySelectorAll('.accordion-item');
let visibleSections = 0;
items.forEach(item => {
const headerText = item.querySelector('.accordion-button')?.textContent.toLowerCase() || '';
const rows = item.querySelectorAll('.backup-file-item');
const headerMatched = query === '' || headerText.includes(query);
let visibleRows = 0;
rows.forEach(row => {
const rowMatched = headerMatched || row.textContent.toLowerCase().includes(query);
row.classList.toggle('match-hidden', !rowMatched);
row.style.display = rowMatched ? '' : 'none';
if (rowMatched) visibleRows++;
});
const isVisible = query === '' || headerMatched || visibleRows > 0;
item.style.display = isVisible ? '' : 'none';
if (isVisible) visibleSections++;
});
if (backupSearchEmpty) {
backupSearchEmpty.classList.toggle('show', query !== '' && visibleSections === 0);
}
return visibleSections;
}
function getActiveSearchContext() {
const repositoryTab = document.getElementById('repository-tab');
const isRepositoryMode = repositoryTab?.classList.contains('active');
if (!isRepositoryMode) {
return {
label: '처리된 파일',
hint: '현재 스테이징 폴더에서 처리된 결과 파일을 확인합니다.',
placeholder: '처리된 파일명을 검색하세요',
scopeText: '처리된 파일',
tableId: 'tableProcessed'
};
}
const activeSubTab = document.querySelector('#repoTabs .nav-link.active');
const activePanelId = activeSubTab?.getAttribute('data-bs-target');
switch (activePanelId) {
case '#subpanel-mac':
return {
label: 'MAC',
hint: '정리된 MAC 파일 저장소를 검색합니다.',
placeholder: 'MAC 파일명을 검색하세요',
scopeText: 'MAC 저장소',
tableId: 'tableMac',
repo: 'mac'
};
case '#subpanel-guid':
return {
label: 'GUID',
hint: '정리된 GUID 파일 저장소를 검색합니다.',
placeholder: 'GUID 파일명을 검색하세요',
scopeText: 'GUID 저장소',
tableId: 'tableGuid',
repo: 'guid'
};
case '#subpanel-gpu':
return {
label: 'GPU',
hint: '정리된 GPU 파일 저장소를 검색합니다.',
placeholder: 'GPU 파일명을 검색하세요',
scopeText: 'GPU 저장소',
tableId: 'tableGpu',
repo: 'gpu'
};
default:
return {
label: '서버 정보',
hint: '백업 폴더명과 내부 파일명을 함께 검색합니다.',
placeholder: '백업 폴더명 또는 서버 정보 파일명을 검색하세요',
scopeText: '서버 정보 백업',
backup: true
};
}
}
function applyGlobalFileSearch() {
if (!globalFileSearch) return;
const query = globalFileSearch.value || '';
const context = getActiveSearchContext();
if (context.backup) {
filterBackupAccordion(query);
return;
}
if (context.tableId) {
window.filterTable(context.tableId, query);
if (context.repo) {
updateRepoSelectionState(context.repo);
}
}
}
function updateFileBrowserState() {
const context = getActiveSearchContext();
const activePanelId = document.querySelector('#repoTabs .nav-link.active')?.getAttribute('data-bs-target');
if (repoTabs) {
repoTabs.classList.toggle('d-none', context.tableId === 'tableProcessed');
}
if (activeFileModeBadge) {
activeFileModeBadge.textContent = context.label;
}
if (fileToolbarHint) {
fileToolbarHint.textContent = context.hint;
}
if (globalFileSearch) {
globalFileSearch.placeholder = context.placeholder;
}
if (fileSearchScopeText) {
fileSearchScopeText.textContent = context.scopeText || context.label;
}
kpiButtons.forEach(button => {
const kpiTarget = button.getAttribute('data-kpi-target');
const isActive = activePanelId === `#${kpiTarget?.replace('-tab', '')}` && context.tableId !== 'tableProcessed';
button.classList.toggle('is-active', Boolean(isActive));
});
applyGlobalFileSearch();
}
if (globalFileSearch) {
globalFileSearch.addEventListener('input', applyGlobalFileSearch);
}
if (clearFileSearchButton && globalFileSearch) {
clearFileSearchButton.addEventListener('click', () => {
globalFileSearch.value = '';
applyGlobalFileSearch();
globalFileSearch.focus();
});
}
if (kpiButtons.length) {
kpiButtons.forEach(button => {
button.addEventListener('click', () => {
const repositoryTab = document.getElementById('repository-tab');
const targetTabId = button.getAttribute('data-kpi-target');
const targetTab = targetTabId ? document.getElementById(targetTabId) : null;
if (repositoryTab && !repositoryTab.classList.contains('active')) {
repositoryTab.click();
}
if (targetTab) {
targetTab.click();
}
});
});
}
// 메인 탭 전환
const mainTabButtons = document.querySelectorAll('#fileTabs button[data-custom-toggle="tab"]');
mainTabButtons.forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
// 모든 탭 버튼 비활성화
mainTabButtons.forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
});
// 클릭한 탭 버튼 활성화
this.classList.add('active');
this.setAttribute('aria-selected', 'true');
// 모든 탭 패널 숨기기
document.querySelectorAll('.custom-tab-panel').forEach(panel => {
panel.classList.remove('custom-active');
panel.style.display = 'none'; // 인라인 스타일로 강제 숨김
});
// 대상 탭 패널 표시
const targetId = this.getAttribute('data-bs-target');
const targetPanel = document.querySelector(targetId);
if (targetPanel) {
targetPanel.classList.add('custom-active');
targetPanel.style.display = 'block'; // 인라인 스타일도 변경
}
updateFileBrowserState();
});
});
// 서브 탭 전환 (Pills)
const subTabButtons = document.querySelectorAll('#repoTabs button[data-custom-toggle="pill"]');
subTabButtons.forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
// 모든 서브 탭 버튼 비활성화
subTabButtons.forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
});
this.classList.add('active');
this.setAttribute('aria-selected', 'true');
// 모든 서브 탭 패널 숨기기
document.querySelectorAll('.custom-sub-panel').forEach(panel => {
panel.classList.remove('custom-sub-active');
panel.classList.add('d-none');
panel.style.display = 'none';
});
// subpanel-backup을 명시적으로 한 번 더 숨김
const backupPanel = document.getElementById('subpanel-backup');
if (backupPanel) {
backupPanel.classList.add('d-none');
backupPanel.style.setProperty('display', 'none', 'important');
}
// 대상 서브 탭 패널 표시
const targetId = this.getAttribute('data-bs-target');
const targetPanel = document.querySelector(targetId);
if (targetPanel) {
targetPanel.classList.add('custom-sub-active');
targetPanel.classList.remove('d-none');
targetPanel.classList.remove('display-none'); // 혹시 모를 클래스 제거
targetPanel.style.setProperty('display', 'block', 'important');
}
updateFileBrowserState();
});
});
updateFileBrowserState();
// ─────────────────────────────────────────────────────────────
// 8. 저장소 파일 선택 작업 (MAC/GUID/GPU)
// ─────────────────────────────────────────────────────────────
const repoCheckboxes = document.querySelectorAll('.repo-file-checkbox');
const repoSelectAllButtons = document.querySelectorAll('.repo-select-all');
const repoSelectedActionButtons = document.querySelectorAll('.repo-selected-action');
function getRepoCheckedFiles(repo) {
return Array.from(document.querySelectorAll(`.repo-file-checkbox[data-repo="${repo}"]:checked`))
.map(input => input.value)
.filter(Boolean);
}
function updateRepoSelectionState(repo) {
const checkedFiles = getRepoCheckedFiles(repo);
repoSelectedActionButtons.forEach(button => {
if (button.getAttribute('data-repo') === repo) {
button.disabled = checkedFiles.length === 0;
}
});
repoSelectAllButtons.forEach(selectAll => {
if (selectAll.getAttribute('data-repo') !== repo) return;
const tableId = selectAll.getAttribute('data-table-id');
const table = tableId ? document.getElementById(tableId) : null;
const checkboxes = table
? Array.from(table.querySelectorAll('.repo-file-checkbox'))
: [];
const visibleCheckboxes = checkboxes.filter(input => {
const row = input.closest('tr');
return row && row.style.display !== 'none';
});
const visibleChecked = visibleCheckboxes.filter(input => input.checked);
selectAll.checked = visibleCheckboxes.length > 0 && visibleChecked.length === visibleCheckboxes.length;
selectAll.indeterminate = visibleChecked.length > 0 && visibleChecked.length < visibleCheckboxes.length;
});
}
function submitRepoSelection(actionUrl, files) {
const form = document.createElement('form');
form.method = 'POST';
form.action = actionUrl;
form.className = 'd-none';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = window.APP_CONFIG?.csrfToken || '';
form.appendChild(csrfInput);
files.forEach(filename => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'files';
input.value = filename;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
repoCheckboxes.forEach(input => {
input.addEventListener('change', () => {
updateRepoSelectionState(input.getAttribute('data-repo'));
});
});
repoSelectAllButtons.forEach(selectAll => {
selectAll.addEventListener('change', () => {
const table = document.getElementById(selectAll.getAttribute('data-table-id'));
const repo = selectAll.getAttribute('data-repo');
if (!table || !repo) return;
table.querySelectorAll('.repo-file-checkbox').forEach(input => {
const row = input.closest('tr');
if (row && row.style.display !== 'none') {
input.checked = selectAll.checked;
}
});
updateRepoSelectionState(repo);
});
});
repoSelectedActionButtons.forEach(button => {
button.addEventListener('click', () => {
const repo = button.getAttribute('data-repo');
const actionUrl = button.getAttribute('data-action');
const files = getRepoCheckedFiles(repo);
const confirmMessage = button.getAttribute('data-confirm');
if (!repo || !actionUrl || files.length === 0) return;
if (confirmMessage && !confirm(confirmMessage)) return;
submitRepoSelection(actionUrl, files);
});
});
['mac', 'guid', 'gpu'].forEach(updateRepoSelectionState);
// ─────────────────────────────────────────────────────────────
// 9. 워크스페이스 업로드 중복 처리
// ─────────────────────────────────────────────────────────────
document.querySelectorAll('.workspace-file-upload-input').forEach(input => {
input.addEventListener('change', () => {
const form = input.form;
if (!form || !input.files || input.files.length === 0) return;
let existingFiles = [];
try {
existingFiles = JSON.parse(input.getAttribute('data-existing-files') || '[]');
} catch (_) {
existingFiles = [];
}
const existingSet = new Set(existingFiles.map(name => String(name).toLowerCase()));
const duplicates = Array.from(input.files)
.map(file => file.name)
.filter(name => existingSet.has(String(name).toLowerCase()));
const policyInput = form.querySelector('input[name="duplicate_policy"]');
if (policyInput) {
policyInput.value = 'rename';
}
if (duplicates.length > 0) {
const preview = duplicates.slice(0, 5).join('\n');
const extra = duplicates.length > 5 ? `\n...외 ${duplicates.length - 5}개` : '';
const overwrite = confirm(
`동일한 이름의 파일이 이미 있습니다.\n\n${preview}${extra}\n\n확인: 덮어쓰기\n취소: 다른 이름으로 저장`
);
if (policyInput) {
policyInput.value = overwrite ? 'overwrite' : 'rename';
}
}
form.submit();
});
});
// ─────────────────────────────────────────────────────────────
// 10. URL 파라미터 기반 탭 활성화 (페이지네이션 지원)
// ─────────────────────────────────────────────────────────────
const urlParams = new URLSearchParams(window.location.search);
// 'backup_page' 파라미터가 있으면 '백업 및 저장소' -> '서버 정보' 탭 활성화
if (urlParams.has('backup_page')) {
const repoTabBtn = document.getElementById('repository-tab');
const backupSubTabBtn = document.getElementById('subpanel-backup-tab');
if (repoTabBtn) {
// 메인 탭 전환 트리거
repoTabBtn.click();
}
if (backupSubTabBtn) {
// 서브 탭 전환 트리거 (메인 탭 전환 후 실행)
setTimeout(() => {
backupSubTabBtn.click();
}, 50);
}
}
const workspaceParam = urlParams.get('workspace');
if (workspaceParam) {
const repoTabBtn = document.getElementById('repository-tab');
const targetSubTabId = {
server_info: 'subpanel-backup-tab',
mac: 'subpanel-mac-tab',
guid: 'subpanel-guid-tab',
gpu: 'subpanel-gpu-tab'
}[workspaceParam];
const targetSubTabBtn = targetSubTabId ? document.getElementById(targetSubTabId) : null;
if (repoTabBtn) {
repoTabBtn.click();
}
if (targetSubTabBtn) {
setTimeout(() => {
targetSubTabBtn.click();
}, 50);
}
}
// ─────────────────────────────────────────────────────────────
// 9. 시스템 제어 패널 토글 아이콘 회전
// ─────────────────────────────────────────────────────────────
const systemControlPanel = document.getElementById('systemControlPanel');
const systemControlHint = document.getElementById('systemControlHint');
const fileToolsPanel = document.getElementById('fileToolsPanel');
const fileToolsHint = document.getElementById('fileToolsHint');
if (systemControlPanel) {
systemControlPanel.addEventListener('show.bs.collapse', () => {
const icon = document.querySelector('button[data-bs-target="#systemControlPanel"] i');
if (icon) {
icon.classList.remove('bi-chevron-down');
icon.classList.add('bi-chevron-up');
}
if (systemControlHint) {
systemControlHint.style.display = 'none';
}
});
systemControlPanel.addEventListener('hide.bs.collapse', () => {
const icon = document.querySelector('button[data-bs-target="#systemControlPanel"] i');
if (icon) {
icon.classList.remove('bi-chevron-up');
icon.classList.add('bi-chevron-down');
}
if (systemControlHint) {
systemControlHint.style.display = 'inline';
}
});
// 힌트 클릭 시 트리거
if (systemControlHint) {
systemControlHint.addEventListener('click', () => {
const btn = document.querySelector('button[data-bs-target="#systemControlPanel"]');
if (btn) btn.click();
});
}
}
if (fileToolsPanel) {
fileToolsPanel.addEventListener('show.bs.collapse', () => {
const icon = document.querySelector('button[data-bs-target="#fileToolsPanel"] i');
if (icon) {
icon.classList.remove('bi-chevron-down');
icon.classList.add('bi-chevron-up');
}
if (fileToolsHint) {
fileToolsHint.style.display = 'none';
}
});
fileToolsPanel.addEventListener('hide.bs.collapse', () => {
const icon = document.querySelector('button[data-bs-target="#fileToolsPanel"] i');
if (icon) {
icon.classList.remove('bi-chevron-up');
icon.classList.add('bi-chevron-down');
}
if (fileToolsHint) {
fileToolsHint.style.display = 'inline';
}
});
if (fileToolsHint) {
fileToolsHint.addEventListener('click', () => {
const btn = document.querySelector('button[data-bs-target="#fileToolsPanel"]');
if (btn) btn.click();
});
}
}
});