arcticaurora commited on
Commit
248b355
·
verified ·
1 Parent(s): 1e09611

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +238 -110
app.py CHANGED
@@ -15,6 +15,10 @@ from typing import Dict, Any, Optional, List
15
  from pathlib import Path
16
  import psycopg2
17
  import logging
 
 
 
 
18
 
19
  # Setup logging
20
  logging.basicConfig(
@@ -172,6 +176,13 @@ def run_dump(source_conn: str, file_path: str, options: dict):
172
  migration_state["dump_completed"] = False
173
  migration_state["previous_size"] = 0 # Reset previous size
174
 
 
 
 
 
 
 
 
175
  process = subprocess.Popen(
176
  cmd,
177
  stdout=subprocess.PIPE,
@@ -179,7 +190,8 @@ def run_dump(source_conn: str, file_path: str, options: dict):
179
  env=env,
180
  text=True,
181
  bufsize=1, # Line buffering
182
- universal_newlines=True
 
183
  )
184
 
185
  with migration_lock:
@@ -295,6 +307,11 @@ def run_restore(target_conn: str, file_path: str, options: dict):
295
  migration_state["restore_completed"] = False
296
  migration_state["progress"]["tables_completed"] = 0 # Reset counter
297
 
 
 
 
 
 
298
  process = subprocess.Popen(
299
  cmd,
300
  stdout=subprocess.PIPE,
@@ -302,7 +319,8 @@ def run_restore(target_conn: str, file_path: str, options: dict):
302
  env=env,
303
  text=True,
304
  bufsize=1, # Line buffering
305
- universal_newlines=True
 
306
  )
307
 
308
  with migration_lock:
@@ -388,37 +406,98 @@ def run_restore(target_conn: str, file_path: str, options: dict):
388
  migration_state["process"] = None
389
  return False
390
 
 
391
  def stop_current_process():
392
- """Stop the current process if running"""
393
  with migration_lock:
394
  if migration_state["process"] and migration_state["running"]:
395
  try:
396
- pid = migration_state["process"].pid
397
- logger.info(f"Terminating process with PID: {pid}")
398
- migration_state["process"].terminate() # Send SIGTERM
399
- try:
400
- # Wait a short time for graceful termination
401
- migration_state["process"].wait(timeout=2)
402
- except subprocess.TimeoutExpired:
403
- logger.warning(f"Process {pid} did not terminate gracefully, sending SIGKILL.")
404
- migration_state["process"].kill() # Send SIGKILL if necessary
405
- migration_state["process"].wait() # Wait for kill
406
-
407
  operation = migration_state["operation"]
408
- log_message(f"Database {operation} operation stopped by user", "warning")
409
-
410
- # Update state *after* termination attempt
411
- migration_state["process"] = None
412
- migration_state["running"] = False
413
- migration_state["end_time"] = time.time()
414
- return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  except Exception as e:
416
  log_message(f"Error stopping process: {str(e)}", "error")
417
- # Attempt to force state update even if termination fails
418
  migration_state["process"] = None
419
  migration_state["running"] = False
 
420
  return False
421
- return False
 
 
 
 
 
 
 
422
 
423
  @app.get("/", response_class=HTMLResponse)
424
  async def home(request: Request):
@@ -429,7 +508,7 @@ async def home(request: Request):
429
  with open("templates/index.html", "w") as f:
430
  f.write("Placeholder - HTML will be generated") # Basic placeholder
431
 
432
- # The actual HTML content
433
  html_content = """<!DOCTYPE html>
434
  <html lang="en">
435
  <head>
@@ -615,6 +694,10 @@ async def home(request: Request):
615
  background-color: rgba(239, 68, 68, 0.2);
616
  color: var(--color-danger);
617
  }
 
 
 
 
618
  .pulse-dot {
619
  display: inline-block;
620
  width: 8px;
@@ -1013,6 +1096,8 @@ async def home(request: Request):
1013
  margin-right: 0.5rem;
1014
  flex-shrink: 0;
1015
  font-weight: bold;
 
 
1016
  }
1017
  .terminal-command {
1018
  color: #fff;
@@ -1022,25 +1107,25 @@ async def home(request: Request):
1022
  .terminal-output {
1023
  color: #aaa;
1024
  white-space: pre-wrap;
1025
- padding-left: 1rem;
1026
  word-break: break-word;
1027
  }
1028
  .terminal-error {
1029
  color: var(--color-danger);
1030
  white-space: pre-wrap;
1031
- padding-left: 1rem;
1032
  word-break: break-word;
1033
  }
1034
  .terminal-success {
1035
  color: var(--color-success);
1036
  white-space: pre-wrap;
1037
- padding-left: 1rem;
1038
  word-break: break-word;
1039
  }
1040
  .terminal-warning {
1041
  color: var(--color-warning);
1042
  white-space: pre-wrap;
1043
- padding-left: 1rem;
1044
  word-break: break-word;
1045
  }
1046
  .terminal-body::-webkit-scrollbar {
@@ -1922,6 +2007,10 @@ async def home(request: Request):
1922
  background-color: rgba(239, 68, 68, 0.1);
1923
  color: var(--color-danger);
1924
  }
 
 
 
 
1925
  </style>
1926
  </head>
1927
  <body>
@@ -2134,7 +2223,7 @@ async def home(request: Request):
2134
  <div class="file-size-value" id="current-size">0 MB</div>
2135
  <div class="file-size-subtitle" id="growth-rate">0 MB/s</div>
2136
  </div>
2137
- <div class="badge" id="dump-status">In Progress</div>
2138
  </div>
2139
  <div class="file-size-chart">
2140
  <canvas id="size-chart"></canvas>
@@ -2164,7 +2253,7 @@ async def home(request: Request):
2164
  </div>
2165
  </div>
2166
  <div class="command-visualization" id="dump-command-preview">
2167
- <div class="command-part command-keyword">pg_dump</div> <div class="command-part command-string">postgres://user:***@hostname:5432/database</div> <div class="command-part command-flag">-Fc</div> <div class="command-part command-flag">-v</div> <div class="command-part command-flag">-f</div> <div class="command-part command-string">timescale_backup.dump</div>
2168
  </div>
2169
  </div>
2170
  </div>
@@ -2275,7 +2364,7 @@ async def home(request: Request):
2275
  </div>
2276
  </div>
2277
  <div class="command-visualization" id="restore-command-preview">
2278
- <div class="command-part command-keyword">pg_restore</div> <div class="command-part command-flag">-d</div> <div class="command-part command-string">postgres://user:***@hostname:5432/database</div> <div class="command-part command-flag">-v</div> <div class="command-part command-option">--no-owner</div> <div class="command-part command-option">--single-transaction</div> <div class="command-part command-string">timescale_backup.dump</div>
2279
  </div>
2280
  </div>
2281
  </div>
@@ -2354,13 +2443,13 @@ async def home(request: Request):
2354
  <p>The restore operation uses <code>pg_restore</code> to import your backup into the target database. It includes TimescaleDB-specific pre and post-restore functions to ensure data integrity.</p>
2355
  <h4 class="mt-4 mb-3">Commands Used</h4>
2356
  <div class="command-visualization">
2357
- <div class="command-part command-keyword">pg_dump</div> <div class="command-part command-string">"postgres://user:password@source-host:5432/source_db"</div> <div class="command-part command-flag">-Fc</div> <div class="command-part command-flag">-v</div> <div class="command-part command-flag">-f</div> <div class="command-part command-string">~/timescale_backup.dump</div>
2358
  </div>
2359
  <div class="command-visualization mt-3">
2360
  <div class="command-part command-keyword">psql</div> <div class="command-part command-string">"postgres://user:password@target-host:5432/target_db"</div> <div class="command-part command-flag">-c</div> <div class="command-part command-string">"SELECT timescaledb_pre_restore();"</div>
2361
  </div>
2362
  <div class="command-visualization mt-3">
2363
- <div class="command-part command-keyword">pg_restore</div> <div class="command-part command-flag">-d</div> <div class="command-part command-string">"postgres://user:password@target-host:5432/target_db"</div> <div class="command-part command-flag">-v</div> <div class="command-part command-option">--no-owner</div> <div class="command-part command-string">~/timescale_backup.dump</div>
2364
  </div>
2365
  <div class="command-visualization mt-3">
2366
  <div class="command-part command-keyword">psql</div> <div class="command-part command-string">"postgres://user:password@target-host:5432/target_db"</div> <div class="command-part command-flag">-c</div> <div class="command-part command-string">"SELECT timescaledb_post_restore(); ANALYZE;"</div>
@@ -2395,6 +2484,10 @@ async def home(request: Request):
2395
  <!-- Scripts -->
2396
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
2397
  <script>
 
 
 
 
2398
  document.addEventListener('DOMContentLoaded', function() {
2399
  // DOM Elements
2400
  const tabs = document.querySelectorAll('.tab');
@@ -2790,7 +2883,13 @@ async def home(request: Request):
2790
  }
2791
  // Update elapsed time
2792
  function startElapsedTimeCounter() {
2793
- startTime = Date.now();
 
 
 
 
 
 
2794
  if (elapsedTimeInterval) {
2795
  clearInterval(elapsedTimeInterval);
2796
  }
@@ -2810,9 +2909,9 @@ async def home(request: Request):
2810
  elapsedTimeInterval = null;
2811
  }
2812
  // Final update based on end_time - start_time if available
2813
- if(migration_state.start_time && migration_state.end_time) {
2814
  const elapsed = Math.floor(migration_state.end_time - migration_state.start_time);
2815
- const formattedTime = formatDuration(elapsed);
2816
  if (migration_state.operation === 'dump') {
2817
  elapsedTimeElement.textContent = formattedTime;
2818
  } else if (migration_state.operation === 'restore') {
@@ -2832,7 +2931,9 @@ async def home(request: Request):
2832
  data.dumps.forEach(dump => {
2833
  const option = document.createElement('option');
2834
  option.value = dump.path;
2835
- option.textContent = `${dump.name} (${dump.size_mb.toFixed(2)} MB, ${dump.date})`;
 
 
2836
  serverBackupFile.appendChild(option);
2837
  });
2838
  } else {
@@ -2917,6 +3018,9 @@ async def home(request: Request):
2917
 
2918
  // Function to handle starting an operation (dump or restore)
2919
  async function handleStartOperation(operationType) {
 
 
 
2920
  if (migration_state && migration_state.running) {
2921
  confirmModalBody.innerHTML = `A migration operation (${migration_state.operation}) is already in progress. Stopping it will lose all progress. Are you sure you want to start a new ${operationType}?`;
2922
  confirmAction = `start-${operationType}`;
@@ -3019,7 +3123,8 @@ async def home(request: Request):
3019
  }
3020
  // Stop dump
3021
  stopDumpBtn.addEventListener('click', () => {
3022
- confirmModalBody.innerHTML = 'Stopping the dump will terminate the process and you will need to start over. Are you sure you want to stop?';
 
3023
  confirmAction = 'stop-dump';
3024
  confirmModal.classList.add('show');
3025
  });
@@ -3034,21 +3139,10 @@ async def home(request: Request):
3034
  method: 'POST'
3035
  });
3036
  const data = await response.json();
 
3037
  if (data.success) {
3038
- showToast('warning', 'Dump Stopped', 'Database dump process has been stopped.');
3039
- // Stop interval and counter first
3040
- stopStatusUpdates();
3041
- stopElapsedTimeCounter();
3042
- // Update UI after stopping updates
3043
- statusBadge.className = 'status-badge warning';
3044
- statusBadge.textContent = 'Stopped';
3045
- dumpStatusElement.textContent = 'Stopped';
3046
- dumpStatusElement.className = 'badge warning';
3047
- // Enable start button
3048
- startDumpBtn.disabled = false;
3049
- startDumpBtn.innerHTML = '<i class="fas fa-play"></i> Start Dump';
3050
- // Set terminal output
3051
- addTerminalLine('Dump process stopped by user', 'warning');
3052
  } else {
3053
  showToast('error', 'Failed to Stop', data.message || 'Could not stop the process.');
3054
  stopDumpBtn.disabled = false; // Re-enable if stop failed
@@ -3057,7 +3151,7 @@ async def home(request: Request):
3057
  }
3058
  } catch (error) {
3059
  console.error('Stop error:', error);
3060
- showToast('error', 'Error', 'Failed to stop the process.');
3061
  stopDumpBtn.disabled = false;
3062
  stopDumpBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
3063
  startDumpBtn.disabled = false;
@@ -3157,7 +3251,8 @@ async def home(request: Request):
3157
  }
3158
  // Stop restore
3159
  stopRestoreBtn.addEventListener('click', () => {
3160
- confirmModalBody.innerHTML = 'Stopping the restore will terminate the process and might leave the database in an inconsistent state. Are you sure you want to stop?';
 
3161
  confirmAction = 'stop-restore';
3162
  confirmModal.classList.add('show');
3163
  });
@@ -3172,22 +3267,10 @@ async def home(request: Request):
3172
  method: 'POST'
3173
  });
3174
  const data = await response.json();
 
3175
  if (data.success) {
3176
- showToast('warning', 'Restore Stopped', 'Database restore process has been stopped.');
3177
- // Stop interval and counter first
3178
- stopStatusUpdates();
3179
- stopElapsedTimeCounter();
3180
- // Update UI after stopping updates
3181
- statusBadge.className = 'status-badge warning';
3182
- statusBadge.textContent = 'Stopped';
3183
- restoreStatusElement.textContent = 'Stopped';
3184
- restoreSubstatusElement.textContent = 'Process was terminated by user';
3185
- restoreProgressBar.classList.remove('animated');
3186
- // Enable start button
3187
- startRestoreBtn.disabled = false;
3188
- startRestoreBtn.innerHTML = '<i class="fas fa-play"></i> Start Restore';
3189
- // Set terminal output
3190
- addTerminalLine('Restore process stopped by user', 'warning');
3191
  } else {
3192
  showToast('error', 'Failed to Stop', data.message || 'Could not stop the process.');
3193
  stopRestoreBtn.disabled = false; // Re-enable if stop failed
@@ -3196,7 +3279,7 @@ async def home(request: Request):
3196
  }
3197
  } catch (error) {
3198
  console.error('Stop error:', error);
3199
- showToast('error', 'Error', 'Failed to stop the process.');
3200
  stopRestoreBtn.disabled = false;
3201
  stopRestoreBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
3202
  startRestoreBtn.disabled = false;
@@ -3301,7 +3384,8 @@ async def home(request: Request):
3301
  if (icon) {
3302
  line.innerHTML = `<div class="terminal-prompt">${icon}</div> <div class="${className}">${sanitizedText}</div>`;
3303
  } else {
3304
- line.innerHTML = `<div class="${className}">${sanitizedText}</div>`; // No prompt for plain output
 
3305
  }
3306
 
3307
  terminalOutput.appendChild(line);
@@ -3310,6 +3394,11 @@ async def home(request: Request):
3310
  }
3311
  // Add log entry
3312
  function addLogEntry(log) {
 
 
 
 
 
3313
  const logEntry = document.createElement('div');
3314
  logEntry.className = 'log-entry';
3315
  logEntry.setAttribute('data-level', log.level);
@@ -3339,7 +3428,11 @@ async def home(request: Request):
3339
  if (log.command) {
3340
  addTerminalLine(log.command, 'command');
3341
  }
3342
- addTerminalLine(log.message, terminalType);
 
 
 
 
3343
  }
3344
  // Check for new logs
3345
  async function checkForNewLogs() {
@@ -3391,6 +3484,7 @@ async def home(request: Request):
3391
  return;
3392
  }
3393
  const data = await response.json();
 
3394
  migration_state = data; // Update local state copy
3395
 
3396
  // Check for new logs first
@@ -3414,6 +3508,9 @@ async def home(request: Request):
3414
  stopDumpBtn.disabled = false;
3415
  startRestoreBtn.disabled = true; // Disable other operation
3416
  stopRestoreBtn.disabled = true;
 
 
 
3417
  } else if (data.operation === 'restore') {
3418
  updateRestoreProgress(data);
3419
  // Ensure buttons reflect running state
@@ -3421,18 +3518,31 @@ async def home(request: Request):
3421
  stopRestoreBtn.disabled = false;
3422
  startDumpBtn.disabled = true; // Disable other operation
3423
  stopDumpBtn.disabled = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3424
  }
3425
- } else {
3426
- // Process finished or idle
3427
- stopStatusUpdates(); // Stop polling if not running
3428
- stopElapsedTimeCounter(); // Stop timer
3429
 
3430
  // Check if dump just completed
3431
- if (data.dump_completed && startDumpBtn.disabled && !stopDumpBtn.disabled) {
3432
  statusBadge.className = 'status-badge success';
3433
  statusBadge.textContent = 'Dump Complete';
3434
  dumpStatusElement.textContent = 'Completed';
3435
  dumpStatusElement.className = 'badge success';
 
 
3436
  // Enable download button
3437
  if (data.dump_file) {
3438
  const downloadPath = `/downloads/${data.dump_file.split(/[\\/]/).pop()}`;
@@ -3452,7 +3562,7 @@ async def home(request: Request):
3452
  loadServerBackups();
3453
  }
3454
  // Check if restore just completed
3455
- else if (data.restore_completed && startRestoreBtn.disabled && !stopRestoreBtn.disabled) {
3456
  statusBadge.className = 'status-badge success';
3457
  statusBadge.textContent = 'Restore Complete';
3458
  restoreStatusElement.textContent = 'Completed';
@@ -3469,8 +3579,8 @@ async def home(request: Request):
3469
  startDumpBtn.disabled = false; // Re-enable dump
3470
  }
3471
  // Handle cases where process stopped unexpectedly or was stopped manually
3472
- else if (data.operation && !data.running && (startDumpBtn.disabled || startRestoreBtn.disabled)) {
3473
- const stoppedOperation = data.operation;
3474
  statusBadge.className = 'status-badge warning';
3475
  statusBadge.textContent = `${stoppedOperation.charAt(0).toUpperCase() + stoppedOperation.slice(1)} Stopped`;
3476
 
@@ -3492,7 +3602,7 @@ async def home(request: Request):
3492
  }
3493
  }
3494
  // Otherwise, we are truly idle
3495
- else {
3496
  statusBadge.className = 'status-badge idle';
3497
  statusBadge.textContent = 'Idle';
3498
  startDumpBtn.disabled = false;
@@ -3550,7 +3660,6 @@ async def home(request: Request):
3550
  }
3551
  // Estimate progress based on tables completed (if total is known, otherwise just show activity)
3552
  // This is a rough estimate as table sizes vary greatly.
3553
- // A better approach would involve pg_restore's verbose output parsing if possible.
3554
  if (data.progress.tables_completed > 0) {
3555
  // Simple visual indication of progress, not accurate percentage
3556
  const pseudoPercent = Math.min(99, Math.max(5, (data.progress.tables_completed % 20) * 5)); // Cycle 5-99% based on table count
@@ -3583,11 +3692,11 @@ async def home(request: Request):
3583
  // Load server backups
3584
  await loadServerBackups();
3585
  // Initial status check
3586
- await updateStatus();
3587
  // Add initial log check
3588
  await checkForNewLogs();
3589
 
3590
- // If a process was running when the page loaded, start updates
3591
  if (migration_state && migration_state.running) {
3592
  startStatusUpdates();
3593
  startElapsedTimeCounter(); // Restart timer based on backend start_time
@@ -3597,6 +3706,8 @@ async def home(request: Request):
3597
  startDumpBtn.disabled = true;
3598
  startRestoreBtn.disabled = true;
3599
  if (migration_state.dump_file) dumpFilePathElement.textContent = migration_state.dump_file;
 
 
3600
  } else if (migration_state.operation === 'restore') {
3601
  restoreProgressSection.classList.remove('hidden');
3602
  stopRestoreBtn.disabled = false;
@@ -3610,28 +3721,40 @@ async def home(request: Request):
3610
 
3611
  // Restore button click (from dump section)
3612
  gotoRestoreFromDumpBtn.addEventListener('click', () => {
3613
- if (migration_state && migration_state.dump_file) {
3614
- // Find the corresponding option in the select dropdown
3615
- const dumpPath = migration_state.dump_file;
3616
- const options = serverBackupFile.options;
3617
- let found = false;
3618
- for (let i = 0; i < options.length; i++) {
3619
- if (options[i].value === dumpPath) {
3620
- serverBackupFile.selectedIndex = i;
3621
- found = true;
3622
- break;
 
 
3623
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3624
  }
3625
- if (!found) {
3626
- // If not found (e.g., file deleted), add it temporarily? Or just warn.
3627
- showToast('warning', 'Backup Not Found', 'The completed backup file is not in the dropdown list.');
3628
- }
3629
- // Switch to restore tab
3630
- document.querySelector('.tab[data-tab="restore"]').click();
3631
- updateRestoreCommandPreview(); // Update preview with selected file
3632
- } else {
3633
- showToast('error', 'Error', 'Cannot determine which backup to restore.');
3634
- }
3635
  });
3636
  });
3637
  </script>
@@ -4020,20 +4143,24 @@ async def stop_process_endpoint():
4020
  "message": "Process stop initiated successfully" # Changed message slightly
4021
  })
4022
  else:
4023
- # Check if it wasn't running in the first place
4024
  with migration_lock:
4025
  is_running = migration_state["running"]
4026
- if not is_running:
 
 
4027
  return JSONResponse(content={
4028
  "success": False, # Technically not an error, but no action taken
4029
  "message": "No process was running to stop"
4030
  })
4031
- else: # Stop was called, but failed internally
 
 
4032
  return JSONResponse(
4033
- status_code=500,
4034
  content={
4035
- "success": False,
4036
- "message": "Failed to stop the running process. Check logs."
4037
  })
4038
  except Exception as e:
4039
  log_message(f"Failed to stop process via endpoint: {str(e)}", "error")
@@ -4144,5 +4271,6 @@ async def download_file(file_name: str):
4144
  if __name__ == "__main__":
4145
  import uvicorn
4146
  # Use reload=True for development, but turn off for production
 
4147
  uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=True)
4148
  # For production: uvicorn.run(app, host="0.0.0.0", port=7860)
 
15
  from pathlib import Path
16
  import psycopg2
17
  import logging
18
+ # Add these imports
19
+ import signal
20
+ # import os # os is already imported
21
+ # import time # time is already imported
22
 
23
  # Setup logging
24
  logging.basicConfig(
 
176
  migration_state["dump_completed"] = False
177
  migration_state["previous_size"] = 0 # Reset previous size
178
 
179
+ # Use preexec_fn=os.setsid to create a new process group
180
+ # This is necessary for the killpg logic in stop_current_process
181
+ # Note: preexec_fn is Unix-specific. This won't work directly on Windows.
182
+ preexec_fn_to_use = None
183
+ if hasattr(os, 'setsid'):
184
+ preexec_fn_to_use = os.setsid
185
+
186
  process = subprocess.Popen(
187
  cmd,
188
  stdout=subprocess.PIPE,
 
190
  env=env,
191
  text=True,
192
  bufsize=1, # Line buffering
193
+ universal_newlines=True,
194
+ preexec_fn=preexec_fn_to_use # Create a new process group
195
  )
196
 
197
  with migration_lock:
 
307
  migration_state["restore_completed"] = False
308
  migration_state["progress"]["tables_completed"] = 0 # Reset counter
309
 
310
+ # Use preexec_fn=os.setsid to create a new process group
311
+ preexec_fn_to_use = None
312
+ if hasattr(os, 'setsid'):
313
+ preexec_fn_to_use = os.setsid
314
+
315
  process = subprocess.Popen(
316
  cmd,
317
  stdout=subprocess.PIPE,
 
319
  env=env,
320
  text=True,
321
  bufsize=1, # Line buffering
322
+ universal_newlines=True,
323
+ preexec_fn=preexec_fn_to_use # Create a new process group
324
  )
325
 
326
  with migration_lock:
 
406
  migration_state["process"] = None
407
  return False
408
 
409
+ # Replace the old stop_current_process with the new one
410
  def stop_current_process():
411
+ """Stop the current process with improved forceful termination"""
412
  with migration_lock:
413
  if migration_state["process"] and migration_state["running"]:
414
  try:
415
+ process = migration_state["process"]
416
+ pid = process.pid
 
 
 
 
 
 
 
 
 
417
  operation = migration_state["operation"]
418
+ log_message(f"Attempting to stop {operation} process (PID: {pid})...", "warning")
419
+
420
+ # Check if process is already terminated before trying to stop
421
+ if process.poll() is not None:
422
+ log_message(f"{operation.capitalize()} process (PID: {pid}) already terminated.", "info")
423
+ migration_state["process"] = None
424
+ migration_state["running"] = False
425
+ return True
426
+
427
+ # First try graceful termination (SIGTERM)
428
+ process.terminate()
429
+ # Wait up to 3 seconds for graceful termination
430
+ for _ in range(30): # 3 seconds with 0.1s checks
431
+ if process.poll() is not None: # Process has terminated
432
+ log_message(f"{operation.capitalize()} process (PID: {pid}) terminated gracefully (SIGTERM)", "warning")
433
+ break
434
+ time.sleep(0.1)
435
+ else: # Loop finished without break, process still running
436
+ # If still running, force kill with SIGKILL
437
+ if process.poll() is None:
438
+ log_message(f"Process (PID: {pid}) not responding to graceful termination, forcing kill (SIGKILL)...", "warning")
439
+ # Try to kill process group (more thorough) - Unix only
440
+ killed_pg = False
441
+ if hasattr(os, 'killpg') and hasattr(os, 'getpgid'):
442
+ try:
443
+ # On Unix systems, negative PID means kill process group
444
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
445
+ killed_pg = True
446
+ log_message(f"Sent SIGKILL to process group of PID {pid}", "warning")
447
+ except ProcessLookupError:
448
+ log_message(f"Process group for PID {pid} not found (already terminated?).", "info")
449
+ # Process likely died between poll and killpg, proceed as if killed
450
+ killed_pg = True # Treat as success for logic below
451
+ except Exception as kill_err:
452
+ log_message(f"Error killing process group for PID {pid}: {kill_err}. Falling back to direct kill.", "error")
453
+ # Fallback to direct kill if process group kill fails
454
+ process.kill()
455
+ log_message(f"Sent SIGKILL directly to PID {pid}", "warning")
456
+ else: # Not on Unix or functions unavailable
457
+ process.kill()
458
+ log_message(f"Sent SIGKILL directly to PID {pid} (killpg not available)", "warning")
459
+
460
+ # Wait a bit for kill to take effect
461
+ time.sleep(0.5)
462
+ process.poll() # Update process status after kill attempt
463
+
464
+ # Final check
465
+ if process.poll() is None:
466
+ log_message(f"Warning: Process (PID: {pid}) may not have terminated successfully after SIGKILL", "error")
467
+ # Even if termination is uncertain, update state to reflect stop attempt
468
+ migration_state["process"] = None
469
+ migration_state["running"] = False
470
+ migration_state["end_time"] = time.time()
471
+ return False # Indicate potential failure
472
+ else:
473
+ log_message(f"Database {operation} operation (PID: {pid}) stopped", "warning")
474
+ migration_state["process"] = None
475
+ migration_state["running"] = False
476
+ migration_state["end_time"] = time.time()
477
+ return True
478
+
479
+ except ProcessLookupError:
480
+ # This can happen if the process terminated between the initial check and trying to kill it
481
+ log_message(f"Process (PID: {pid}) already terminated before stop action completed.", "info")
482
+ migration_state["process"] = None
483
+ migration_state["running"] = False
484
+ migration_state["end_time"] = time.time()
485
+ return True
486
  except Exception as e:
487
  log_message(f"Error stopping process: {str(e)}", "error")
488
+ # Force state update even on error
489
  migration_state["process"] = None
490
  migration_state["running"] = False
491
+ migration_state["end_time"] = time.time()
492
  return False
493
+ else:
494
+ # No process was running or associated with the state
495
+ log_message("Stop command received, but no process found in current state.", "info")
496
+ # Ensure state reflects not running if it wasn't already
497
+ if migration_state["running"]:
498
+ migration_state["running"] = False
499
+ migration_state["process"] = None
500
+ return False # Indicate no action was needed/taken on a process
501
 
502
  @app.get("/", response_class=HTMLResponse)
503
  async def home(request: Request):
 
508
  with open("templates/index.html", "w") as f:
509
  f.write("Placeholder - HTML will be generated") # Basic placeholder
510
 
511
+ # The actual HTML content (NOTE: Frontend changes mentioned in prompt are NOT applied here, only backend)
512
  html_content = """<!DOCTYPE html>
513
  <html lang="en">
514
  <head>
 
694
  background-color: rgba(239, 68, 68, 0.2);
695
  color: var(--color-danger);
696
  }
697
+ .status-badge.success { /* Added for completed state */
698
+ background-color: rgba(16, 185, 129, 0.2);
699
+ color: var(--color-success);
700
+ }
701
  .pulse-dot {
702
  display: inline-block;
703
  width: 8px;
 
1096
  margin-right: 0.5rem;
1097
  flex-shrink: 0;
1098
  font-weight: bold;
1099
+ min-width: 1em; /* Ensure space for icon */
1100
+ text-align: center;
1101
  }
1102
  .terminal-command {
1103
  color: #fff;
 
1107
  .terminal-output {
1108
  color: #aaa;
1109
  white-space: pre-wrap;
1110
+ /* padding-left: 1rem; */ /* Removed padding, rely on prompt */
1111
  word-break: break-word;
1112
  }
1113
  .terminal-error {
1114
  color: var(--color-danger);
1115
  white-space: pre-wrap;
1116
+ /* padding-left: 1rem; */
1117
  word-break: break-word;
1118
  }
1119
  .terminal-success {
1120
  color: var(--color-success);
1121
  white-space: pre-wrap;
1122
+ /* padding-left: 1rem; */
1123
  word-break: break-word;
1124
  }
1125
  .terminal-warning {
1126
  color: var(--color-warning);
1127
  white-space: pre-wrap;
1128
+ /* padding-left: 1rem; */
1129
  word-break: break-word;
1130
  }
1131
  .terminal-body::-webkit-scrollbar {
 
2007
  background-color: rgba(239, 68, 68, 0.1);
2008
  color: var(--color-danger);
2009
  }
2010
+ .badge.info { /* Added for in-progress */
2011
+ background-color: rgba(59, 130, 246, 0.1);
2012
+ color: var(--color-info);
2013
+ }
2014
  </style>
2015
  </head>
2016
  <body>
 
2223
  <div class="file-size-value" id="current-size">0 MB</div>
2224
  <div class="file-size-subtitle" id="growth-rate">0 MB/s</div>
2225
  </div>
2226
+ <div class="badge info" id="dump-status">In Progress</div> <!-- Default to info -->
2227
  </div>
2228
  <div class="file-size-chart">
2229
  <canvas id="size-chart"></canvas>
 
2253
  </div>
2254
  </div>
2255
  <div class="command-visualization" id="dump-command-preview">
2256
+ <div class="command-part command-keyword">pg_dump</div> <div class="command-part command-string">"postgres://user:***@hostname:5432/database"</div> <div class="command-part command-flag">-Fc</div> <div class="command-part command-flag">-v</div> <div class="command-part command-flag">-f</div> <div class="command-part command-string">"timescale_backup.dump"</div>
2257
  </div>
2258
  </div>
2259
  </div>
 
2364
  </div>
2365
  </div>
2366
  <div class="command-visualization" id="restore-command-preview">
2367
+ <div class="command-part command-keyword">pg_restore</div> <div class="command-part command-flag">-d</div> <div class="command-part command-string">"postgres://user:***@hostname:5432/database"</div> <div class="command-part command-flag">-v</div> <div class="command-part command-option">--no-owner</div> <div class="command-part command-option">--single-transaction</div> <div class="command-part command-string">"timescale_backup.dump"</div>
2368
  </div>
2369
  </div>
2370
  </div>
 
2443
  <p>The restore operation uses <code>pg_restore</code> to import your backup into the target database. It includes TimescaleDB-specific pre and post-restore functions to ensure data integrity.</p>
2444
  <h4 class="mt-4 mb-3">Commands Used</h4>
2445
  <div class="command-visualization">
2446
+ <div class="command-part command-keyword">pg_dump</div> <div class="command-part command-string">"postgres://user:password@source-host:5432/source_db"</div> <div class="command-part command-flag">-Fc</div> <div class="command-part command-flag">-v</div> <div class="command-part command-flag">-f</div> <div class="command-part command-string">"~/timescale_backup.dump"</div>
2447
  </div>
2448
  <div class="command-visualization mt-3">
2449
  <div class="command-part command-keyword">psql</div> <div class="command-part command-string">"postgres://user:password@target-host:5432/target_db"</div> <div class="command-part command-flag">-c</div> <div class="command-part command-string">"SELECT timescaledb_pre_restore();"</div>
2450
  </div>
2451
  <div class="command-visualization mt-3">
2452
+ <div class="command-part command-keyword">pg_restore</div> <div class="command-part command-flag">-d</div> <div class="command-part command-string">"postgres://user:password@target-host:5432/target_db"</div> <div class="command-part command-flag">-v</div> <div class="command-part command-option">--no-owner</div> <div class="command-part command-string">"~/timescale_backup.dump"</div>
2453
  </div>
2454
  <div class="command-visualization mt-3">
2455
  <div class="command-part command-keyword">psql</div> <div class="command-part command-string">"postgres://user:password@target-host:5432/target_db"</div> <div class="command-part command-flag">-c</div> <div class="command-part command-string">"SELECT timescaledb_post_restore(); ANALYZE;"</div>
 
2484
  <!-- Scripts -->
2485
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
2486
  <script>
2487
+ // NOTE: This JS is the previous version.
2488
+ // The user prompt included JS changes for the confirmation modal text,
2489
+ // but the request was only to output the modified Python code.
2490
+ // For a fully working system, the JS would also need updating.
2491
  document.addEventListener('DOMContentLoaded', function() {
2492
  // DOM Elements
2493
  const tabs = document.querySelectorAll('.tab');
 
2883
  }
2884
  // Update elapsed time
2885
  function startElapsedTimeCounter() {
2886
+ // Use start_time from backend state if available and process is running
2887
+ if (migration_state && migration_state.running && migration_state.start_time) {
2888
+ startTime = migration_state.start_time * 1000; // Convert seconds to ms
2889
+ } else {
2890
+ startTime = Date.now();
2891
+ }
2892
+
2893
  if (elapsedTimeInterval) {
2894
  clearInterval(elapsedTimeInterval);
2895
  }
 
2909
  elapsedTimeInterval = null;
2910
  }
2911
  // Final update based on end_time - start_time if available
2912
+ if(migration_state && migration_state.start_time && migration_state.end_time) {
2913
  const elapsed = Math.floor(migration_state.end_time - migration_state.start_time);
2914
+ const formattedTime = formatDuration(elapsed >= 0 ? elapsed : 0);
2915
  if (migration_state.operation === 'dump') {
2916
  elapsedTimeElement.textContent = formattedTime;
2917
  } else if (migration_state.operation === 'restore') {
 
2931
  data.dumps.forEach(dump => {
2932
  const option = document.createElement('option');
2933
  option.value = dump.path;
2934
+ // Indicate if it's a directory
2935
+ const typeIndicator = dump.is_dir ? ' (Dir)' : '';
2936
+ option.textContent = `${dump.name}${typeIndicator} (${dump.size_mb.toFixed(2)} MB, ${dump.date})`;
2937
  serverBackupFile.appendChild(option);
2938
  });
2939
  } else {
 
3018
 
3019
  // Function to handle starting an operation (dump or restore)
3020
  async function handleStartOperation(operationType) {
3021
+ // Fetch latest status before deciding
3022
+ await updateStatus();
3023
+
3024
  if (migration_state && migration_state.running) {
3025
  confirmModalBody.innerHTML = `A migration operation (${migration_state.operation}) is already in progress. Stopping it will lose all progress. Are you sure you want to start a new ${operationType}?`;
3026
  confirmAction = `start-${operationType}`;
 
3123
  }
3124
  // Stop dump
3125
  stopDumpBtn.addEventListener('click', () => {
3126
+ // Use the updated text from the prompt
3127
+ confirmModalBody.innerHTML = 'Stopping the dump will terminate the process and you will need to start over. The process may take a few seconds to stop completely. Are you sure you want to stop?';
3128
  confirmAction = 'stop-dump';
3129
  confirmModal.classList.add('show');
3130
  });
 
3139
  method: 'POST'
3140
  });
3141
  const data = await response.json();
3142
+ // UI updates will be handled by the status poller detecting the change
3143
  if (data.success) {
3144
+ showToast('warning', 'Stop Initiated', 'Attempting to stop the dump process...');
3145
+ // The status poller will eventually update the UI state fully
 
 
 
 
 
 
 
 
 
 
 
 
3146
  } else {
3147
  showToast('error', 'Failed to Stop', data.message || 'Could not stop the process.');
3148
  stopDumpBtn.disabled = false; // Re-enable if stop failed
 
3151
  }
3152
  } catch (error) {
3153
  console.error('Stop error:', error);
3154
+ showToast('error', 'Error', 'Failed to send stop command.');
3155
  stopDumpBtn.disabled = false;
3156
  stopDumpBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
3157
  startDumpBtn.disabled = false;
 
3251
  }
3252
  // Stop restore
3253
  stopRestoreBtn.addEventListener('click', () => {
3254
+ // Use the updated text from the prompt
3255
+ confirmModalBody.innerHTML = 'Stopping the restore will terminate the process and might leave the database in an inconsistent state. The process may take a few seconds to stop completely. Are you sure you want to stop?';
3256
  confirmAction = 'stop-restore';
3257
  confirmModal.classList.add('show');
3258
  });
 
3267
  method: 'POST'
3268
  });
3269
  const data = await response.json();
3270
+ // UI updates will be handled by the status poller detecting the change
3271
  if (data.success) {
3272
+ showToast('warning', 'Stop Initiated', 'Attempting to stop the restore process...');
3273
+ // The status poller will eventually update the UI state fully
 
 
 
 
 
 
 
 
 
 
 
 
 
3274
  } else {
3275
  showToast('error', 'Failed to Stop', data.message || 'Could not stop the process.');
3276
  stopRestoreBtn.disabled = false; // Re-enable if stop failed
 
3279
  }
3280
  } catch (error) {
3281
  console.error('Stop error:', error);
3282
+ showToast('error', 'Error', 'Failed to send stop command.');
3283
  stopRestoreBtn.disabled = false;
3284
  stopRestoreBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
3285
  startRestoreBtn.disabled = false;
 
3384
  if (icon) {
3385
  line.innerHTML = `<div class="terminal-prompt">${icon}</div> <div class="${className}">${sanitizedText}</div>`;
3386
  } else {
3387
+ // For plain output, don't add a prompt div, just the message
3388
+ line.innerHTML = `<div class="${className}" style="padding-left: calc(1em + 0.5rem);">${sanitizedText}</div>`;
3389
  }
3390
 
3391
  terminalOutput.appendChild(line);
 
3394
  }
3395
  // Add log entry
3396
  function addLogEntry(log) {
3397
+ // Avoid adding duplicate logs if status updates overlap slightly
3398
+ if (document.querySelector(`.log-entry[data-id="${log.id}"]`)) {
3399
+ return;
3400
+ }
3401
+
3402
  const logEntry = document.createElement('div');
3403
  logEntry.className = 'log-entry';
3404
  logEntry.setAttribute('data-level', log.level);
 
3428
  if (log.command) {
3429
  addTerminalLine(log.command, 'command');
3430
  }
3431
+ // Only add non-command messages if they are not just verbose process output (heuristic)
3432
+ // This avoids cluttering the terminal too much with pg_dump/restore verbose lines
3433
+ if (!log.command && (log.level !== 'info' || !log.message.startsWith('pg_'))) {
3434
+ addTerminalLine(log.message, terminalType);
3435
+ }
3436
  }
3437
  // Check for new logs
3438
  async function checkForNewLogs() {
 
3484
  return;
3485
  }
3486
  const data = await response.json();
3487
+ const previousState = migration_state; // Store previous state for comparison
3488
  migration_state = data; // Update local state copy
3489
 
3490
  // Check for new logs first
 
3508
  stopDumpBtn.disabled = false;
3509
  startRestoreBtn.disabled = true; // Disable other operation
3510
  stopRestoreBtn.disabled = true;
3511
+ if (!dumpProgressSection.classList.contains('hidden')) {
3512
+ dumpProgressSection.classList.remove('hidden'); // Ensure visible
3513
+ }
3514
  } else if (data.operation === 'restore') {
3515
  updateRestoreProgress(data);
3516
  // Ensure buttons reflect running state
 
3518
  stopRestoreBtn.disabled = false;
3519
  startDumpBtn.disabled = true; // Disable other operation
3520
  stopDumpBtn.disabled = true;
3521
+ if (!restoreProgressSection.classList.contains('hidden')) {
3522
+ restoreProgressSection.classList.remove('hidden'); // Ensure visible
3523
+ }
3524
+ }
3525
+ // If the process just started, start the timer
3526
+ if (!previousState.running && data.running) {
3527
+ startElapsedTimeCounter();
3528
+ startStatusUpdates(); // Ensure updates continue
3529
+ }
3530
+
3531
+ } else { // Not running
3532
+ // If it *was* running previously, stop timers/updates
3533
+ if (previousState.running && !data.running) {
3534
+ stopStatusUpdates();
3535
+ stopElapsedTimeCounter();
3536
  }
 
 
 
 
3537
 
3538
  // Check if dump just completed
3539
+ if (data.dump_completed && previousState.running && previousState.operation === 'dump') {
3540
  statusBadge.className = 'status-badge success';
3541
  statusBadge.textContent = 'Dump Complete';
3542
  dumpStatusElement.textContent = 'Completed';
3543
  dumpStatusElement.className = 'badge success';
3544
+ // Ensure final size/rate is displayed
3545
+ updateDumpProgress(data);
3546
  // Enable download button
3547
  if (data.dump_file) {
3548
  const downloadPath = `/downloads/${data.dump_file.split(/[\\/]/).pop()}`;
 
3562
  loadServerBackups();
3563
  }
3564
  // Check if restore just completed
3565
+ else if (data.restore_completed && previousState.running && previousState.operation === 'restore') {
3566
  statusBadge.className = 'status-badge success';
3567
  statusBadge.textContent = 'Restore Complete';
3568
  restoreStatusElement.textContent = 'Completed';
 
3579
  startDumpBtn.disabled = false; // Re-enable dump
3580
  }
3581
  // Handle cases where process stopped unexpectedly or was stopped manually
3582
+ else if (previousState.running && !data.running) {
3583
+ const stoppedOperation = previousState.operation; // Use previous state's operation
3584
  statusBadge.className = 'status-badge warning';
3585
  statusBadge.textContent = `${stoppedOperation.charAt(0).toUpperCase() + stoppedOperation.slice(1)} Stopped`;
3586
 
 
3602
  }
3603
  }
3604
  // Otherwise, we are truly idle
3605
+ else if (!data.running) {
3606
  statusBadge.className = 'status-badge idle';
3607
  statusBadge.textContent = 'Idle';
3608
  startDumpBtn.disabled = false;
 
3660
  }
3661
  // Estimate progress based on tables completed (if total is known, otherwise just show activity)
3662
  // This is a rough estimate as table sizes vary greatly.
 
3663
  if (data.progress.tables_completed > 0) {
3664
  // Simple visual indication of progress, not accurate percentage
3665
  const pseudoPercent = Math.min(99, Math.max(5, (data.progress.tables_completed % 20) * 5)); // Cycle 5-99% based on table count
 
3692
  // Load server backups
3693
  await loadServerBackups();
3694
  // Initial status check
3695
+ await updateStatus(); // This also updates migration_state
3696
  // Add initial log check
3697
  await checkForNewLogs();
3698
 
3699
+ // If a process was running when the page loaded, sync UI and start updates
3700
  if (migration_state && migration_state.running) {
3701
  startStatusUpdates();
3702
  startElapsedTimeCounter(); // Restart timer based on backend start_time
 
3706
  startDumpBtn.disabled = true;
3707
  startRestoreBtn.disabled = true;
3708
  if (migration_state.dump_file) dumpFilePathElement.textContent = migration_state.dump_file;
3709
+ // Restore chart data if possible (limited history)
3710
+ // This part is complex and might require storing recent points in backend state
3711
  } else if (migration_state.operation === 'restore') {
3712
  restoreProgressSection.classList.remove('hidden');
3713
  stopRestoreBtn.disabled = false;
 
3721
 
3722
  // Restore button click (from dump section)
3723
  gotoRestoreFromDumpBtn.addEventListener('click', () => {
3724
+ // Fetch latest status to get the correct dump file path
3725
+ fetch('/status').then(res => res.json()).then(data => {
3726
+ if (data && data.dump_file) {
3727
+ const dumpPath = data.dump_file;
3728
+ const options = serverBackupFile.options;
3729
+ let found = false;
3730
+ for (let i = 0; i < options.length; i++) {
3731
+ if (options[i].value === dumpPath) {
3732
+ serverBackupFile.selectedIndex = i;
3733
+ found = true;
3734
+ break;
3735
+ }
3736
  }
3737
+ if (!found) {
3738
+ showToast('warning', 'Backup Not Found', 'The completed backup file is not in the dropdown list. Refreshing list...');
3739
+ loadServerBackups().then(() => { // Reload and try again
3740
+ for (let i = 0; i < serverBackupFile.options.length; i++) {
3741
+ if (serverBackupFile.options[i].value === dumpPath) {
3742
+ serverBackupFile.selectedIndex = i;
3743
+ break;
3744
+ }
3745
+ }
3746
+ });
3747
+ }
3748
+ // Switch to restore tab
3749
+ document.querySelector('.tab[data-tab="restore"]').click();
3750
+ updateRestoreCommandPreview(); // Update preview with selected file
3751
+ } else {
3752
+ showToast('error', 'Error', 'Cannot determine which backup to restore.');
3753
  }
3754
+ }).catch(err => {
3755
+ console.error("Error fetching status for restore button:", err);
3756
+ showToast('error', 'Error', 'Could not get latest status.');
3757
+ });
 
 
 
 
 
 
3758
  });
3759
  });
3760
  </script>
 
4143
  "message": "Process stop initiated successfully" # Changed message slightly
4144
  })
4145
  else:
4146
+ # Check if it wasn't running in the first place or if stop failed
4147
  with migration_lock:
4148
  is_running = migration_state["running"]
4149
+ was_process = migration_state["process"] is not None # Check if we *thought* a process existed
4150
+
4151
+ if not is_running and not was_process:
4152
  return JSONResponse(content={
4153
  "success": False, # Technically not an error, but no action taken
4154
  "message": "No process was running to stop"
4155
  })
4156
+ else: # Stop was called, but failed internally or process already gone
4157
+ # The stop_current_process function now returns False on failure or if already stopped
4158
+ # Check logs for specific reason
4159
  return JSONResponse(
4160
+ status_code=200, # Return 200, but indicate potential issue in message
4161
  content={
4162
+ "success": False, # Indicate stop wasn't fully successful *now*
4163
+ "message": "Stop command executed. Check logs for termination status."
4164
  })
4165
  except Exception as e:
4166
  log_message(f"Failed to stop process via endpoint: {str(e)}", "error")
 
4271
  if __name__ == "__main__":
4272
  import uvicorn
4273
  # Use reload=True for development, but turn off for production
4274
+ # Assuming the file is named main.py for reload to work correctly
4275
  uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=True)
4276
  # For production: uvicorn.run(app, host="0.0.0.0", port=7860)