arcticaurora commited on
Commit
9a24b3e
·
verified ·
1 Parent(s): 6639025

Update static/js/app.js

Browse files
Files changed (1) hide show
  1. static/js/app.js +273 -364
static/js/app.js CHANGED
@@ -1,218 +1,152 @@
1
  // static/js/app.js
2
-
3
  document.addEventListener('DOMContentLoaded', () => {
4
- // --- STATE ---
5
- let migrationState = {};
6
  let updateInterval = null;
7
  let lastLogId = -1;
8
  let confirmAction = null;
9
 
10
- // --- SELECTORS ---
11
- const selectors = {
12
- // Navigation
13
- navLinks: document.querySelectorAll('.nav-link'),
14
- pages: document.querySelectorAll('.page'),
15
- headerTitle: document.getElementById('header-title'),
16
- // Status
17
- statusBadge: document.getElementById('status-badge'),
18
- statusDot: document.querySelector('#status-badge .status-dot'),
19
- statusText: document.querySelector('#status-badge .status-text'),
20
- // Dashboard
21
- dashboardStatusText: document.getElementById('dashboard-status-text'),
22
- dashboardGotoDump: document.getElementById('dashboard-goto-dump'),
23
- dashboardGotoRestore: document.getElementById('dashboard-goto-restore'),
24
- lastOpSummary: document.getElementById('last-operation-summary'),
25
- miniLogFeed: document.getElementById('mini-log-feed'),
26
- // Connections
27
- sourceConnInput: document.getElementById('source-conn'),
28
- targetConnInput: document.getElementById('target-conn'),
29
- toggleSourceVisibility: document.getElementById('toggle-source-visibility'),
30
- toggleTargetVisibility: document.getElementById('toggle-target-visibility'),
31
- testSourceBtn: document.getElementById('test-source-btn'),
32
- testTargetBtn: document.getElementById('test-target-btn'),
33
- sourceStatusDetails: document.getElementById('source-status-details'),
34
- targetStatusDetails: document.getElementById('target-status-details'),
35
- // Dump
36
- dumpConfigView: document.getElementById('dump-config-view'),
37
- dumpProgressView: document.getElementById('dump-progress-view'),
38
- startDumpBtn: document.getElementById('start-dump-btn'),
39
- stopDumpBtn: document.getElementById('stop-dump-btn'),
40
- dumpFormat: document.getElementById('dump-format'),
41
- dumpCompression: document.getElementById('dump-compression'),
42
- schemaFilter: document.getElementById('schema-filter'),
43
- dumpFilename: document.getElementById('dump-filename'),
44
- dumpElapsedTime: document.getElementById('dump-elapsed-time'),
45
- dumpFileSize: document.getElementById('dump-file-size'),
46
- dumpCurrentTable: document.getElementById('dump-current-table'),
47
- dumpSizeProgressBar: document.getElementById('dump-size-progress-bar'),
48
- dumpSizeProgressText: document.getElementById('dump-size-progress-text'),
49
- dumpSizeSubtext: document.getElementById('dump-size-subtext'),
50
- dumpCountProgressBar: document.getElementById('dump-count-progress-bar'),
51
- dumpCountProgressText: document.getElementById('dump-count-progress-text'),
52
- dumpCountSubtext: document.getElementById('dump-count-subtext'),
53
- // Restore
54
- restoreConfigView: document.getElementById('restore-config-view'),
55
- restoreProgressView: document.getElementById('restore-progress-view'),
56
- startRestoreBtn: document.getElementById('start-restore-btn'),
57
- stopRestoreBtn: document.getElementById('stop-restore-btn'),
58
- serverBackupFile: document.getElementById('server-backup-file'),
59
- restoreElapsedTime: document.getElementById('restore-elapsed-time'),
60
- restoreTablesCompleted: document.getElementById('restore-tables-completed'),
61
- restoreCurrentTable: document.getElementById('restore-current-table'),
62
- // Logs
63
- logOutput: document.getElementById('log-output'),
64
- logAutoscrollToggle: document.getElementById('log-autoscroll-toggle'),
65
- // Modal
66
- confirmModal: document.getElementById('confirm-modal'),
67
- confirmModalTitle: document.getElementById('confirm-modal-title'),
68
- confirmModalBody: document.getElementById('confirm-modal-body'),
69
- closeConfirmModal: document.getElementById('close-confirm-modal'),
70
- cancelConfirmBtn: document.getElementById('cancel-confirm-btn'),
71
- confirmActionBtn: document.getElementById('confirm-action-btn'),
72
  };
73
 
74
  // --- UTILITY FUNCTIONS ---
75
- const formatBytes = (bytes, decimals = 2) => {
76
- if (bytes === 0) return '0 B';
77
- const k = 1024;
78
- const dm = decimals < 0 ? 0 : decimals;
79
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
80
- const i = Math.floor(Math.log(bytes) / Math.log(k));
81
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
82
- };
83
-
84
- const formatDuration = (seconds) => {
85
- if (seconds < 0) seconds = 0;
86
- const h = Math.floor(seconds / 3600).toString().padStart(2, '0');
87
- const m = Math.floor((seconds % 3600) / 60).toString().padStart(2, '0');
88
- const s = Math.floor(seconds % 60).toString().padStart(2, '0');
89
- return `${h}:${m}:${s}`;
90
- };
91
-
92
- const showToast = (type, title, message) => {
93
- const container = document.getElementById('toast-container');
94
  const toast = document.createElement('div');
95
  toast.className = `toast ${type}`;
96
- const icons = { success: 'fa-check-circle', error: 'fa-times-circle' };
97
- toast.innerHTML = `
98
- <i class="fas ${icons[type]} toast-icon"></i>
99
- <div class="toast-body">
100
- <h4>${title}</h4>
101
- <p>${message}</p>
102
- </div>
103
- `;
104
- container.appendChild(toast);
105
- setTimeout(() => toast.remove(), 5000);
106
  };
107
-
108
  const navigateTo = (targetId) => {
109
- selectors.pages.forEach(p => p.classList.remove('active'));
110
- document.getElementById(`${targetId}-page`).classList.add('active');
111
- selectors.navLinks.forEach(l => l.classList.remove('active'));
112
- const activeLink = document.querySelector(`.nav-link[data-target="${targetId}"]`);
113
- activeLink.classList.add('active');
114
- selectors.headerTitle.textContent = activeLink.querySelector('span').textContent;
 
115
  };
116
-
117
- // --- UI RENDERING ---
118
- const renderUI = (state) => {
119
- migrationState = state;
120
-
121
- // Global Status Badge
122
- if (state.running) {
123
- const operationText = state.operation === 'dump' ? 'Dumping' : 'Restoring';
124
- selectors.statusBadge.className = 'status-badge running';
125
- selectors.statusText.textContent = operationText;
126
- } else if (state.dump_completed || state.restore_completed) {
127
- selectors.statusBadge.className = 'status-badge success';
128
- selectors.statusText.textContent = 'Completed';
129
- } else if (state.end_time && !state.dump_completed && !state.restore_completed) {
130
- selectors.statusBadge.className = 'status-badge error';
131
- selectors.statusText.textContent = 'Failed/Stopped';
132
- } else {
133
- selectors.statusBadge.className = 'status-badge idle';
134
- selectors.statusText.textContent = 'Idle';
135
  }
 
 
136
 
137
- // Dashboard
138
- renderDashboard(state);
139
-
140
- // Dump Page
141
- if (state.operation === 'dump') {
142
- selectors.dumpConfigView.classList.toggle('hidden', state.running);
143
- selectors.dumpProgressView.classList.toggle('hidden', !state.running);
144
- if (state.running) {
145
- renderDumpProgress(state);
146
- }
147
- } else { // If not dumping, show config
148
- selectors.dumpConfigView.classList.remove('hidden');
149
- selectors.dumpProgressView.classList.add('hidden');
150
  }
151
-
152
- // Restore Page
153
- if (state.operation === 'restore') {
154
- selectors.restoreConfigView.classList.toggle('hidden', state.running);
155
- selectors.restoreProgressView.classList.toggle('hidden', !state.running);
156
- if(state.running) {
157
- renderRestoreProgress(state);
158
- }
159
- } else {
160
- selectors.restoreConfigView.classList.remove('hidden');
161
- selectors.restoreProgressView.classList.add('hidden');
162
  }
163
-
164
- // Logs
165
- renderLogs(state.log);
 
 
 
 
 
 
 
166
  };
167
 
168
- const renderDashboard = (state) => {
169
- if (state.running) {
170
- const opText = state.operation === 'dump' ? 'A database dump' : 'A database restore';
171
- selectors.dashboardStatusText.textContent = `${opText} is currently in progress.`;
172
- } else {
173
- selectors.dashboardStatusText.textContent = 'The system is currently idle and ready for a new operation.';
174
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
176
- if (state.end_time) {
177
- const success = state.dump_completed || state.restore_completed;
178
- const duration = formatDuration(state.end_time - state.start_time);
179
- const op = state.operation === 'dump' ? 'Dump' : 'Restore';
180
- selectors.lastOpSummary.innerHTML = `
181
- <p class="op-status ${success ? 'success' : 'error'}">
182
- <i class="fas ${success ? 'fa-check-circle' : 'fa-times-circle'}"></i>
183
- ${op} ${success ? 'Completed' : 'Failed/Stopped'}
184
- </p>
185
- <div class="op-details">
186
- <span>Duration:</span><strong>${duration}</strong>
187
- ${state.operation === 'dump' ? `<span>File Size:</span><strong>${formatBytes(state.dump_file_size * 1024 * 1024)}</strong>` : ''}
188
- </div>
189
- `;
 
190
  } else {
191
- selectors.lastOpSummary.innerHTML = `<p class="text-muted">No operations have been run yet.</p>`;
 
192
  }
193
  };
194
 
195
- const renderDumpProgress = (state) => {
196
- const elapsed = state.start_time ? (Date.now() / 1000) - state.start_time : 0;
197
- selectors.dumpElapsedTime.textContent = formatDuration(elapsed);
198
- selectors.dumpFileSize.textContent = formatBytes(state.dump_file_size * 1024 * 1024);
199
- selectors.dumpCurrentTable.textContent = state.progress.current_table || '-';
200
-
201
- const { progress } = state;
202
- selectors.dumpSizeProgressBar.style.width = `${progress.percent_complete_by_size}%`;
203
- selectors.dumpSizeProgressText.textContent = `${progress.percent_complete_by_size.toFixed(2)}%`;
204
- selectors.dumpSizeSubtext.textContent = `${formatBytes(progress.size_processed_bytes)} / ${formatBytes(progress.total_size_bytes)}`;
205
-
206
- selectors.dumpCountProgressBar.style.width = `${progress.percent_complete_by_count}%`;
207
- selectors.dumpCountProgressText.textContent = `${progress.percent_complete_by_count.toFixed(2)}%`;
208
- selectors.dumpCountSubtext.textContent = `${progress.chunks_processed} / ${progress.total_chunks} Chunks`;
209
  };
210
 
211
- const renderRestoreProgress = (state) => {
212
- const elapsed = state.start_time ? (Date.now() / 1000) - state.start_time : 0;
213
- selectors.restoreElapsedTime.textContent = formatDuration(elapsed);
214
- selectors.restoreTablesCompleted.textContent = state.progress.tables_completed;
215
- selectors.restoreCurrentTable.textContent = state.progress.current_table || '-';
 
 
 
216
  };
217
 
218
  const renderLogs = (logs) => {
@@ -222,225 +156,200 @@ document.addEventListener('DOMContentLoaded', () => {
222
  newLogs.forEach(log => {
223
  const entry = document.createElement('div');
224
  entry.className = 'log-entry';
225
- entry.innerHTML = `
226
- <span class="timestamp">${log.timestamp}</span>
227
- <span class="level level-${log.level}">${log.level.toUpperCase()}</span>
228
- <span class="message">${log.message}</span>
229
- `;
230
  fragment.appendChild(entry);
231
  });
232
- selectors.logOutput.appendChild(fragment);
233
  lastLogId = newLogs[newLogs.length - 1].id;
234
-
235
- if (selectors.logAutoscrollToggle.checked) {
236
- selectors.logOutput.scrollTop = selectors.logOutput.scrollHeight;
237
- }
238
  }
239
- // Update mini log feed on dashboard
240
- const recentLogs = logs.slice(-5).reverse();
241
- selectors.miniLogFeed.innerHTML = recentLogs.map(log => `
242
- <div class="mini-log-entry">
243
- <span class="timestamp">${log.timestamp.split(' ')[1]}</span>
244
- <span class="level level-${log.level}">${log.level.toUpperCase()}</span>
245
- <span class="message">${log.message}</span>
246
- </div>
247
- `).join('');
248
  };
249
 
250
- // --- API CALLS ---
251
- const updateStatus = async () => {
252
- try {
253
- const response = await fetch('/status');
254
- if (!response.ok) throw new Error('Network response was not ok');
255
- const state = await response.json();
256
- renderUI(state);
257
- // If process is finished, stop polling
258
- if (!state.running && updateInterval) {
259
- clearInterval(updateInterval);
260
- updateInterval = null;
261
- }
262
- } catch (error) {
263
- console.error('Failed to fetch status:', error);
264
- if (updateInterval) {
265
- clearInterval(updateInterval);
266
- updateInterval = null;
267
- }
268
- // You might want to show a "disconnected" message here
269
- }
270
  };
271
 
272
- const startPolling = () => {
273
- if (!updateInterval) {
274
- updateStatus(); // Initial call
275
- updateInterval = setInterval(updateStatus, 1500);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  }
277
  };
278
 
279
- const testConnection = async (connString, detailsElement) => {
280
- detailsElement.innerHTML = `<p><i class="fas fa-spinner fa-spin"></i> Testing connection...</p>`;
 
 
281
  try {
282
- const response = await fetch('/test-connection', {
283
- method: 'POST',
284
- headers: { 'Content-Type': 'application/json' },
285
- body: JSON.stringify({ connection_string: connString }),
 
 
 
 
 
 
286
  });
287
- const data = await response.json();
288
- if (data.success) {
289
- detailsElement.innerHTML = `
290
- <p class="success"><i class="fas fa-check-circle"></i> Connection successful!</p>
291
- <p><strong>DB:</strong> ${data.database}</p>
292
- <p><strong>Version:</strong> ${data.is_timescaledb ? `TimescaleDB ${data.timescaledb_version}` : 'PostgreSQL'}</p>
293
- `;
294
- } else {
295
- detailsElement.innerHTML = `<p class="error"><i class="fas fa-times-circle"></i> ${data.message}</p>`;
296
  }
297
  } catch (error) {
298
- detailsElement.innerHTML = `<p class="error"><i class="fas fa-exclamation-triangle"></i> Request failed. Is the server running?</p>`;
299
  }
300
  };
301
 
302
  const loadServerBackups = async () => {
303
  try {
304
- const response = await fetch('/list-dumps');
305
- const data = await response.json();
306
- selectors.serverBackupFile.innerHTML = '';
307
- if (data.dumps && data.dumps.length > 0) {
308
- data.dumps.forEach(d => {
309
- const option = document.createElement('option');
310
- option.value = d.path;
311
- option.textContent = `${d.name} (${d.size_mb.toFixed(2)} MB)`;
312
- selectors.serverBackupFile.appendChild(option);
313
- });
314
- } else {
315
- selectors.serverBackupFile.innerHTML = '<option value="" disabled>No backups found</option>';
316
- }
317
  } catch (error) {
318
- console.error('Failed to load backups:', error);
319
- selectors.serverBackupFile.innerHTML = '<option value="" disabled>Error loading backups</option>';
320
  }
 
321
  };
322
 
323
- // --- EVENT LISTENERS ---
324
- selectors.navLinks.forEach(link => {
325
- link.addEventListener('click', (e) => {
326
- e.preventDefault();
327
- navigateTo(link.dataset.target);
328
- });
329
- });
330
-
331
- selectors.dashboardGotoDump.addEventListener('click', () => navigateTo('dump'));
332
- selectors.dashboardGotoRestore.addEventListener('click', () => navigateTo('restore'));
333
-
334
- selectors.toggleSourceVisibility.addEventListener('click', () => {
335
- const input = selectors.sourceConnInput;
336
- input.type = input.type === 'password' ? 'text' : 'password';
337
- });
338
- selectors.toggleTargetVisibility.addEventListener('click', () => {
339
- const input = selectors.targetConnInput;
340
- input.type = input.type === 'password' ? 'text' : 'password';
341
- });
342
-
343
- selectors.testSourceBtn.addEventListener('click', () => testConnection(selectors.sourceConnInput.value, selectors.sourceStatusDetails));
344
- selectors.testTargetBtn.addEventListener('click', () => testConnection(selectors.targetConnInput.value, selectors.targetStatusDetails));
345
-
346
- selectors.startDumpBtn.addEventListener('click', async () => {
347
- if (!selectors.sourceConnInput.value) {
348
- return showToast('error', 'Missing Input', 'Source connection string is required.');
349
  }
 
350
  try {
351
- const response = await fetch('/start-dump', {
352
- method: 'POST',
353
- headers: { 'Content-Type': 'application/json' },
354
- body: JSON.stringify({
355
- source_conn: selectors.sourceConnInput.value,
356
- options: {
357
- format: selectors.dumpFormat.value,
358
- compression: selectors.dumpCompression.value,
359
- schema: selectors.schemaFilter.value,
360
- filename: selectors.dumpFilename.value,
361
- }
362
- })
363
  });
364
- const data = await response.json();
365
- if (data.success) {
366
- showToast('success', 'Dump Started', 'The database dump is now in progress.');
367
- startPolling();
368
- } else {
369
- showToast('error', 'Failed to Start', data.detail || 'An unknown error occurred.');
370
- }
371
  } catch (error) {
372
- showToast('error', 'Request Failed', 'Could not start the dump process.');
373
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  });
375
 
376
- selectors.startRestoreBtn.addEventListener('click', async () => {
377
- if (!selectors.targetConnInput.value || !selectors.serverBackupFile.value) {
378
- return showToast('error', 'Missing Input', 'Target connection and a backup file are required.');
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  }
380
- try {
381
- const response = await fetch('/start-restore', {
382
- method: 'POST',
383
- headers: { 'Content-Type': 'application/json' },
384
- body: JSON.stringify({
385
- target_conn: selectors.targetConnInput.value,
386
- dump_file: selectors.serverBackupFile.value,
387
- options: {
388
- timescaledb_pre_restore: document.getElementById('timescaledb-pre-restore').checked,
389
- timescaledb_post_restore: document.getElementById('timescaledb-post-restore').checked,
390
- no_owner: document.getElementById('no-owner').checked,
391
- clean: document.getElementById('clean').checked,
392
- }
393
- })
394
- });
395
- const data = await response.json();
396
- if (data.success) {
397
- showToast('success', 'Restore Started', 'The database restore is now in progress.');
398
- startPolling();
399
- } else {
400
- showToast('error', 'Failed to Start', data.detail || 'An unknown error occurred.');
401
  }
402
- } catch (error) {
403
- showToast('error', 'Request Failed', 'Could not start the restore process.');
404
  }
405
  });
406
 
407
- const openConfirmModal = (action, title, body) => {
408
- confirmAction = action;
409
- selectors.confirmModalTitle.textContent = title;
410
- selectors.confirmModalBody.textContent = body;
411
- selectors.confirmModal.classList.remove('hidden');
412
- };
413
 
414
- const stopProcess = async () => {
415
- try {
416
- await fetch('/stop-process', { method: 'POST' });
417
- showToast('success', 'Stop Initiated', 'Attempting to stop the current operation.');
418
- // Polling will handle the UI update once the process is confirmed stopped.
419
- } catch (error) {
420
- showToast('error', 'Request Failed', 'Could not send stop command.');
421
  }
422
- };
423
-
424
- selectors.stopDumpBtn.addEventListener('click', () => {
425
- openConfirmModal('stop', 'Stop Dump?', 'Are you sure you want to stop the dump? The backup file will be incomplete.');
426
  });
427
- selectors.stopRestoreBtn.addEventListener('click', () => {
428
- openConfirmModal('stop', 'Stop Restore?', 'Are you sure? Stopping a restore may leave the target database in an inconsistent state.');
 
 
 
 
 
 
429
  });
430
-
431
- selectors.closeConfirmModal.addEventListener('click', () => selectors.confirmModal.classList.add('hidden'));
432
- selectors.cancelConfirmBtn.addEventListener('click', () => selectors.confirmModal.classList.add('hidden'));
433
- selectors.confirmActionBtn.addEventListener('click', () => {
434
- if (confirmAction === 'stop') {
435
- stopProcess();
436
- }
437
- selectors.confirmModal.classList.add('hidden');
438
  });
439
 
440
  // --- INITIALIZATION ---
441
- const initialize = () => {
442
- startPolling();
443
- loadServerBackups();
 
 
 
 
 
 
 
 
 
 
444
  };
445
 
446
  initialize();
 
1
  // static/js/app.js
 
2
  document.addEventListener('DOMContentLoaded', () => {
3
+ // --- STATE & CONFIG ---
4
+ let state = {};
5
  let updateInterval = null;
6
  let lastLogId = -1;
7
  let confirmAction = null;
8
 
9
+ // --- DOM SELECTORS ---
10
+ const $ = (selector) => document.querySelector(selector);
11
+ const $$ = (selector) => document.querySelectorAll(selector);
12
+ const elements = {
13
+ sidebar: $('#sidebar'), menuToggle: $('#menu-toggle'), navLinks: $$('.nav-link'),
14
+ pages: $$('.page'), headerTitle: $('#header-title'), statusBadge: $('#status-badge'),
15
+ sourceConn: $('#source-conn'), targetConn: $('#target-conn'), testSourceBtn: $('#test-source-btn'),
16
+ testTargetBtn: $('#test-target-btn'), sourceStatusCard: $('#source-status-card'),
17
+ targetStatusCard: $('#target-status-card'), dumpConfigView: $('#dump-config-view'),
18
+ dumpMonitoringView: $('#dump-monitoring-view'), startDumpBtn: $('#start-dump-btn'),
19
+ dumpFormat: $('#dump-format'), dumpCompression: $('#dump-compression'),
20
+ schemaFilter: $('#schema-filter'), dumpFilename: $('#dump-filename'),
21
+ dumpCommandPreview: $('#dump-command-preview'), restoreConfigView: $('#restore-config-view'),
22
+ restoreMonitoringView: $('#restore-monitoring-view'), startRestoreBtn: $('#start-restore-btn'),
23
+ serverBackupFile: $('#server-backup-file'), restoreCommandPreview: $('#restore-command-preview'),
24
+ logExplorer: $('.log-explorer'), logOutput: $('#log-output'), logLevelFilters: $('#log-level-filters'),
25
+ logSearch: $('#log-search'), logAutoscroll: $('#log-autoscroll-toggle'),
26
+ exportLogsBtn: $('#export-logs-btn'), clearLogsBtn: $('#clear-logs-btn'),
27
+ confirmModal: $('#confirm-modal'), confirmModalTitle: $('#confirm-modal-title'),
28
+ confirmModalBody: $('#confirm-modal-body'), cancelConfirmBtn: $('#cancel-confirm-btn'),
29
+ confirmActionBtn: $('#confirm-action-btn'),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  };
31
 
32
  // --- UTILITY FUNCTIONS ---
33
+ 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]);
34
+ 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(':');
35
+ const showToast = (type, msg) => {
36
+ const icon = type === 'success' ? 'fa-check-circle' : 'fa-times-circle';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  const toast = document.createElement('div');
38
  toast.className = `toast ${type}`;
39
+ toast.innerHTML = `<i class="fas ${icon}"></i> <p>${msg}</p>`;
40
+ $('#toast-container').appendChild(toast);
41
+ setTimeout(() => toast.remove(), 4000);
 
 
 
 
 
 
 
42
  };
 
43
  const navigateTo = (targetId) => {
44
+ elements.pages.forEach(p => p.classList.remove('active'));
45
+ $(`#${targetId}-page`).classList.add('active');
46
+ elements.navLinks.forEach(l => l.classList.remove('active'));
47
+ const link = $(`[data-target="${targetId}"]`);
48
+ link.classList.add('active');
49
+ elements.headerTitle.textContent = link.querySelector('span').textContent;
50
+ if (window.innerWidth <= 1024) elements.sidebar.classList.remove('open');
51
  };
52
+ const apiFetch = async (endpoint, options) => {
53
+ const response = await fetch(endpoint, options);
54
+ if (!response.ok) {
55
+ const err = await response.json();
56
+ throw new Error(err.detail || 'API request failed');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  }
58
+ return response.json();
59
+ };
60
 
61
+ // --- UI RENDERING ---
62
+ const renderConnectionStatus = (card, data) => {
63
+ if (data.loading) {
64
+ card.innerHTML = `<div class="status-header"><i class="fas fa-spinner fa-spin"></i> Testing...</div>`;
65
+ return;
 
 
 
 
 
 
 
 
66
  }
67
+ if (!data.success) {
68
+ card.innerHTML = `<div class="status-header error"><i class="fas fa-times-circle"></i> Connection Failed</div><p>${data.message}</p>`;
69
+ return;
 
 
 
 
 
 
 
 
70
  }
71
+ card.innerHTML = `
72
+ <div class="status-header success"><i class="fas fa-check-circle"></i> Connected</div>
73
+ <div class="details-grid">
74
+ <strong>Database:</strong><span>${data.database}</span>
75
+ <strong>PG Version:</strong><span>${data.version.split(' ')[1]}</span>
76
+ <strong>TSDB Version:</strong><span>${data.is_timescaledb ? data.timescaledb_version : 'N/A'}</span>
77
+ <strong>Tables:</strong><span id="${card.id}-tables">${data.table_count || '...'}</span>
78
+ <strong>Size:</strong><span id="${card.id}-size">${data.database_size || '...'}</span>
79
+ </div>
80
+ `;
81
  };
82
 
83
+ const createMonitoringView = (operation) => {
84
+ const view = document.createElement('div');
85
+ view.className = 'monitoring-card';
86
+ view.innerHTML = `
87
+ <div class="monitoring-header">
88
+ <h3><i class="fas fa-sync fa-spin"></i> ${operation.charAt(0).toUpperCase() + operation.slice(1)} in Progress</h3>
89
+ <button id="stop-${operation}-btn" class="btn btn-danger"><i class="fas fa-stop"></i> Stop</button>
90
+ </div>
91
+ <div class="monitoring-stats">
92
+ <div><span class="stat-label">Elapsed Time</span><span class="stat-value" id="${operation}-elapsed-time">00:00:00</span></div>
93
+ <div><span class="stat-label">Data Processed</span><span class="stat-value" id="${operation}-size-processed">-</span></div>
94
+ <div><span class="stat-label">Current Chunk</span><span class="stat-value" id="${operation}-current-table">-</span></div>
95
+ </div>
96
+ <div class="progress-tracker" id="${operation}-size-tracker">
97
+ <label>Data Size Progress (<span id="${operation}-size-percent">0%</span>)</label>
98
+ <div class="progress-bar-container"><div id="${operation}-size-bar" class="progress-bar"></div></div>
99
+ <span class="progress-subtext" id="${operation}-size-subtext">0 B / 0 B</span>
100
+ </div>
101
+ <div class="progress-tracker" id="${operation}-count-tracker">
102
+ <label>Chunk Count Progress (<span id="${operation}-count-percent">0%</span>)</label>
103
+ <div class="progress-bar-container"><div class="progress-bar secondary" id="${operation}-count-bar"></div></div>
104
+ <span class="progress-subtext" id="${operation}-count-subtext">0 / 0 Chunks</span>
105
+ </div>
106
+ <div class="monitoring-footer" id="${operation}-footer"></div>
107
+ `;
108
+ return view;
109
+ };
110
 
111
+ const updateMonitoringView = (operation, state) => {
112
+ const elapsed = state.start_time ? (Date.now() / 1000) - state.start_time : 0;
113
+ $(`#${operation}-elapsed-time`).textContent = formatDuration(elapsed);
114
+ $(`#${operation}-current-table`).textContent = state.progress.current_table || '-';
115
+ $(`#${operation}-size-processed`).textContent = formatBytes(state.progress.size_processed_bytes);
116
+
117
+ if (state.progress.manifest_loaded) {
118
+ $(`#${operation}-size-tracker`).classList.remove('hidden');
119
+ $(`#${operation}-count-tracker`).classList.remove('hidden');
120
+ $(`#${operation}-size-bar`).style.width = `${state.progress.percent_complete_by_size}%`;
121
+ $(`#${operation}-size-percent`).textContent = `${state.progress.percent_complete_by_size.toFixed(2)}%`;
122
+ $(`#${operation}-size-subtext`).textContent = `${formatBytes(state.progress.size_processed_bytes)} / ${formatBytes(state.progress.total_size_bytes)}`;
123
+ $(`#${operation}-count-bar`).style.width = `${state.progress.percent_complete_by_count}%`;
124
+ $(`#${operation}-count-percent`).textContent = `${state.progress.percent_complete_by_count.toFixed(2)}%`;
125
+ $(`#${operation}-count-subtext`).textContent = `${state.progress.chunks_processed} / ${state.progress.total_chunks} Chunks`;
126
  } else {
127
+ $(`#${operation}-size-tracker`).classList.add('hidden');
128
+ $(`#${operation}-count-tracker`).classList.add('hidden');
129
  }
130
  };
131
 
132
+ const renderGlobalStatus = (state) => {
133
+ let statusClass = 'idle', statusText = 'Idle';
134
+ if (state.running) {
135
+ statusClass = 'running';
136
+ statusText = state.operation === 'dump' ? 'Dumping' : 'Restoring';
137
+ }
138
+ elements.statusBadge.className = `status-badge ${statusClass}`;
139
+ elements.statusBadge.querySelector('.status-text').textContent = statusText;
 
 
 
 
 
 
140
  };
141
 
142
+ const filterLogs = () => {
143
+ const level = elements.logLevelFilters.querySelector('.active').dataset.level;
144
+ const search = elements.logSearch.value.toLowerCase();
145
+ $$('#log-output .log-entry').forEach(entry => {
146
+ const showLevel = level === 'all' || entry.dataset.level === level;
147
+ const showSearch = !search || entry.textContent.toLowerCase().includes(search);
148
+ entry.style.display = (showLevel && showSearch) ? 'flex' : 'none';
149
+ });
150
  };
151
 
152
  const renderLogs = (logs) => {
 
156
  newLogs.forEach(log => {
157
  const entry = document.createElement('div');
158
  entry.className = 'log-entry';
159
+ entry.dataset.level = log.level;
160
+ entry.innerHTML = `<span class="timestamp">${log.timestamp}</span><span class="level level-${log.level}">${log.level.toUpperCase()}</span><span class="message">${log.message}</span>`;
 
 
 
161
  fragment.appendChild(entry);
162
  });
163
+ elements.logOutput.appendChild(fragment);
164
  lastLogId = newLogs[newLogs.length - 1].id;
165
+ if (elements.logAutoscroll.checked) elements.logOutput.scrollTop = elements.logOutput.scrollHeight;
166
+ filterLogs();
 
 
167
  }
 
 
 
 
 
 
 
 
 
168
  };
169
 
170
+ const updateCommandPreviews = () => {
171
+ // Dump Preview
172
+ const sourcePrev = elements.sourceConn.value ? elements.sourceConn.value.replace(/:(?!\/\/)([^@]+)@/, ':********@') : '...';
173
+ 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> ...`;
174
+ // Restore Preview
175
+ const targetPrev = elements.targetConn.value ? elements.targetConn.value.replace(/:(?!\/\/)([^@]+)@/, ':********@') : '...';
176
+ const filePrev = elements.serverBackupFile.selectedOptions[0]?.text.split(' ')[0] || '...';
177
+ 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> ...`;
 
 
 
 
 
 
 
 
 
 
 
 
178
  };
179
 
180
+ const restoreUiState = (state) => {
181
+ if (state.running) {
182
+ navigateTo(state.operation);
183
+ const viewContainer = $(`#${state.operation}-monitoring-view`);
184
+ viewContainer.innerHTML = '';
185
+ viewContainer.appendChild(createMonitoringView(state.operation));
186
+ $(`#${state.operation}-config-view`).classList.add('hidden');
187
+ viewContainer.classList.remove('hidden');
188
+ updateMonitoringView(state.operation, state);
189
+ startPolling();
190
+ } else if (state.end_time) { // Operation finished
191
+ if (state.operation === 'dump' && state.dump_completed) {
192
+ const footer = $('#dump-footer');
193
+ if(footer) {
194
+ footer.innerHTML = `
195
+ <a href="/downloads/${state.dump_file.split(/[\\/]/).pop()}" class="btn btn-secondary" target="_blank"><i class="fas fa-download"></i> Download</a>
196
+ <button id="goto-restore-btn" class="btn btn-primary"><i class="fas fa-upload"></i> Go to Restore</button>
197
+ `;
198
+ }
199
+ }
200
  }
201
  };
202
 
203
+ // --- API & WORKFLOWS ---
204
+ const testConnection = async (connStr, card, type) => {
205
+ if (!connStr) return;
206
+ renderConnectionStatus(card, { loading: true });
207
  try {
208
+ const data = await apiFetch('/test-connection', {
209
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
210
+ body: JSON.stringify({ connection_string: connStr })
211
+ });
212
+ renderConnectionStatus(card, data);
213
+ localStorage.setItem(`${type}_conn`, connStr);
214
+ // Fetch extended info
215
+ const info = await apiFetch('/database-info', {
216
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({ connection_string: connStr })
218
  });
219
+ if (info.success) {
220
+ $(`#${card.id}-tables`).textContent = info.table_count;
221
+ $(`#${card.id}-size`).textContent = info.database_size;
 
 
 
 
 
 
222
  }
223
  } catch (error) {
224
+ renderConnectionStatus(card, { success: false, message: error.message });
225
  }
226
  };
227
 
228
  const loadServerBackups = async () => {
229
  try {
230
+ const data = await apiFetch('/list-dumps');
231
+ elements.serverBackupFile.innerHTML = data.dumps.length > 0
232
+ ? data.dumps.map(d => `<option value="${d.path}">${d.name} (${d.size_mb.toFixed(2)} MB)</option>`).join('')
233
+ : '<option value="" disabled>No backups found</option>';
 
 
 
 
 
 
 
 
 
234
  } catch (error) {
235
+ elements.serverBackupFile.innerHTML = '<option value="" disabled>Error loading backups</option>';
 
236
  }
237
+ updateCommandPreviews();
238
  };
239
 
240
+ const startOperation = async (operation) => {
241
+ const isDump = operation === 'dump';
242
+ const config = isDump ? {
243
+ source_conn: elements.sourceConn.value,
244
+ options: { format: elements.dumpFormat.value, compression: elements.dumpCompression.value, schema: elements.schemaFilter.value, filename: elements.dumpFilename.value }
245
+ } : {
246
+ target_conn: elements.targetConn.value, dump_file: elements.serverBackupFile.value,
247
+ options: {
248
+ timescaledb_pre_restore: $('#timescaledb-pre-restore').checked,
249
+ timescaledb_post_restore: $('#timescaledb-post-restore').checked,
250
+ no_owner: $('#no-owner').checked, clean: $('#clean').checked
251
+ }
252
+ };
253
+ if ((isDump && !config.source_conn) || (!isDump && (!config.target_conn || !config.dump_file))) {
254
+ return showToast('error', 'Please complete all required fields.');
 
 
 
 
 
 
 
 
 
 
 
255
  }
256
+
257
  try {
258
+ await apiFetch(`/start-${operation}`, {
259
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
260
+ body: JSON.stringify(config)
 
 
 
 
 
 
 
 
 
261
  });
262
+ showToast('success', `${operation.charAt(0).toUpperCase() + operation.slice(1)} process started.`);
263
+ navigateTo(operation);
264
+ restoreUiState({ running: true, operation: operation }); // Immediately switch view
 
 
 
 
265
  } catch (error) {
266
+ showToast('error', `Failed to start ${operation}: ${error.message}`);
267
  }
268
+ };
269
+
270
+ // --- EVENT LISTENERS ---
271
+ elements.menuToggle.addEventListener('click', () => elements.sidebar.classList.toggle('open'));
272
+ elements.navLinks.forEach(link => link.addEventListener('click', e => { e.preventDefault(); navigateTo(link.dataset.target); }));
273
+ elements.testSourceBtn.addEventListener('click', () => testConnection(elements.sourceConn.value, elements.sourceStatusCard, 'source'));
274
+ elements.testTargetBtn.addEventListener('click', () => testConnection(elements.targetConn.value, elements.targetStatusCard, 'target'));
275
+ [elements.sourceConn, elements.targetConn, elements.dumpFormat, elements.serverBackupFile].forEach(el => el.addEventListener('change', updateCommandPreviews));
276
+
277
+ elements.startDumpBtn.addEventListener('click', () => {
278
+ elements.confirmModalTitle.textContent = 'Confirm Dump';
279
+ elements.confirmModalBody.textContent = 'Are you sure you want to start the dump process?';
280
+ confirmAction = () => startOperation('dump');
281
+ elements.confirmModal.classList.remove('hidden');
282
  });
283
 
284
+ elements.startRestoreBtn.addEventListener('click', () => {
285
+ elements.confirmModalTitle.textContent = 'Confirm Restore';
286
+ elements.confirmModalBody.textContent = 'WARNING: This may overwrite data on the target database. Are you sure you want to proceed?';
287
+ confirmAction = () => startOperation('restore');
288
+ elements.confirmModal.classList.remove('hidden');
289
+ });
290
+
291
+ document.body.addEventListener('click', e => {
292
+ if (e.target.matches('#stop-dump-btn') || e.target.matches('#stop-restore-btn')) {
293
+ elements.confirmModalTitle.textContent = 'Confirm Stop';
294
+ elements.confirmModalBody.textContent = 'Are you sure you want to stop the current operation?';
295
+ confirmAction = async () => {
296
+ try { await apiFetch('/stop-process', { method: 'POST' }); showToast('success', 'Stop command sent.'); }
297
+ catch (error) { showToast('error', `Failed to stop: ${error.message}`); }
298
+ };
299
+ elements.confirmModal.classList.remove('hidden');
300
  }
301
+ if (e.target.matches('#goto-restore-btn')) {
302
+ const lastFile = state.dump_file;
303
+ if (lastFile) {
304
+ const option = Array.from(elements.serverBackupFile.options).find(opt => opt.value === lastFile);
305
+ if (option) option.selected = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  }
307
+ navigateTo('restore');
308
+ updateCommandPreviews();
309
  }
310
  });
311
 
312
+ elements.cancelConfirmBtn.addEventListener('click', () => elements.confirmModal.classList.add('hidden'));
313
+ elements.confirmActionBtn.addEventListener('click', () => {
314
+ if (confirmAction) confirmAction();
315
+ elements.confirmModal.classList.add('hidden');
316
+ });
 
317
 
318
+ elements.logLevelFilters.addEventListener('click', e => {
319
+ if (e.target.tagName === 'BUTTON') {
320
+ elements.logLevelFilters.querySelector('.active').classList.remove('active');
321
+ e.target.classList.add('active');
322
+ filterLogs();
 
 
323
  }
 
 
 
 
324
  });
325
+ elements.logSearch.addEventListener('input', filterLogs);
326
+ elements.exportLogsBtn.addEventListener('click', () => {
327
+ const content = Array.from($$('#log-output .log-entry')).map(e => e.textContent.trim()).join('\n');
328
+ const blob = new Blob([content], { type: 'text/plain' });
329
+ const a = document.createElement('a');
330
+ a.href = URL.createObjectURL(blob);
331
+ a.download = `migrator-logs-${new Date().toISOString()}.txt`;
332
+ a.click();
333
  });
334
+ elements.clearLogsBtn.addEventListener('click', () => {
335
+ elements.logOutput.innerHTML = '';
336
+ lastLogId = -1;
 
 
 
 
 
337
  });
338
 
339
  // --- INITIALIZATION ---
340
+ const initialize = async () => {
341
+ elements.sourceConn.value = localStorage.getItem('source_conn') || '';
342
+ elements.targetConn.value = localStorage.getItem('target_conn') || '';
343
+ updateCommandPreviews();
344
+ await loadServerBackups();
345
+ try {
346
+ state = await apiFetch('/status');
347
+ renderGlobalStatus(state);
348
+ renderLogs(state.log);
349
+ restoreUiState(state);
350
+ } catch (e) {
351
+ showToast('error', 'Could not connect to backend.');
352
+ }
353
  };
354
 
355
  initialize();