// static/js/app.js document.addEventListener('DOMContentLoaded', () => { // --- STATE & CONFIG --- let state = {}; let updateInterval = null; let lastLogId = -1; let confirmAction = null; // --- DOM SELECTORS --- const $ = (selector) => document.querySelector(selector); const $$ = (selector) => document.querySelectorAll(selector); const elements = { sidebar: $('#sidebar'), menuToggle: $('#menu-toggle'), navLinks: $$('.nav-link'), pages: $$('.page'), headerTitle: $('#header-title'), statusBadge: $('#status-badge'), sourceConn: $('#source-conn'), targetConn: $('#target-conn'), testSourceBtn: $('#test-source-btn'), testTargetBtn: $('#test-target-btn'), sourceStatusCard: $('#source-status-card'), targetStatusCard: $('#target-status-card'), dumpConfigView: $('#dump-config-view'), dumpMonitoringView: $('#dump-monitoring-view'), startDumpBtn: $('#start-dump-btn'), dumpFormat: $('#dump-format'), dumpCompression: $('#dump-compression'), schemaFilter: $('#schema-filter'), dumpFilename: $('#dump-filename'), dumpCommandPreview: $('#dump-command-preview'), restoreConfigView: $('#restore-config-view'), restoreMonitoringView: $('#restore-monitoring-view'), startRestoreBtn: $('#start-restore-btn'), serverBackupFile: $('#server-backup-file'), restoreCommandPreview: $('#restore-command-preview'), logExplorer: $('.log-explorer'), logOutput: $('#log-output'), logLevelFilters: $('#log-level-filters'), logSearch: $('#log-search'), logAutoscroll: $('#log-autoscroll-toggle'), exportLogsBtn: $('#export-logs-btn'), clearLogsBtn: $('#clear-logs-btn'), confirmModal: $('#confirm-modal'), confirmModalTitle: $('#confirm-modal-title'), confirmModalBody: $('#confirm-modal-body'), cancelConfirmBtn: $('#cancel-confirm-btn'), confirmActionBtn: $('#confirm-action-btn'), }; // --- UTILITY FUNCTIONS --- const formatBytes = (bytes, d=2) => (bytes===0)?'0 B':(k=1024,dm=d<0?0:d,sizes=['B','KB','MB','GB','TB'],i=Math.floor(Math.log(bytes)/Math.log(k)),parseFloat((bytes/Math.pow(k,i)).toFixed(dm))+' '+sizes[i]); const formatDuration = (s) => (s<0)&&(s=0), [Math.floor(s/3600),Math.floor(s%3600/60),Math.floor(s%60)].map(v=>v.toString().padStart(2,'0')).join(':'); const showToast = (type, msg) => { const icon = type === 'success' ? 'fa-check-circle' : 'fa-times-circle'; const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.innerHTML = `

${msg}

`; $('#toast-container').appendChild(toast); setTimeout(() => toast.remove(), 4000); }; const navigateTo = (targetId) => { elements.pages.forEach(p => p.classList.remove('active')); $(`#${targetId}-page`).classList.add('active'); elements.navLinks.forEach(l => l.classList.remove('active')); const link = $(`[data-target="${targetId}"]`); link.classList.add('active'); elements.headerTitle.textContent = link.querySelector('span').textContent; if (window.innerWidth <= 1024) elements.sidebar.classList.remove('open'); }; const apiFetch = async (endpoint, options) => { const response = await fetch(endpoint, options); if (!response.ok) { const err = await response.json(); throw new Error(err.detail || 'API request failed'); } return response.json(); }; // --- UI RENDERING --- const renderConnectionStatus = (card, data) => { if (data.loading) { card.innerHTML = `
Testing...
`; return; } if (!data.success) { card.innerHTML = `
Connection Failed

${data.message}

`; return; } card.innerHTML = `
Connected
Database:${data.database} PG Version:${data.version.split(' ')[1]} TSDB Version:${data.is_timescaledb ? data.timescaledb_version : 'N/A'} Tables:${data.table_count || '...'} Size:${data.database_size || '...'}
`; }; const createMonitoringView = (operation) => { const view = document.createElement('div'); view.className = 'monitoring-card'; view.innerHTML = `

${operation.charAt(0).toUpperCase() + operation.slice(1)} in Progress

Elapsed Time00:00:00
Data Processed-
Current Chunk-
0 B / 0 B
0 / 0 Chunks
`; return view; }; const updateMonitoringView = (operation, state) => { const elapsed = state.start_time ? (Date.now() / 1000) - state.start_time : 0; $(`#${operation}-elapsed-time`).textContent = formatDuration(elapsed); $(`#${operation}-current-table`).textContent = state.progress.current_table || '-'; $(`#${operation}-size-processed`).textContent = formatBytes(state.progress.size_processed_bytes); if (state.progress.manifest_loaded) { $(`#${operation}-size-tracker`).classList.remove('hidden'); $(`#${operation}-count-tracker`).classList.remove('hidden'); $(`#${operation}-size-bar`).style.width = `${state.progress.percent_complete_by_size}%`; $(`#${operation}-size-percent`).textContent = `${state.progress.percent_complete_by_size.toFixed(2)}%`; $(`#${operation}-size-subtext`).textContent = `${formatBytes(state.progress.size_processed_bytes)} / ${formatBytes(state.progress.total_size_bytes)}`; $(`#${operation}-count-bar`).style.width = `${state.progress.percent_complete_by_count}%`; $(`#${operation}-count-percent`).textContent = `${state.progress.percent_complete_by_count.toFixed(2)}%`; $(`#${operation}-count-subtext`).textContent = `${state.progress.chunks_processed} / ${state.progress.total_chunks} Chunks`; } else { $(`#${operation}-size-tracker`).classList.add('hidden'); $(`#${operation}-count-tracker`).classList.add('hidden'); } }; const renderGlobalStatus = (state) => { let statusClass = 'idle', statusText = 'Idle'; if (state.running) { statusClass = 'running'; statusText = state.operation === 'dump' ? 'Dumping' : 'Restoring'; } elements.statusBadge.className = `status-badge ${statusClass}`; elements.statusBadge.querySelector('.status-text').textContent = statusText; }; const filterLogs = () => { const level = elements.logLevelFilters.querySelector('.active').dataset.level; const search = elements.logSearch.value.toLowerCase(); $$('#log-output .log-entry').forEach(entry => { const showLevel = level === 'all' || entry.dataset.level === level; const showSearch = !search || entry.textContent.toLowerCase().includes(search); entry.style.display = (showLevel && showSearch) ? 'flex' : 'none'; }); }; const renderLogs = (logs) => { const newLogs = logs.filter(log => log.id > lastLogId); if (newLogs.length > 0) { const fragment = document.createDocumentFragment(); newLogs.forEach(log => { const entry = document.createElement('div'); entry.className = 'log-entry'; entry.dataset.level = log.level; entry.innerHTML = `${log.timestamp}${log.level.toUpperCase()}${log.message}`; fragment.appendChild(entry); }); elements.logOutput.appendChild(fragment); lastLogId = newLogs[newLogs.length - 1].id; if (elements.logAutoscroll.checked) elements.logOutput.scrollTop = elements.logOutput.scrollHeight; filterLogs(); } }; const updateCommandPreviews = () => { // Dump Preview const sourcePrev = elements.sourceConn.value ? elements.sourceConn.value.replace(/:(?!\/\/)([^@]+)@/, ':********@') : '...'; elements.dumpCommandPreview.innerHTML = `pg_dump "${sourcePrev}" -F${elements.dumpFormat.value} -v ...`; // Restore Preview const targetPrev = elements.targetConn.value ? elements.targetConn.value.replace(/:(?!\/\/)([^@]+)@/, ':********@') : '...'; const filePrev = elements.serverBackupFile.selectedOptions[0]?.text.split(' ')[0] || '...'; elements.restoreCommandPreview.innerHTML = `pg_restore -d "${targetPrev}" -v "${filePrev}" ...`; }; const restoreUiState = (state) => { if (state.running) { navigateTo(state.operation); const viewContainer = $(`#${state.operation}-monitoring-view`); viewContainer.innerHTML = ''; viewContainer.appendChild(createMonitoringView(state.operation)); $(`#${state.operation}-config-view`).classList.add('hidden'); viewContainer.classList.remove('hidden'); updateMonitoringView(state.operation, state); startPolling(); } else if (state.end_time) { // Operation finished if (state.operation === 'dump' && state.dump_completed) { const footer = $('#dump-footer'); if(footer) { footer.innerHTML = ` Download `; } } } }; // --- API & WORKFLOWS --- const testConnection = async (connStr, card, type) => { if (!connStr) return; renderConnectionStatus(card, { loading: true }); try { const data = await apiFetch('/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_string: connStr }) }); renderConnectionStatus(card, data); localStorage.setItem(`${type}_conn`, connStr); // Fetch extended info const info = await apiFetch('/database-info', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_string: connStr }) }); if (info.success) { $(`#${card.id}-tables`).textContent = info.table_count; $(`#${card.id}-size`).textContent = info.database_size; } } catch (error) { renderConnectionStatus(card, { success: false, message: error.message }); } }; const loadServerBackups = async () => { try { const data = await apiFetch('/list-dumps'); elements.serverBackupFile.innerHTML = data.dumps.length > 0 ? data.dumps.map(d => ``).join('') : ''; } catch (error) { elements.serverBackupFile.innerHTML = ''; } updateCommandPreviews(); }; const startOperation = async (operation) => { const isDump = operation === 'dump'; const config = isDump ? { source_conn: elements.sourceConn.value, options: { format: elements.dumpFormat.value, compression: elements.dumpCompression.value, schema: elements.schemaFilter.value, filename: elements.dumpFilename.value } } : { target_conn: elements.targetConn.value, dump_file: elements.serverBackupFile.value, options: { timescaledb_pre_restore: $('#timescaledb-pre-restore').checked, timescaledb_post_restore: $('#timescaledb-post-restore').checked, no_owner: $('#no-owner').checked, clean: $('#clean').checked } }; if ((isDump && !config.source_conn) || (!isDump && (!config.target_conn || !config.dump_file))) { return showToast('error', 'Please complete all required fields.'); } try { await apiFetch(`/start-${operation}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) }); showToast('success', `${operation.charAt(0).toUpperCase() + operation.slice(1)} process started.`); navigateTo(operation); restoreUiState({ running: true, operation: operation }); // Immediately switch view } catch (error) { showToast('error', `Failed to start ${operation}: ${error.message}`); } }; // --- EVENT LISTENERS --- elements.menuToggle.addEventListener('click', () => elements.sidebar.classList.toggle('open')); elements.navLinks.forEach(link => link.addEventListener('click', e => { e.preventDefault(); navigateTo(link.dataset.target); })); elements.testSourceBtn.addEventListener('click', () => testConnection(elements.sourceConn.value, elements.sourceStatusCard, 'source')); elements.testTargetBtn.addEventListener('click', () => testConnection(elements.targetConn.value, elements.targetStatusCard, 'target')); [elements.sourceConn, elements.targetConn, elements.dumpFormat, elements.serverBackupFile].forEach(el => el.addEventListener('change', updateCommandPreviews)); elements.startDumpBtn.addEventListener('click', () => { elements.confirmModalTitle.textContent = 'Confirm Dump'; elements.confirmModalBody.textContent = 'Are you sure you want to start the dump process?'; confirmAction = () => startOperation('dump'); elements.confirmModal.classList.remove('hidden'); }); elements.startRestoreBtn.addEventListener('click', () => { elements.confirmModalTitle.textContent = 'Confirm Restore'; elements.confirmModalBody.textContent = 'WARNING: This may overwrite data on the target database. Are you sure you want to proceed?'; confirmAction = () => startOperation('restore'); elements.confirmModal.classList.remove('hidden'); }); document.body.addEventListener('click', e => { if (e.target.matches('#stop-dump-btn') || e.target.matches('#stop-restore-btn')) { elements.confirmModalTitle.textContent = 'Confirm Stop'; elements.confirmModalBody.textContent = 'Are you sure you want to stop the current operation?'; confirmAction = async () => { try { await apiFetch('/stop-process', { method: 'POST' }); showToast('success', 'Stop command sent.'); } catch (error) { showToast('error', `Failed to stop: ${error.message}`); } }; elements.confirmModal.classList.remove('hidden'); } if (e.target.matches('#goto-restore-btn')) { const lastFile = state.dump_file; if (lastFile) { const option = Array.from(elements.serverBackupFile.options).find(opt => opt.value === lastFile); if (option) option.selected = true; } navigateTo('restore'); updateCommandPreviews(); } }); elements.cancelConfirmBtn.addEventListener('click', () => elements.confirmModal.classList.add('hidden')); elements.confirmActionBtn.addEventListener('click', () => { if (confirmAction) confirmAction(); elements.confirmModal.classList.add('hidden'); }); elements.logLevelFilters.addEventListener('click', e => { if (e.target.tagName === 'BUTTON') { elements.logLevelFilters.querySelector('.active').classList.remove('active'); e.target.classList.add('active'); filterLogs(); } }); elements.logSearch.addEventListener('input', filterLogs); elements.exportLogsBtn.addEventListener('click', () => { const content = Array.from($$('#log-output .log-entry')).map(e => e.textContent.trim()).join('\n'); const blob = new Blob([content], { type: 'text/plain' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `migrator-logs-${new Date().toISOString()}.txt`; a.click(); }); elements.clearLogsBtn.addEventListener('click', () => { elements.logOutput.innerHTML = ''; lastLogId = -1; }); // --- INITIALIZATION --- const initialize = async () => { elements.sourceConn.value = localStorage.getItem('source_conn') || ''; elements.targetConn.value = localStorage.getItem('target_conn') || ''; updateCommandPreviews(); await loadServerBackups(); try { state = await apiFetch('/status'); renderGlobalStatus(state); renderLogs(state.log); restoreUiState(state); } catch (e) { showToast('error', 'Could not connect to backend.'); } }; initialize(); });