arcticaurora commited on
Commit
f4c0131
·
verified ·
1 Parent(s): a16d403

Create js/app.js

Browse files
Files changed (1) hide show
  1. static/js/app.js +447 -0
static/js/app.js ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) => {
219
+ const newLogs = logs.filter(log => log.id > lastLogId);
220
+ if (newLogs.length > 0) {
221
+ const fragment = document.createDocumentFragment();
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();
447
+ });