Spaces:
Sleeping
Sleeping
| // 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 = `<i class="fas ${icon}"></i> <p>${msg}</p>`; | |
| $('#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 = `<div class="status-header"><i class="fas fa-spinner fa-spin"></i> Testing...</div>`; | |
| return; | |
| } | |
| if (!data.success) { | |
| card.innerHTML = `<div class="status-header error"><i class="fas fa-times-circle"></i> Connection Failed</div><p>${data.message}</p>`; | |
| return; | |
| } | |
| card.innerHTML = ` | |
| <div class="status-header success"><i class="fas fa-check-circle"></i> Connected</div> | |
| <div class="details-grid"> | |
| <strong>Database:</strong><span>${data.database}</span> | |
| <strong>PG Version:</strong><span>${data.version.split(' ')[1]}</span> | |
| <strong>TSDB Version:</strong><span>${data.is_timescaledb ? data.timescaledb_version : 'N/A'}</span> | |
| <strong>Tables:</strong><span id="${card.id}-tables">${data.table_count || '...'}</span> | |
| <strong>Size:</strong><span id="${card.id}-size">${data.database_size || '...'}</span> | |
| </div> | |
| `; | |
| }; | |
| const createMonitoringView = (operation) => { | |
| const view = document.createElement('div'); | |
| view.className = 'monitoring-card'; | |
| view.innerHTML = ` | |
| <div class="monitoring-header"> | |
| <h3><i class="fas fa-sync fa-spin"></i> ${operation.charAt(0).toUpperCase() + operation.slice(1)} in Progress</h3> | |
| <button id="stop-${operation}-btn" class="btn btn-danger"><i class="fas fa-stop"></i> Stop</button> | |
| </div> | |
| <div class="monitoring-stats"> | |
| <div><span class="stat-label">Elapsed Time</span><span class="stat-value" id="${operation}-elapsed-time">00:00:00</span></div> | |
| <div><span class="stat-label">Data Processed</span><span class="stat-value" id="${operation}-size-processed">-</span></div> | |
| <div><span class="stat-label">Current Chunk</span><span class="stat-value" id="${operation}-current-table">-</span></div> | |
| </div> | |
| <div class="progress-tracker" id="${operation}-size-tracker"> | |
| <label>Data Size Progress (<span id="${operation}-size-percent">0%</span>)</label> | |
| <div class="progress-bar-container"><div id="${operation}-size-bar" class="progress-bar"></div></div> | |
| <span class="progress-subtext" id="${operation}-size-subtext">0 B / 0 B</span> | |
| </div> | |
| <div class="progress-tracker" id="${operation}-count-tracker"> | |
| <label>Chunk Count Progress (<span id="${operation}-count-percent">0%</span>)</label> | |
| <div class="progress-bar-container"><div class="progress-bar secondary" id="${operation}-count-bar"></div></div> | |
| <span class="progress-subtext" id="${operation}-count-subtext">0 / 0 Chunks</span> | |
| </div> | |
| <div class="monitoring-footer" id="${operation}-footer"></div> | |
| `; | |
| 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 = `<span class="timestamp">${log.timestamp}</span><span class="level level-${log.level}">${log.level.toUpperCase()}</span><span class="message">${log.message}</span>`; | |
| 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 = `<span class="keyword">pg_dump</span> <span class="value">"${sourcePrev}"</span> <span class="flag">-F${elements.dumpFormat.value}</span> <span class="flag">-v</span> ...`; | |
| // Restore Preview | |
| const targetPrev = elements.targetConn.value ? elements.targetConn.value.replace(/:(?!\/\/)([^@]+)@/, ':********@') : '...'; | |
| const filePrev = elements.serverBackupFile.selectedOptions[0]?.text.split(' ')[0] || '...'; | |
| elements.restoreCommandPreview.innerHTML = `<span class="keyword">pg_restore</span> <span class="flag">-d</span> <span class="value">"${targetPrev}"</span> <span class="flag">-v</span> <span class="value">"${filePrev}"</span> ...`; | |
| }; | |
| 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 = ` | |
| <a href="/downloads/${state.dump_file.split(/[\\/]/).pop()}" class="btn btn-secondary" target="_blank"><i class="fas fa-download"></i> Download</a> | |
| <button id="goto-restore-btn" class="btn btn-primary"><i class="fas fa-upload"></i> Go to Restore</button> | |
| `; | |
| } | |
| } | |
| } | |
| }; | |
| // --- 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 => `<option value="${d.path}">${d.name} (${d.size_mb.toFixed(2)} MB)</option>`).join('') | |
| : '<option value="" disabled>No backups found</option>'; | |
| } catch (error) { | |
| elements.serverBackupFile.innerHTML = '<option value="" disabled>Error loading backups</option>'; | |
| } | |
| 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(); | |
| }); |