Spaces:
Sleeping
Sleeping
| from fastapi import FastAPI, Request, BackgroundTasks, HTTPException | |
| from fastapi.responses import HTMLResponse, FileResponse, JSONResponse | |
| from fastapi.templating import Jinja2Templates | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.middleware.cors import CORSMiddleware | |
| import os | |
| import re | |
| import subprocess | |
| import threading | |
| import time | |
| import json | |
| import datetime | |
| import uuid | |
| import shutil | |
| from typing import Dict, Any, Optional, List | |
| from pathlib import Path | |
| import psycopg2 | |
| import logging | |
| # Add these imports | |
| import signal | |
| # import os # os is already imported | |
| # import time # time is already imported | |
| # Setup logging | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", | |
| ) | |
| logger = logging.getLogger("pgmigrator") | |
| # Initialize FastAPI app | |
| app = FastAPI(title="TimescaleDB Migration Tool") | |
| # Enable CORS | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Create necessary directories | |
| os.makedirs("templates", exist_ok=True) | |
| os.makedirs("dumps", exist_ok=True) | |
| # Setup templates | |
| templates = Jinja2Templates(directory="templates") | |
| # Create a static files directory for downloads | |
| static_dir = Path("dumps") | |
| static_dir.mkdir(exist_ok=True) | |
| app.mount("/downloads", StaticFiles(directory="dumps"), name="downloads") | |
| # Global state for migration | |
| migration_state = { | |
| "id": str(uuid.uuid4()), | |
| "running": False, | |
| "operation": None, # "dump" or "restore" | |
| "start_time": None, | |
| "end_time": None, | |
| "dump_file": None, | |
| "dump_file_size": 0, | |
| "previous_size": 0, | |
| "dump_completed": False, | |
| "restore_completed": False, | |
| "last_activity": time.time(), | |
| "log": [], | |
| "process": None, | |
| "chunk_info_internal": None, # NEW: internal TSDB chunk details (not returned to client) | |
| "progress": { | |
| "current_table": None, | |
| "tables_completed": 0, | |
| "total_tables": 0, | |
| "current_size_mb": 0, | |
| "growth_rate_mb_per_sec": 0, | |
| "estimated_time_remaining": None, | |
| "percent_complete": 0, | |
| "total_expected_bytes": 0, # NEW: expected total bytes from chunk map | |
| "bytes_completed": 0, # NEW: bytes completed (chunk-based) | |
| "chunks_completed": 0, # NEW: chunks completed (count) | |
| "chunks_total": 0, # NEW: total chunks (from chunk map) | |
| "counted_chunk_names": [] # NEW: to prevent double counting (internal) | |
| } | |
| } | |
| # Lock for updating global state | |
| migration_lock = threading.Lock() | |
| def log_message(message: str, level: str = "info", command: str = None): | |
| """Add timestamped log message with level""" | |
| timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| log_entry = { | |
| "timestamp": timestamp, | |
| "message": message, | |
| "level": level, | |
| "command": command, | |
| "id": len(migration_state["log"]) | |
| } | |
| with migration_lock: | |
| migration_state["log"].append(log_entry) | |
| migration_state["last_activity"] = time.time() | |
| logger.info(f"[{level.upper()}] {message}") | |
| if command: | |
| logger.info(f"Command: {command}") | |
| def test_connection_logic(connection_string: str) -> bool: | |
| """Test a PostgreSQL connection string (internal logic)""" | |
| try: | |
| conn = psycopg2.connect(connection_string) | |
| conn.close() | |
| return True | |
| except Exception as e: | |
| logger.error(f"Connection test failed: {str(e)}") | |
| return False | |
| def get_file_size_mb(file_path: str) -> float: | |
| """Get file size in megabytes""" | |
| try: | |
| size_bytes = os.path.getsize(file_path) | |
| return size_bytes / (1024 * 1024) # Convert to MB | |
| except Exception: | |
| return 0 | |
| def build_timescaledb_chunk_info(conn): | |
| """Build a comprehensive map of all TimescaleDB chunks and their compressed counterparts.""" | |
| info = { | |
| "generated_at": datetime.datetime.utcnow().isoformat() + 'Z', | |
| "summary": { | |
| "chunks_count": 0, | |
| "hypertables_count": 0, | |
| "total_effective_size_bytes": 0 | |
| }, | |
| "name_to_entry": {}, # map of schema.table -> entry | |
| "entries": [] # list of chunk relationship entries | |
| } | |
| try: | |
| with conn.cursor() as cur: | |
| cur.execute(""" | |
| SELECT | |
| ht.id AS hypertable_id, | |
| ht.schema_name || '.' || ht.table_name AS hypertable, | |
| orig.schema_name || '.' || orig.table_name AS original_chunk, | |
| CASE WHEN comp.id IS NOT NULL THEN comp.schema_name || '.' || comp.table_name END AS compressed_chunk, | |
| pg_total_relation_size(orig.schema_name || '.' || orig.table_name) AS original_size_bytes, | |
| CASE WHEN comp.id IS NOT NULL THEN pg_total_relation_size(comp.schema_name || '.' || comp.table_name) END AS compressed_size_bytes, | |
| (orig.compressed_chunk_id IS NOT NULL) AS is_compressed, | |
| (ht.table_name LIKE '_materialized_hypertable_%') AS is_cagg | |
| FROM _timescaledb_catalog.chunk AS orig | |
| JOIN _timescaledb_catalog.hypertable AS ht ON ht.id = orig.hypertable_id | |
| LEFT JOIN _timescaledb_catalog.chunk AS comp ON orig.compressed_chunk_id = comp.id | |
| """) | |
| rows = cur.fetchall() | |
| hyps = set() | |
| total_effective = 0 | |
| for ( | |
| hypertable_id, hypertable, original_chunk, compressed_chunk, | |
| original_size, compressed_size, is_compressed, is_cagg | |
| ) in rows: | |
| effective_size = compressed_size if compressed_size is not None else original_size | |
| entry = { | |
| "hypertable_id": hypertable_id, | |
| "hypertable": hypertable, | |
| "original_chunk": original_chunk, | |
| "compressed_chunk": compressed_chunk, | |
| "original_size_bytes": int(original_size) if original_size is not None else 0, | |
| "compressed_size_bytes": int(compressed_size) if compressed_size is not None else None, | |
| "effective_size_bytes": int(effective_size) if effective_size is not None else 0, | |
| "compression_status": "COMPRESSED" if is_compressed else "UNCOMPRESSED", | |
| "is_cagg": bool(is_cagg) | |
| } | |
| info["entries"].append(entry) | |
| hyps.add(hypertable) | |
| total_effective += entry["effective_size_bytes"] | |
| # Map both names to same entry for fast lookup | |
| info["name_to_entry"][original_chunk] = entry | |
| if compressed_chunk: | |
| info["name_to_entry"][compressed_chunk] = entry | |
| info["summary"]["chunks_count"] = len(info["entries"]) | |
| info["summary"]["hypertables_count"] = len(hyps) | |
| info["summary"]["total_effective_size_bytes"] = total_effective | |
| except Exception as e: | |
| logger.warning(f"Could not build TimescaleDB chunk map: {e}") | |
| return info | |
| def monitor_dump_size(): | |
| """Monitor the dump file size and update state""" | |
| while migration_state["running"] and migration_state["operation"] == "dump": | |
| try: | |
| if migration_state["dump_file"] and os.path.exists(migration_state["dump_file"]): | |
| # Get current file size | |
| current_size = get_file_size_mb(migration_state["dump_file"]) | |
| # Calculate growth rate | |
| elapsed = time.time() - migration_state["start_time"] | |
| if elapsed > 0: | |
| growth_rate = current_size / elapsed # MB/sec | |
| # Update progress state | |
| with migration_lock: | |
| migration_state["dump_file_size"] = current_size | |
| migration_state["progress"]["current_size_mb"] = round(current_size, 2) | |
| migration_state["progress"]["growth_rate_mb_per_sec"] = round(growth_rate, 2) | |
| # Update size change for UI display | |
| size_change = current_size - migration_state["previous_size"] | |
| if size_change > 0: | |
| migration_state["previous_size"] = current_size | |
| except Exception as e: | |
| logger.error(f"Error monitoring dump size: {str(e)}") | |
| time.sleep(1) # Update every second | |
| def run_dump(source_conn: str, file_path: str, options: dict): | |
| """Run pg_dump in a background thread with chunk-based progress tracking.""" | |
| try: | |
| if os.path.exists(file_path): | |
| os.remove(file_path) | |
| env = os.environ.copy() | |
| format_flag = "-F" + options.get("format", "c") | |
| cmd = ["pg_dump", source_conn, format_flag, "-v", "-f", file_path] | |
| if options.get("schema"): | |
| cmd.extend(["-n", options["schema"]]) | |
| if options.get("compression") and options["compression"] != "default": | |
| cmd.extend(["-Z", options["compression"]]) | |
| log_message(f"Starting database dump to {file_path}", "info", " ".join(cmd)) | |
| monitor_thread = threading.Thread(target=monitor_dump_size, daemon=True) | |
| monitor_thread.start() | |
| with migration_lock: | |
| migration_state["start_time"] = time.time() | |
| migration_state["running"] = True | |
| migration_state["operation"] = "dump" | |
| migration_state["dump_file"] = file_path | |
| migration_state["dump_completed"] = False | |
| migration_state["previous_size"] = 0 | |
| preexec_fn_to_use = os.setsid if hasattr(os, 'setsid') else None | |
| process = subprocess.Popen( | |
| cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| env=env, | |
| text=True, | |
| bufsize=1, | |
| universal_newlines=True, | |
| preexec_fn=preexec_fn_to_use | |
| ) | |
| with migration_lock: | |
| migration_state["process"] = process | |
| if process.stderr: | |
| for line in iter(process.stderr.readline, ''): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| log_message(line, "info") | |
| if "dumping contents of table" in line: | |
| qualified = None | |
| try: | |
| quoted = re.findall(r'"([^"]+)"', line) | |
| if len(quoted) >= 2: | |
| qualified = f"{quoted[-2]}.{quoted[-1]}" | |
| else: | |
| m = re.search(r'dumping contents of table\s+([^\s]+)', line, re.IGNORECASE) | |
| if m: | |
| qualified = m.group(1).strip().strip('"') | |
| except Exception as parse_err: | |
| logger.warning(f"Could not parse table name from dump line: {line} ({parse_err})") | |
| if qualified: | |
| with migration_lock: | |
| migration_state["progress"]["current_table"] = qualified | |
| # Chunk-based progress | |
| chunk_info = migration_state.get("chunk_info_internal") | |
| if chunk_info: | |
| entry = chunk_info["name_to_entry"].get(qualified) | |
| if entry: | |
| counted = set(migration_state["progress"].get("counted_chunk_names", [])) | |
| names_to_mark = {entry["original_chunk"]} | |
| if entry["compressed_chunk"]: | |
| names_to_mark.add(entry["compressed_chunk"]) | |
| # only count once | |
| if not (counted & names_to_mark): | |
| migration_state["progress"]["bytes_completed"] += entry["effective_size_bytes"] | |
| migration_state["progress"]["chunks_completed"] += 1 | |
| counted |= names_to_mark | |
| migration_state["progress"]["counted_chunk_names"] = list(counted) | |
| total = migration_state["progress"].get("total_expected_bytes", 0) or 0 | |
| if total > 0: | |
| migration_state["progress"]["percent_complete"] = min( | |
| 99, | |
| int((migration_state["progress"]["bytes_completed"] / total) * 100) | |
| ) | |
| elif migration_state["progress"]["chunks_total"]: | |
| migration_state["progress"]["percent_complete"] = min( | |
| 99, | |
| int(migration_state["progress"]["chunks_completed"] * 100 / migration_state["progress"]["chunks_total"]) | |
| ) | |
| elapsed = time.time() - (migration_state.get("start_time") or time.time()) | |
| if elapsed > 5 and migration_state["progress"]["bytes_completed"] > 0: | |
| rate = migration_state["progress"]["bytes_completed"] / elapsed | |
| remaining = total - migration_state["progress"]["bytes_completed"] | |
| eta = int(remaining / rate) if rate > 0 and total > 0 else None | |
| migration_state["progress"]["estimated_time_remaining"] = eta | |
| # Maintain table count as well (legacy) | |
| migration_state["progress"]["tables_completed"] += 1 | |
| with migration_lock: | |
| if not migration_state["running"]: | |
| break | |
| stdout, stderr = process.communicate() | |
| exit_code = process.returncode | |
| with migration_lock: | |
| if migration_state["running"]: | |
| if exit_code == 0: | |
| final_size = get_file_size_mb(file_path) | |
| migration_state["dump_file_size"] = final_size | |
| migration_state["progress"]["current_size_mb"] = round(final_size, 2) | |
| migration_state["dump_completed"] = True | |
| migration_state["end_time"] = time.time() | |
| migration_state["progress"]["percent_complete"] = 100 | |
| migration_state["progress"]["estimated_time_remaining"] = 0 | |
| total_time = migration_state["end_time"] - migration_state["start_time"] | |
| log_message( | |
| f"Database dump completed successfully. Size: {round(final_size, 2)} MB. Time: {round(total_time, 2)} seconds", | |
| "success" | |
| ) | |
| else: | |
| error_message = stderr or stdout or "Unknown error during dump" | |
| log_message(f"Database dump failed: {error_message}", "error") | |
| migration_state["running"] = False | |
| migration_state["process"] = None | |
| return exit_code == 0 | |
| except Exception as e: | |
| log_message(f"Error during database dump: {str(e)}", "error") | |
| with migration_lock: | |
| migration_state["running"] = False | |
| migration_state["process"] = None | |
| return False | |
| def run_restore(target_conn: str, file_path: str, options: dict): | |
| """Run pg_restore in a background thread with chunk-based progress tracking.""" | |
| try: | |
| if not os.path.exists(file_path): | |
| log_message(f"Dump file not found: {file_path}", "error") | |
| with migration_lock: | |
| migration_state["running"] = False | |
| return False | |
| env = os.environ.copy() | |
| if options.get("timescaledb_pre_restore", True): | |
| pre_restore_cmd = ["psql", target_conn, "-c", "SELECT timescaledb_pre_restore();"] | |
| log_message("Running timescaledb_pre_restore()", "info", " ".join(pre_restore_cmd)) | |
| pre_restore_process = subprocess.Popen( | |
| pre_restore_cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| env=env, | |
| text=True | |
| ) | |
| pre_restore_stdout, pre_restore_stderr = pre_restore_process.communicate() | |
| if pre_restore_process.returncode != 0: | |
| log_message(f"Pre-restore failed: {pre_restore_stderr or pre_restore_stdout}", "error") | |
| with migration_lock: | |
| migration_state["running"] = False | |
| return False | |
| cmd = ["pg_restore", "-d", target_conn, "-v"] | |
| if options.get("no_owner", True): | |
| cmd.append("--no-owner") | |
| if options.get("clean", False): | |
| cmd.append("--clean") | |
| # Default OFF now: | |
| if options.get("single_transaction", False): | |
| cmd.append("--single-transaction") | |
| cmd.append(file_path) | |
| log_message(f"Starting database restore from {file_path}", "info", " ".join(cmd)) | |
| with migration_lock: | |
| migration_state["start_time"] = time.time() | |
| migration_state["running"] = True | |
| migration_state["operation"] = "restore" | |
| migration_state["restore_completed"] = False | |
| migration_state["progress"]["tables_completed"] = 0 | |
| preexec_fn_to_use = os.setsid if hasattr(os, 'setsid') else None | |
| process = subprocess.Popen( | |
| cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| env=env, | |
| text=True, | |
| bufsize=1, | |
| universal_newlines=True, | |
| preexec_fn=preexec_fn_to_use | |
| ) | |
| with migration_lock: | |
| migration_state["process"] = process | |
| if process.stderr: | |
| for line in iter(process.stderr.readline, ''): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| log_message(line, "info") | |
| if re.search(r'(processing|restoring)\s+data\s+for\s+table', line, re.IGNORECASE): | |
| qualified = None | |
| try: | |
| quoted = re.findall(r'"([^"]+)"', line) | |
| if len(quoted) >= 2: | |
| qualified = f"{quoted[-2]}.{quoted[-1]}" | |
| else: | |
| m = re.search(r'(processing|restoring)\s+data\s+for\s+table\s+([^\s]+)', line, re.IGNORECASE) | |
| if m: | |
| qualified = m.group(2).strip().strip('"') | |
| except Exception as parse_err: | |
| logger.warning(f"Error parsing restore line '{line}': {parse_err}") | |
| if qualified: | |
| with migration_lock: | |
| migration_state["progress"]["current_table"] = qualified | |
| chunk_info = migration_state.get("chunk_info_internal") | |
| if chunk_info: | |
| entry = chunk_info["name_to_entry"].get(qualified) | |
| if entry: | |
| counted = set(migration_state["progress"].get("counted_chunk_names", [])) | |
| names_to_mark = {entry["original_chunk"]} | |
| if entry["compressed_chunk"]: | |
| names_to_mark.add(entry["compressed_chunk"]) | |
| if not (counted & names_to_mark): | |
| migration_state["progress"]["bytes_completed"] += entry["effective_size_bytes"] | |
| migration_state["progress"]["chunks_completed"] += 1 | |
| counted |= names_to_mark | |
| migration_state["progress"]["counted_chunk_names"] = list(counted) | |
| total = migration_state["progress"].get("total_expected_bytes", 0) or 0 | |
| if total > 0: | |
| migration_state["progress"]["percent_complete"] = min( | |
| 99, | |
| int((migration_state["progress"]["bytes_completed"] / total) * 100) | |
| ) | |
| elif migration_state["progress"]["chunks_total"]: | |
| migration_state["progress"]["percent_complete"] = min( | |
| 99, | |
| int(migration_state["progress"]["chunks_completed"] * 100 / migration_state["progress"]["chunks_total"]) | |
| ) | |
| elapsed = time.time() - (migration_state.get("start_time") or time.time()) | |
| if elapsed > 5 and migration_state["progress"]["bytes_completed"] > 0: | |
| rate = migration_state["progress"]["bytes_completed"] / elapsed | |
| remaining = total - migration_state["progress"]["bytes_completed"] | |
| eta = int(remaining / rate) if rate > 0 and total > 0 else None | |
| migration_state["progress"]["estimated_time_remaining"] = eta | |
| # Maintain legacy counter | |
| migration_state["progress"]["tables_completed"] += 1 | |
| with migration_lock: | |
| if not migration_state["running"]: | |
| break | |
| stdout, stderr = process.communicate() | |
| exit_code = process.returncode | |
| post_restore_success = True | |
| if exit_code == 0 and options.get("timescaledb_post_restore", True): | |
| post_restore_cmd = ["psql", target_conn, "-c", "SELECT timescaledb_post_restore(); ANALYZE;"] | |
| log_message("Running timescaledb_post_restore() and ANALYZE", "info", " ".join(post_restore_cmd)) | |
| post_restore_process = subprocess.Popen( | |
| post_restore_cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| env=env, | |
| text=True | |
| ) | |
| post_restore_stdout, post_restore_stderr = post_restore_process.communicate() | |
| if post_restore_process.returncode != 0: | |
| log_message(f"Post-restore failed: {post_restore_stderr or post_restore_stdout}", "error") | |
| post_restore_success = False | |
| with migration_lock: | |
| if migration_state["running"]: | |
| if exit_code == 0 and post_restore_success: | |
| migration_state["restore_completed"] = True | |
| migration_state["end_time"] = time.time() | |
| migration_state["progress"]["percent_complete"] = 100 | |
| migration_state["progress"]["estimated_time_remaining"] = 0 | |
| total_time = migration_state["end_time"] - migration_state["start_time"] | |
| log_message( | |
| f"Database restore completed successfully. Time: {round(total_time, 2)} seconds", | |
| "success" | |
| ) | |
| elif exit_code != 0: | |
| error_message = stderr or stdout or "Unknown error during restore" | |
| log_message(f"Database restore failed: {error_message}", "error") | |
| migration_state["running"] = False | |
| migration_state["process"] = None | |
| return exit_code == 0 and post_restore_success | |
| except Exception as e: | |
| log_message(f"Error during database restore: {str(e)}", "error") | |
| with migration_lock: | |
| migration_state["running"] = False | |
| migration_state["process"] = None | |
| return False | |
| # Replace the old stop_current_process with the new one | |
| def stop_current_process(): | |
| """Stop the current process with improved forceful termination""" | |
| with migration_lock: | |
| if migration_state["process"] and migration_state["running"]: | |
| try: | |
| process = migration_state["process"] | |
| pid = process.pid | |
| operation = migration_state["operation"] | |
| log_message(f"Attempting to stop {operation} process (PID: {pid})...", "warning") | |
| # Check if process is already terminated before trying to stop | |
| if process.poll() is not None: | |
| log_message(f"{operation.capitalize()} process (PID: {pid}) already terminated.", "info") | |
| migration_state["process"] = None | |
| migration_state["running"] = False | |
| return True | |
| # First try graceful termination (SIGTERM) | |
| process.terminate() | |
| # Wait up to 3 seconds for graceful termination | |
| for _ in range(30): # 3 seconds with 0.1s checks | |
| if process.poll() is not None: # Process has terminated | |
| log_message(f"{operation.capitalize()} process (PID: {pid}) terminated gracefully (SIGTERM)", "warning") | |
| break | |
| time.sleep(0.1) | |
| else: # Loop finished without break, process still running | |
| # If still running, force kill with SIGKILL | |
| if process.poll() is None: | |
| log_message(f"Process (PID: {pid}) not responding to graceful termination, forcing kill (SIGKILL)...", "warning") | |
| # Try to kill process group (more thorough) - Unix only | |
| killed_pg = False | |
| if hasattr(os, 'killpg') and hasattr(os, 'getpgid'): | |
| try: | |
| # On Unix systems, negative PID means kill process group | |
| os.killpg(os.getpgid(pid), signal.SIGKILL) | |
| killed_pg = True | |
| log_message(f"Sent SIGKILL to process group of PID {pid}", "warning") | |
| except ProcessLookupError: | |
| log_message(f"Process group for PID {pid} not found (already terminated?).", "info") | |
| # Process likely died between poll and killpg, proceed as if killed | |
| killed_pg = True # Treat as success for logic below | |
| except Exception as kill_err: | |
| log_message(f"Error killing process group for PID {pid}: {kill_err}. Falling back to direct kill.", "error") | |
| # Fallback to direct kill if process group kill fails | |
| process.kill() | |
| log_message(f"Sent SIGKILL directly to PID {pid}", "warning") | |
| else: # Not on Unix or functions unavailable | |
| process.kill() | |
| log_message(f"Sent SIGKILL directly to PID {pid} (killpg not available)", "warning") | |
| # Wait a bit for kill to take effect | |
| time.sleep(0.5) | |
| process.poll() # Update process status after kill attempt | |
| # Final check | |
| if process.poll() is None: | |
| log_message(f"Warning: Process (PID: {pid}) may not have terminated successfully after SIGKILL", "error") | |
| # Even if termination is uncertain, update state to reflect stop attempt | |
| migration_state["process"] = None | |
| migration_state["running"] = False | |
| migration_state["end_time"] = time.time() | |
| return False # Indicate potential failure | |
| else: | |
| log_message(f"Database {operation} operation (PID: {pid}) stopped", "warning") | |
| migration_state["process"] = None | |
| migration_state["running"] = False | |
| migration_state["end_time"] = time.time() | |
| return True | |
| except ProcessLookupError: | |
| # This can happen if the process terminated between the initial check and trying to kill it | |
| log_message(f"Process (PID: {pid}) already terminated before stop action completed.", "info") | |
| migration_state["process"] = None | |
| migration_state["running"] = False | |
| migration_state["end_time"] = time.time() | |
| return True | |
| except Exception as e: | |
| log_message(f"Error stopping process: {str(e)}", "error") | |
| # Force state update even on error | |
| migration_state["process"] = None | |
| migration_state["running"] = False | |
| migration_state["end_time"] = time.time() | |
| return False | |
| else: | |
| # No process was running or associated with the state | |
| log_message("Stop command received, but no process found in current state.", "info") | |
| # Ensure state reflects not running if it wasn't already | |
| if migration_state["running"]: | |
| migration_state["running"] = False | |
| migration_state["process"] = None | |
| return False # Indicate no action was needed/taken on a process | |
| async def home(request: Request): | |
| """Home page with migration UI""" | |
| # In a real app, load from file, but for simplicity, keep it inline. | |
| # Create the file if it doesn't exist (e.g., first run) | |
| if not os.path.exists("templates/index.html"): | |
| with open("templates/index.html", "w") as f: | |
| f.write("Placeholder - HTML will be generated") # Basic placeholder | |
| # The actual HTML content (NOTE: Frontend changes mentioned in prompt are NOT applied here, only backend) | |
| html_content = """<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>TimescaleDB Migrator</title> | |
| <!-- Fonts --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> | |
| <!-- Icons --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| :root { | |
| /* Colors */ | |
| --color-bg: #0f172a; | |
| --color-bg-darker: #0c1425; | |
| --color-bg-lighter: #1e293b; | |
| --color-primary: #06b6d4; | |
| --color-primary-dark: #0891b2; | |
| --color-primary-light: #22d3ee; | |
| --color-secondary: #8b5cf6; | |
| --color-secondary-dark: #7c3aed; | |
| --color-secondary-light: #a78bfa; | |
| --color-success: #10b981; | |
| --color-warning: #f59e0b; | |
| --color-danger: #ef4444; | |
| --color-info: #3b82f6; | |
| --color-text: #f8fafc; | |
| --color-text-muted: #94a3b8; | |
| --color-border: #334155; | |
| /* Gradients */ | |
| --gradient-primary: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); | |
| --gradient-dark: linear-gradient(135deg, var(--color-bg-darker), var(--color-bg)); | |
| /* Shadows */ | |
| --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); | |
| --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); | |
| --shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); | |
| --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); | |
| --shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06); | |
| /* Typography */ | |
| --font-family-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| --font-family-mono: 'JetBrains Mono', monospace; | |
| /* Other */ | |
| --border-radius-sm: 0.25rem; | |
| --border-radius: 0.375rem; | |
| --border-radius-md: 0.5rem; | |
| --border-radius-lg: 0.75rem; | |
| --border-radius-xl: 1rem; | |
| --border-radius-2xl: 1.5rem; | |
| --border-radius-full: 9999px; | |
| /* Animation */ | |
| --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); | |
| --transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1); | |
| --transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1); | |
| /* Dimensions */ | |
| --header-height: 4rem; | |
| --sidebar-width: 16rem; | |
| } | |
| /* Base styles */ | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: var(--font-family-sans); | |
| background-color: var(--color-bg); | |
| color: var(--color-text); | |
| font-size: 0.875rem; | |
| line-height: 1.5; | |
| overflow-x: hidden; | |
| } | |
| h1, h2, h3, h4, h5, h6 { | |
| font-weight: 600; | |
| line-height: 1.25; | |
| margin-bottom: 1rem; | |
| } | |
| h1 { | |
| font-size: 1.875rem; | |
| font-weight: 700; | |
| } | |
| h2 { | |
| font-size: 1.5rem; | |
| } | |
| h3 { | |
| font-size: 1.25rem; | |
| } | |
| h4 { | |
| font-size: 1rem; | |
| margin-bottom: 0.75rem; | |
| } | |
| p { | |
| margin-bottom: 1rem; | |
| } | |
| a { | |
| color: var(--color-primary); | |
| text-decoration: none; | |
| transition: color var(--transition-fast); | |
| } | |
| a:hover { | |
| color: var(--color-primary-light); | |
| } | |
| ul { | |
| padding-left: 1.5rem; | |
| margin-bottom: 1rem; | |
| } | |
| li { | |
| margin-bottom: 0.5rem; | |
| } | |
| /* Layout */ | |
| .app-container { | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 100vh; | |
| max-width: 100vw; | |
| overflow-x: hidden; | |
| } | |
| .main-header { | |
| position: sticky; | |
| top: 0; | |
| z-index: 50; | |
| height: var(--header-height); | |
| background: var(--gradient-dark); | |
| backdrop-filter: blur(8px); | |
| border-bottom: 1px solid var(--color-border); | |
| padding: 0 1.5rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .logo { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| font-weight: 700; | |
| font-size: 1.25rem; | |
| background: var(--gradient-primary); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .logo i { | |
| font-size: 1.25rem; | |
| color: var(--color-primary); | |
| -webkit-text-fill-color: var(--color-primary); | |
| } | |
| .main-content { | |
| flex: 1; | |
| padding: 1.5rem; | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| width: 100%; | |
| } | |
| .app-status { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .status-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| padding: 0.25rem 0.75rem; | |
| border-radius: var(--border-radius-full); | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| box-shadow: var(--shadow-sm); | |
| } | |
| .status-badge.idle { | |
| background-color: rgba(148, 163, 184, 0.2); | |
| color: var(--color-text-muted); | |
| } | |
| .status-badge.running { | |
| background-color: rgba(16, 185, 129, 0.2); | |
| color: var(--color-success); | |
| } | |
| .status-badge.warning { | |
| background-color: rgba(245, 158, 11, 0.2); | |
| color: var(--color-warning); | |
| } | |
| .status-badge.error { | |
| background-color: rgba(239, 68, 68, 0.2); | |
| color: var(--color-danger); | |
| } | |
| .status-badge.success { /* Added for completed state */ | |
| background-color: rgba(16, 185, 129, 0.2); | |
| color: var(--color-success); | |
| } | |
| .pulse-dot { | |
| display: inline-block; | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| margin-right: 0.5rem; | |
| background-color: currentColor; | |
| position: relative; | |
| } | |
| .pulse-dot::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| border-radius: 50%; | |
| box-shadow: 0 0 0 0 currentColor; | |
| animation: pulse 1.5s infinite; | |
| } | |
| @keyframes pulse { | |
| 0% { | |
| transform: scale(0.95); | |
| box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); | |
| } | |
| 70% { | |
| transform: scale(1); | |
| box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); | |
| } | |
| 100% { | |
| transform: scale(0.95); | |
| box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); | |
| } | |
| } | |
| /* Cards and panels */ | |
| .card { | |
| background-color: var(--color-bg-lighter); | |
| border-radius: var(--border-radius-lg); | |
| box-shadow: var(--shadow-md); | |
| overflow: hidden; | |
| transition: transform var(--transition-normal), box-shadow var(--transition-normal); | |
| border: 1px solid var(--color-border); | |
| height: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: var(--shadow-lg); | |
| } | |
| .card-header { | |
| padding: 1.25rem 1.5rem; | |
| border-bottom: 1px solid var(--color-border); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .card-title { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| margin-bottom: 0; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .card-title i { | |
| color: var(--color-primary); | |
| } | |
| .card-subtitle { | |
| font-size: 0.875rem; | |
| color: var(--color-text-muted); | |
| margin-top: 0.25rem; | |
| } | |
| .card-body { | |
| padding: 1.5rem; | |
| flex: 1; | |
| } | |
| .card-footer { | |
| padding: 1.25rem 1.5rem; | |
| border-top: 1px solid var(--color-border); | |
| background-color: rgba(0, 0, 0, 0.1); | |
| } | |
| /* Form elements */ | |
| .form-group { | |
| margin-bottom: 1.25rem; | |
| } | |
| .form-label { | |
| display: block; | |
| margin-bottom: 0.5rem; | |
| font-weight: 500; | |
| color: var(--color-text); | |
| } | |
| .form-control { | |
| width: 100%; | |
| padding: 0.625rem 0.875rem; | |
| background-color: var(--color-bg-darker); | |
| border: 1px solid var(--color-border); | |
| border-radius: var(--border-radius); | |
| color: var(--color-text); | |
| font-family: var(--font-family-sans); | |
| font-size: 0.875rem; | |
| line-height: 1.5; | |
| transition: border-color var(--transition-fast), box-shadow var(--transition-fast); | |
| } | |
| .form-control:focus { | |
| outline: none; | |
| border-color: var(--color-primary); | |
| box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.25); | |
| } | |
| .form-control::placeholder { | |
| color: var(--color-text-muted); | |
| } | |
| .form-text { | |
| margin-top: 0.375rem; | |
| font-size: 0.75rem; | |
| color: var(--color-text-muted); | |
| } | |
| .form-check { | |
| display: flex; | |
| align-items: center; | |
| margin-bottom: 0.5rem; | |
| } | |
| .form-check-input { | |
| margin-right: 0.5rem; | |
| cursor: pointer; | |
| } | |
| .form-check label { | |
| cursor: pointer; | |
| } | |
| /* Toggle Password Visibility */ | |
| .input-group { | |
| display: flex; | |
| position: relative; | |
| } | |
| .input-group .form-control { | |
| flex: 1; | |
| border-radius: var(--border-radius) 0 0 var(--border-radius); | |
| } | |
| .input-group-append { | |
| display: flex; | |
| } | |
| .input-group-text { | |
| display: flex; | |
| align-items: center; | |
| padding: 0.625rem 0.875rem; | |
| background-color: var(--color-bg-darker); | |
| border: 1px solid var(--color-border); | |
| border-left: none; | |
| border-radius: 0 var(--border-radius) var(--border-radius) 0; | |
| color: var(--color-text-muted); | |
| cursor: pointer; | |
| transition: background-color var(--transition-fast); | |
| } | |
| .input-group-text:hover { | |
| background-color: rgba(6, 182, 212, 0.1); | |
| color: var(--color-primary); | |
| } | |
| /* Buttons */ | |
| .btn { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.5rem; | |
| padding: 0.625rem 1.25rem; | |
| font-size: 0.875rem; | |
| font-weight: 500; | |
| line-height: 1.5; | |
| border-radius: var(--border-radius); | |
| border: none; | |
| cursor: pointer; | |
| transition: all var(--transition-fast); | |
| position: relative; | |
| overflow: hidden; | |
| box-shadow: var(--shadow-sm); | |
| text-decoration: none; | |
| } | |
| .btn::after { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background-color: currentColor; | |
| opacity: 0; | |
| transition: opacity var(--transition-fast); | |
| } | |
| .btn:hover::after { | |
| opacity: 0.1; | |
| } | |
| .btn:focus { | |
| outline: none; | |
| box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.4); | |
| } | |
| .btn:disabled { | |
| opacity: 0.65; | |
| pointer-events: none; | |
| cursor: not-allowed; | |
| } | |
| .btn-primary { | |
| background-color: var(--color-primary); | |
| color: #ffffff; | |
| } | |
| .btn-primary:hover:not(:disabled) { | |
| background-color: var(--color-primary-dark); | |
| } | |
| .btn-secondary { | |
| background-color: var(--color-secondary); | |
| color: #ffffff; | |
| } | |
| .btn-secondary:hover:not(:disabled) { | |
| background-color: var(--color-secondary-dark); | |
| } | |
| .btn-success { | |
| background-color: var(--color-success); | |
| color: #ffffff; | |
| } | |
| .btn-success:hover:not(:disabled) { | |
| background-color: var(--color-success); | |
| filter: brightness(90%); | |
| } | |
| .btn-danger { | |
| background-color: var(--color-danger); | |
| color: #ffffff; | |
| } | |
| .btn-danger:hover:not(:disabled) { | |
| background-color: var(--color-danger); | |
| filter: brightness(90%); | |
| } | |
| .btn-warning { | |
| background-color: var(--color-warning); | |
| color: #ffffff; | |
| } | |
| .btn-info { | |
| background-color: var(--color-info); | |
| color: #ffffff; | |
| } | |
| .btn-outline-primary { | |
| background-color: transparent; | |
| color: var(--color-primary); | |
| border: 1px solid var(--color-primary); | |
| } | |
| .btn-outline-primary:hover:not(:disabled) { | |
| background-color: var(--color-primary); | |
| color: #ffffff; | |
| } | |
| .btn-outline-secondary { | |
| background-color: transparent; | |
| color: var(--color-secondary); | |
| border: 1px solid var(--color-secondary); | |
| } | |
| .btn-outline-secondary:hover:not(:disabled) { | |
| background-color: var(--color-secondary); | |
| color: #ffffff; | |
| } | |
| .btn-outline-danger { | |
| background-color: transparent; | |
| color: var(--color-danger); | |
| border: 1px solid var(--color-danger); | |
| } | |
| .btn-outline-danger:hover:not(:disabled) { | |
| background-color: var(--color-danger); | |
| color: #ffffff; | |
| } | |
| .btn-sm { | |
| padding: 0.375rem 0.75rem; | |
| font-size: 0.75rem; | |
| } | |
| .btn-lg { | |
| padding: 0.75rem 1.5rem; | |
| font-size: 1rem; | |
| } | |
| .btn-icon { | |
| width: 2.25rem; | |
| height: 2.25rem; | |
| padding: 0; | |
| border-radius: var(--border-radius); | |
| } | |
| .btn-icon-sm { | |
| width: 1.75rem; | |
| height: 1.75rem; | |
| font-size: 0.75rem; | |
| } | |
| .btn-icon-lg { | |
| width: 2.75rem; | |
| height: 2.75rem; | |
| font-size: 1.25rem; | |
| } | |
| .btn-block { | |
| display: flex; | |
| width: 100%; | |
| } | |
| /* Tabs */ | |
| .tabs { | |
| display: flex; | |
| border-bottom: 1px solid var(--color-border); | |
| overflow-x: auto; | |
| scrollbar-width: none; | |
| -ms-overflow-style: none; | |
| } | |
| .tabs::-webkit-scrollbar { | |
| display: none; | |
| } | |
| .tab { | |
| padding: 0.875rem 1.25rem; | |
| font-weight: 500; | |
| color: var(--color-text-muted); | |
| border-bottom: 2px solid transparent; | |
| cursor: pointer; | |
| transition: all var(--transition-fast); | |
| white-space: nowrap; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .tab:hover { | |
| color: var(--color-text); | |
| background-color: rgba(255, 255, 255, 0.05); | |
| } | |
| .tab.active { | |
| color: var(--color-primary); | |
| border-bottom-color: var(--color-primary); | |
| } | |
| .tab-content { | |
| display: none; | |
| padding: 1.5rem 0; | |
| } | |
| .tab-content.active { | |
| display: block; | |
| animation: fade-in 0.3s ease-in-out; | |
| } | |
| @keyframes fade-in { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* Terminal */ | |
| .terminal { | |
| background-color: #000; | |
| border-radius: var(--border-radius); | |
| padding: 1rem; | |
| font-family: var(--font-family-mono); | |
| font-size: 0.875rem; | |
| overflow: hidden; | |
| box-shadow: var(--shadow-md); | |
| display: flex; | |
| flex-direction: column; | |
| height: 400px; | |
| border: 1px solid var(--color-border); | |
| } | |
| .terminal-header { | |
| display: flex; | |
| align-items: center; | |
| padding-bottom: 0.75rem; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.1); | |
| margin-bottom: 0.75rem; | |
| } | |
| .terminal-controls { | |
| display: flex; | |
| gap: 0.375rem; | |
| } | |
| .terminal-control { | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| } | |
| .terminal-control.close { | |
| background-color: #ff5f56; | |
| } | |
| .terminal-control.minimize { | |
| background-color: #ffbd2e; | |
| } | |
| .terminal-control.maximize { | |
| background-color: #27c93f; | |
| } | |
| .terminal-title { | |
| flex: 1; | |
| text-align: center; | |
| color: #ddd; | |
| font-size: 0.75rem; | |
| user-select: none; | |
| } | |
| .terminal-body { | |
| flex: 1; | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| color: #ddd; | |
| padding-right: 0.5rem; | |
| } | |
| .terminal-line { | |
| display: flex; | |
| margin-bottom: 0.25rem; | |
| word-break: break-word; | |
| } | |
| .terminal-prompt { | |
| color: var(--color-primary); | |
| margin-right: 0.5rem; | |
| flex-shrink: 0; | |
| font-weight: bold; | |
| min-width: 1em; /* Ensure space for icon */ | |
| text-align: center; | |
| } | |
| .terminal-command { | |
| color: #fff; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .terminal-output { | |
| color: #aaa; | |
| white-space: pre-wrap; | |
| /* padding-left: 1rem; */ /* Removed padding, rely on prompt */ | |
| word-break: break-word; | |
| } | |
| .terminal-error { | |
| color: var(--color-danger); | |
| white-space: pre-wrap; | |
| /* padding-left: 1rem; */ | |
| word-break: break-word; | |
| } | |
| .terminal-success { | |
| color: var(--color-success); | |
| white-space: pre-wrap; | |
| /* padding-left: 1rem; */ | |
| word-break: break-word; | |
| } | |
| .terminal-warning { | |
| color: var(--color-warning); | |
| white-space: pre-wrap; | |
| /* padding-left: 1rem; */ | |
| word-break: break-word; | |
| } | |
| .terminal-body::-webkit-scrollbar { | |
| width: 6px; | |
| height: 6px; | |
| } | |
| .terminal-body::-webkit-scrollbar-track { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 10px; | |
| } | |
| .terminal-body::-webkit-scrollbar-thumb { | |
| background: rgba(255, 255, 255, 0.3); | |
| border-radius: 10px; | |
| } | |
| .terminal-body::-webkit-scrollbar-thumb:hover { | |
| background: rgba(255, 255, 255, 0.5); | |
| } | |
| .terminal-cursor { | |
| display: inline-block; | |
| width: 0.5em; | |
| height: 1em; | |
| background-color: #ddd; | |
| margin-left: 2px; | |
| animation: blink 1s step-end infinite; | |
| } | |
| @keyframes blink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0; } | |
| } | |
| /* Toast notifications */ | |
| .toast-container { | |
| position: fixed; | |
| bottom: 1.5rem; | |
| right: 1.5rem; | |
| z-index: 9999; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.75rem; | |
| max-width: 100%; | |
| } | |
| .toast { | |
| display: flex; | |
| background-color: var(--color-bg-lighter); | |
| border-radius: var(--border-radius); | |
| overflow: hidden; | |
| box-shadow: var(--shadow-lg); | |
| width: 350px; | |
| max-width: calc(100vw - 2rem); | |
| border-left: 4px solid var(--color-primary); | |
| animation: slide-in-right 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; | |
| position: relative; /* Needed for progress bar */ | |
| } | |
| .toast.hide { | |
| animation: slide-out-right 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; | |
| } | |
| .toast-body { | |
| padding: 1rem; | |
| flex: 1; | |
| } | |
| .toast-title { | |
| font-weight: 600; | |
| margin-bottom: 0.25rem; | |
| font-size: 0.875rem; | |
| } | |
| .toast-message { | |
| font-size: 0.75rem; | |
| color: var(--color-text-muted); | |
| } | |
| .toast-close { | |
| background: none; | |
| border: none; | |
| color: var(--color-text-muted); | |
| padding: 0.5rem; | |
| cursor: pointer; | |
| align-self: flex-start; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: color var(--transition-fast); | |
| } | |
| .toast-close:hover { | |
| color: var(--color-text); | |
| } | |
| .toast-progress { | |
| height: 4px; | |
| background-color: var(--color-primary); | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; | |
| width: 100%; | |
| transform-origin: left; | |
| animation: toast-progress 5s linear; | |
| } | |
| @keyframes toast-progress { | |
| 0% { transform: scaleX(1); } | |
| 100% { transform: scaleX(0); } | |
| } | |
| .toast.success { | |
| border-left-color: var(--color-success); | |
| } | |
| .toast.success .toast-progress { | |
| background-color: var(--color-success); | |
| } | |
| .toast.warning { | |
| border-left-color: var(--color-warning); | |
| } | |
| .toast.warning .toast-progress { | |
| background-color: var(--color-warning); | |
| } | |
| .toast.error { | |
| border-left-color: var(--color-danger); | |
| } | |
| .toast.error .toast-progress { | |
| background-color: var(--color-danger); | |
| } | |
| .toast.info { | |
| border-left-color: var(--color-info); | |
| } | |
| .toast.info .toast-progress { | |
| background-color: var(--color-info); | |
| } | |
| @keyframes slide-in-right { | |
| 0% { transform: translateX(100%); opacity: 0; } | |
| 100% { transform: translateX(0); opacity: 1; } | |
| } | |
| @keyframes slide-out-right { | |
| 0% { transform: translateX(0); opacity: 1; } | |
| 100% { transform: translateX(100%); opacity: 0; } | |
| } | |
| /* Progress visualization */ | |
| .progress-container { | |
| margin-bottom: 1.25rem; | |
| } | |
| .progress-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 0.5rem; | |
| } | |
| .progress-title { | |
| font-weight: 500; | |
| font-size: 0.875rem; | |
| } | |
| .progress-value { | |
| font-size: 0.75rem; | |
| color: var(--color-text-muted); | |
| } | |
| .progress-bar-container { | |
| height: 0.5rem; | |
| background-color: var(--color-bg-darker); | |
| border-radius: var(--border-radius-full); | |
| overflow: hidden; | |
| } | |
| .progress-bar { | |
| height: 100%; | |
| border-radius: var(--border-radius-full); | |
| background: var(--gradient-primary); | |
| width: 0%; | |
| transition: width var(--transition-normal); | |
| } | |
| .progress-bar.animated { | |
| background-size: 30px 30px; | |
| background-image: linear-gradient( | |
| 135deg, | |
| rgba(255, 255, 255, 0.15) 25%, | |
| transparent 25%, | |
| transparent 50%, | |
| rgba(255, 255, 255, 0.15) 50%, | |
| rgba(255, 255, 255, 0.15) 75%, | |
| transparent 75%, | |
| transparent | |
| ); | |
| animation: progress-bar-stripes 1s linear infinite; | |
| } | |
| @keyframes progress-bar-stripes { | |
| from { background-position: 30px 0; } | |
| to { background-position: 0 0; } | |
| } | |
| /* Log viewer */ | |
| .logs-container { | |
| margin-top: 1.5rem; | |
| height: 350px; | |
| border: 1px solid var(--color-border); | |
| border-radius: var(--border-radius); | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| background-color: var(--color-bg-darker); | |
| } | |
| .logs-header { | |
| padding: 0.75rem 1rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-bottom: 1px solid var(--color-border); | |
| } | |
| .logs-title { | |
| font-weight: 600; | |
| font-size: 0.875rem; | |
| } | |
| .logs-filters { | |
| display: flex; | |
| gap: 0.375rem; | |
| } | |
| .log-filter { | |
| padding: 0.25rem 0.5rem; | |
| font-size: 0.75rem; | |
| background: none; | |
| border: none; | |
| color: var(--color-text-muted); | |
| border-radius: var(--border-radius-sm); | |
| cursor: pointer; | |
| transition: all var(--transition-fast); | |
| } | |
| .log-filter:hover { | |
| background-color: var(--color-bg-lighter); | |
| color: var(--color-text); | |
| } | |
| .log-filter.active { | |
| background-color: rgba(6, 182, 212, 0.1); | |
| color: var(--color-primary); | |
| font-weight: 500; | |
| } | |
| .logs-body { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 0.5rem; | |
| font-family: var(--font-family-mono); | |
| font-size: 0.75rem; | |
| } | |
| .log-entry { | |
| display: flex; | |
| gap: 0.5rem; | |
| padding: 0.25rem 0; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.05); | |
| line-height: 1.5; | |
| animation: fade-in 0.2s ease-in-out; | |
| } | |
| .log-entry:last-child { | |
| border-bottom: none; | |
| } | |
| .log-timestamp { | |
| color: var(--color-text-muted); | |
| flex-shrink: 0; | |
| user-select: none; | |
| } | |
| .log-level { | |
| flex-shrink: 0; | |
| padding: 0.125rem 0.25rem; | |
| border-radius: var(--border-radius-sm); | |
| font-size: 0.625rem; | |
| text-transform: uppercase; | |
| font-weight: 600; | |
| } | |
| .log-level.info { | |
| background-color: rgba(59, 130, 246, 0.2); | |
| color: var(--color-info); | |
| } | |
| .log-level.success { | |
| background-color: rgba(16, 185, 129, 0.2); | |
| color: var(--color-success); | |
| } | |
| .log-level.warning { | |
| background-color: rgba(245, 158, 11, 0.2); | |
| color: var(--color-warning); | |
| } | |
| .log-level.error { | |
| background-color: rgba(239, 68, 68, 0.2); | |
| color: var(--color-danger); | |
| } | |
| .log-message { | |
| flex: 1; | |
| word-break: break-word; | |
| } | |
| /* File size visualization */ | |
| .file-size-visualization { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1rem; | |
| padding: 1.5rem; | |
| background-color: var(--color-bg-darker); | |
| border-radius: var(--border-radius); | |
| margin-bottom: 1.5rem; | |
| border: 1px solid var(--color-border); | |
| } | |
| .file-size-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .file-size-title { | |
| font-weight: 600; | |
| font-size: 1rem; | |
| } | |
| .file-size-value { | |
| font-size: 2rem; | |
| font-weight: 700; | |
| background: var(--gradient-primary); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .file-size-subtitle { | |
| font-size: 0.875rem; | |
| color: var(--color-text-muted); | |
| margin-top: 0.25rem; | |
| } | |
| .file-size-chart { | |
| height: 120px; | |
| position: relative; | |
| } | |
| .file-size-chart canvas { | |
| height: 100% !important; | |
| width: 100% !important; | |
| } | |
| .file-size-stats { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); | |
| gap: 1rem; | |
| margin-top: 0.5rem; | |
| } | |
| .file-size-stat { | |
| padding: 0.75rem; | |
| background-color: rgba(255, 255, 255, 0.05); | |
| border-radius: var(--border-radius); | |
| border: 1px solid var(--color-border); | |
| } | |
| .file-size-stat-title { | |
| font-size: 0.75rem; | |
| color: var(--color-text-muted); | |
| margin-bottom: 0.25rem; | |
| } | |
| .file-size-stat-value { | |
| font-size: 1.125rem; | |
| font-weight: 600; | |
| } | |
| /* Modal */ | |
| .modal-backdrop { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-color: rgba(0, 0, 0, 0.5); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 9999; | |
| opacity: 0; | |
| visibility: hidden; | |
| transition: opacity var(--transition-normal), visibility var(--transition-normal); | |
| backdrop-filter: blur(5px); | |
| } | |
| .modal-backdrop.show { | |
| opacity: 1; | |
| visibility: visible; | |
| } | |
| .modal { | |
| background-color: var(--color-bg-lighter); | |
| border-radius: var(--border-radius-lg); | |
| box-shadow: var(--shadow-lg); | |
| overflow: hidden; | |
| width: 500px; | |
| max-width: calc(100% - 2rem); | |
| max-height: calc(100vh - 2rem); | |
| display: flex; | |
| flex-direction: column; | |
| transform: scale(0.9); | |
| opacity: 0; | |
| transition: transform var(--transition-normal), opacity var(--transition-normal); | |
| } | |
| .modal-backdrop.show .modal { | |
| transform: scale(1); | |
| opacity: 1; | |
| } | |
| .modal-header { | |
| padding: 1.25rem 1.5rem; | |
| border-bottom: 1px solid var(--color-border); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .modal-title { | |
| font-size: 1.125rem; | |
| font-weight: 600; | |
| margin-bottom: 0; | |
| } | |
| .modal-close { | |
| background: none; | |
| border: none; | |
| color: var(--color-text-muted); | |
| cursor: pointer; | |
| padding: 0.5rem; | |
| transition: color var(--transition-fast); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .modal-close:hover { | |
| color: var(--color-text); | |
| } | |
| .modal-body { | |
| padding: 1.5rem; | |
| overflow-y: auto; | |
| } | |
| .modal-footer { | |
| padding: 1.25rem 1.5rem; | |
| border-top: 1px solid var(--color-border); | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 0.75rem; | |
| } | |
| /* Command visualization */ | |
| .command-visualization { | |
| font-family: var(--font-family-mono); | |
| background-color: var(--color-bg-darker); | |
| border-radius: var(--border-radius); | |
| padding: 1rem; | |
| margin: 1rem 0; | |
| overflow-x: auto; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| line-height: 1.5; | |
| border: 1px solid var(--color-border); | |
| } | |
| .command-part { | |
| color: #ddd; | |
| } | |
| .command-keyword { | |
| color: var(--color-primary); | |
| font-weight: 500; | |
| } | |
| .command-string { | |
| color: var(--color-success); | |
| } | |
| .command-flag { | |
| color: var(--color-warning); | |
| } | |
| .command-option { | |
| color: var(--color-secondary); | |
| } | |
| /* Status cards */ | |
| .status-cards { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 1rem; | |
| margin-bottom: 1.5rem; | |
| } | |
| .status-card { | |
| background-color: var(--color-bg-lighter); | |
| border-radius: var(--border-radius); | |
| padding: 1.25rem; | |
| display: flex; | |
| flex-direction: column; | |
| border: 1px solid var(--color-border); | |
| transition: transform var(--transition-normal), box-shadow var(--transition-normal); | |
| } | |
| .status-card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: var(--shadow-md); | |
| } | |
| .status-card-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| margin-bottom: 1rem; | |
| } | |
| .status-card-title { | |
| font-size: 0.75rem; | |
| text-transform: uppercase; | |
| font-weight: 600; | |
| color: var(--color-text-muted); | |
| letter-spacing: 0.05em; | |
| } | |
| .status-card-icon { | |
| width: 2.5rem; | |
| height: 2.5rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: var(--border-radius); | |
| background-color: rgba(6, 182, 212, 0.1); | |
| color: var(--color-primary); | |
| } | |
| .status-card-icon.warning { | |
| background-color: rgba(245, 158, 11, 0.1); | |
| color: var(--color-warning); | |
| } | |
| .status-card-icon.danger { | |
| background-color: rgba(239, 68, 68, 0.1); | |
| color: var(--color-danger); | |
| } | |
| .status-card-icon.success { | |
| background-color: rgba(16, 185, 129, 0.1); | |
| color: var(--color-success); | |
| } | |
| .status-card-value { | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| margin-bottom: 0.25rem; | |
| } | |
| .status-card-subtitle { | |
| font-size: 0.75rem; | |
| color: var(--color-text-muted); | |
| } | |
| /* Grid layout */ | |
| .grid { | |
| display: grid; | |
| gap: 1.5rem; | |
| } | |
| .grid-cols-1 { | |
| grid-template-columns: 1fr; | |
| } | |
| .grid-cols-2 { | |
| grid-template-columns: repeat(2, 1fr); | |
| } | |
| .grid-cols-3 { | |
| grid-template-columns: repeat(3, 1fr); | |
| } | |
| @media (max-width: 1024px) { | |
| .grid-cols-3 { | |
| grid-template-columns: repeat(2, 1fr); | |
| } | |
| } | |
| @media (max-width: 768px) { | |
| .grid-cols-2, .grid-cols-3 { | |
| grid-template-columns: 1fr; | |
| } | |
| .connection-card { | |
| height: auto !important; /* Adjust height for smaller screens */ | |
| min-height: 220px; | |
| } | |
| } | |
| /* Utility classes */ | |
| .d-flex { | |
| display: flex; | |
| } | |
| .align-center { | |
| align-items: center; | |
| } | |
| .justify-between { | |
| justify-content: space-between; | |
| } | |
| .justify-center { | |
| justify-content: center; | |
| } | |
| .gap-2 { | |
| gap: 0.5rem; | |
| } | |
| .gap-3 { | |
| gap: 0.75rem; | |
| } | |
| .gap-4 { | |
| gap: 1rem; | |
| } | |
| .mb-0 { margin-bottom: 0; } | |
| .mb-1 { margin-bottom: 0.25rem; } | |
| .mb-2 { margin-bottom: 0.5rem; } | |
| .mb-3 { margin-bottom: 0.75rem; } | |
| .mb-4 { margin-bottom: 1rem; } | |
| .mb-5 { margin-bottom: 1.5rem; } | |
| .mt-1 { margin-top: 0.25rem; } | |
| .mt-2 { margin-top: 0.5rem; } | |
| .mt-3 { margin-top: 0.75rem; } | |
| .mt-4 { margin-top: 1rem; } | |
| .mt-5 { margin-top: 1.5rem; } | |
| .mx-auto { margin-left: auto; margin-right: auto; } | |
| .text-center { text-align: center; } | |
| .text-right { text-align: right; } | |
| .text-sm { font-size: 0.875rem; } | |
| .text-xs { font-size: 0.75rem; } | |
| .text-lg { font-size: 1.125rem; } | |
| .text-xl { font-size: 1.25rem; } | |
| .font-semibold { font-weight: 600; } | |
| .font-bold { font-weight: 700; } | |
| .text-muted { color: var(--color-text-muted); } | |
| .text-primary { color: var(--color-primary); } | |
| .text-success { color: var(--color-success); } | |
| .text-warning { color: var(--color-warning); } | |
| .text-danger { color: var(--color-danger); } | |
| .hidden { display: none !important; } | |
| .border-t { border-top: 1px solid var(--color-border); } | |
| .border-b { border-bottom: 1px solid var(--color-border); } | |
| .p-4 { padding: 1rem; } | |
| .p-5 { padding: 1.5rem; } | |
| .py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; } | |
| .px-4 { padding-left: 1rem; padding-right: 1rem; } | |
| /* Responsive adjustments */ | |
| @media (max-width: 768px) { | |
| .main-content { | |
| padding: 1rem; | |
| } | |
| .card-body { | |
| padding: 1rem; | |
| } | |
| .status-cards { | |
| grid-template-columns: 1fr; | |
| } | |
| .form-actions { | |
| flex-direction: column; | |
| } | |
| .form-actions .btn { | |
| width: 100%; | |
| } | |
| } | |
| /* Animations */ | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| .spin { | |
| animation: spin 1s linear infinite; | |
| } | |
| .action-panel { | |
| margin-top: 1.5rem; | |
| display: flex; | |
| gap: 0.75rem; | |
| flex-wrap: wrap; | |
| } | |
| /* Sections */ | |
| .section { | |
| margin-bottom: 2rem; | |
| } | |
| .section-title { | |
| font-size: 1.25rem; | |
| font-weight: 600; | |
| margin-bottom: 1rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .section-title i { | |
| color: var(--color-primary); | |
| } | |
| /* Custom Scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 6px; | |
| height: 6px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: var(--color-bg-darker); | |
| border-radius: 10px; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: var(--color-border); | |
| border-radius: 10px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: var(--color-text-muted); | |
| } | |
| /* Tooltip */ | |
| .tooltip { | |
| position: relative; | |
| display: inline-block; | |
| } | |
| .tooltip .tooltip-text { | |
| visibility: hidden; | |
| background-color: var(--color-bg-darker); | |
| color: var(--color-text); | |
| text-align: center; | |
| border-radius: var(--border-radius); | |
| padding: 0.5rem 0.75rem; | |
| position: absolute; | |
| z-index: 1; | |
| bottom: 125%; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| opacity: 0; | |
| transition: opacity var(--transition-fast); | |
| white-space: nowrap; | |
| box-shadow: var(--shadow-md); | |
| border: 1px solid var(--color-border); | |
| font-size: 0.75rem; | |
| } | |
| .tooltip .tooltip-text::after { | |
| content: ""; | |
| position: absolute; | |
| top: 100%; | |
| left: 50%; | |
| margin-left: -5px; | |
| border-width: 5px; | |
| border-style: solid; | |
| border-color: var(--color-border) transparent transparent transparent; | |
| } | |
| .tooltip:hover .tooltip-text { | |
| visibility: visible; | |
| opacity: 1; | |
| } | |
| /* Connection card flip */ | |
| .connection-card { | |
| perspective: 1000px; | |
| position: relative; | |
| height: 250px; /* Adjusted height */ | |
| transform-style: preserve-3d; | |
| transition: all var(--transition-slow); | |
| } | |
| .connection-card-inner { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| transition: transform var(--transition-slow); | |
| transform-style: preserve-3d; | |
| } | |
| .connection-card.flipped .connection-card-inner { | |
| transform: rotateY(180deg); | |
| } | |
| .connection-card-front, | |
| .connection-card-back { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| backface-visibility: hidden; | |
| border-radius: var(--border-radius-lg); | |
| overflow: hidden; | |
| padding: 1.5rem; | |
| display: flex; | |
| flex-direction: column; | |
| background-color: var(--color-bg-lighter); | |
| border: 1px solid var(--color-border); | |
| } | |
| .connection-card-front { | |
| z-index: 2; | |
| } | |
| .connection-card-back { | |
| transform: rotateY(180deg); | |
| z-index: 1; | |
| } | |
| /* New styles for connection info */ | |
| .connection-info-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 0.75rem; | |
| padding-bottom: 0.75rem; | |
| border-bottom: 1px solid var(--color-border); | |
| } | |
| .connection-info-header h4 { | |
| margin-bottom: 0; | |
| } | |
| .connection-info-content { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding-right: 0.5rem; /* Space for scrollbar */ | |
| font-size: 0.8rem; | |
| } | |
| .connection-info-content::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .connection-info-content::-webkit-scrollbar-track { | |
| background: var(--color-bg-darker); | |
| border-radius: var(--border-radius-full); | |
| } | |
| .connection-info-content::-webkit-scrollbar-thumb { | |
| background: var(--color-border); | |
| border-radius: var(--border-radius-full); | |
| } | |
| .info-row { | |
| display: flex; | |
| margin-bottom: 0.6rem; | |
| line-height: 1.4; | |
| } | |
| .info-label { | |
| font-weight: 500; | |
| min-width: 90px; /* Adjust as needed */ | |
| color: var(--color-text-muted); | |
| flex-shrink: 0; | |
| } | |
| .info-value { | |
| flex: 1; | |
| word-break: break-word; | |
| color: var(--color-text); | |
| } | |
| .connection-card-title { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| margin-bottom: 1rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .connection-card-title i { | |
| color: var(--color-primary); | |
| } | |
| .connection-card-actions { | |
| margin-top: auto; | |
| padding-top: 1rem; | |
| border-top: 1px solid var(--color-border); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .connection-status { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| font-size: 0.875rem; | |
| } | |
| .connection-status.connected { | |
| color: var(--color-success); | |
| } | |
| .connection-status.not-connected { | |
| color: var(--color-text-muted); | |
| } | |
| .db-icon { | |
| width: 3rem; | |
| height: 3rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: var(--border-radius); | |
| background-color: rgba(6, 182, 212, 0.1); | |
| color: var(--color-primary); | |
| font-size: 1.5rem; | |
| margin-bottom: 1rem; | |
| } | |
| /* Empty state */ | |
| .empty-state { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 2rem; | |
| text-align: center; | |
| background-color: var(--color-bg-darker); | |
| border-radius: var(--border-radius-lg); | |
| border: 1px dashed var(--color-border); | |
| height: 100%; /* Fill available space */ | |
| } | |
| .empty-state-icon { | |
| font-size: 3rem; | |
| color: var(--color-text-muted); | |
| margin-bottom: 1rem; | |
| } | |
| .empty-state-title { | |
| font-size: 1.25rem; | |
| font-weight: 600; | |
| margin-bottom: 0.5rem; | |
| } | |
| .empty-state-description { | |
| color: var(--color-text-muted); | |
| max-width: 400px; | |
| margin-bottom: 1.5rem; | |
| } | |
| /* Fancy separators */ | |
| .separator { | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| margin: 2rem 0; | |
| color: var(--color-text-muted); | |
| } | |
| .separator::before, | |
| .separator::after { | |
| content: ''; | |
| flex: 1; | |
| height: 1px; | |
| background-color: var(--color-border); | |
| } | |
| /* Badge */ | |
| .badge { | |
| display: inline-flex; | |
| align-items: center; | |
| padding: 0.125rem 0.5rem; | |
| border-radius: var(--border-radius-full); | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| background-color: rgba(6, 182, 212, 0.1); | |
| color: var(--color-primary); | |
| text-transform: uppercase; | |
| letter-spacing: 0.025em; | |
| } | |
| .badge.success { | |
| background-color: rgba(16, 185, 129, 0.1); | |
| color: var(--color-success); | |
| } | |
| .badge.warning { | |
| background-color: rgba(245, 158, 11, 0.1); | |
| color: var(--color-warning); | |
| } | |
| .badge.danger { | |
| background-color: rgba(239, 68, 68, 0.1); | |
| color: var(--color-danger); | |
| } | |
| .badge.info { /* Added for in-progress */ | |
| background-color: rgba(59, 130, 246, 0.1); | |
| color: var(--color-info); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app-container"> | |
| <!-- Header --> | |
| <header class="main-header"> | |
| <div class="logo"> | |
| <i class="fas fa-database"></i> | |
| <span>TimescaleDB Migrator</span> | |
| </div> | |
| <div class="app-status"> | |
| <div id="status-badge" class="status-badge idle">Idle</div> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <div class="main-content"> | |
| <!-- Tabs --> | |
| <div class="tabs"> | |
| <div class="tab active" data-tab="connections"> | |
| <i class="fas fa-plug"></i> Connections | |
| </div> | |
| <div class="tab" data-tab="dump"> | |
| <i class="fas fa-download"></i> Dump | |
| </div> | |
| <div class="tab" data-tab="restore"> | |
| <i class="fas fa-upload"></i> Restore | |
| </div> | |
| <div class="tab" data-tab="logs"> | |
| <i class="fas fa-terminal"></i> Logs | |
| </div> | |
| <div class="tab" data-tab="about"> | |
| <i class="fas fa-info-circle"></i> About | |
| </div> | |
| </div> | |
| <!-- Tab Content --> | |
| <div class="tab-content active" id="connections-tab"> | |
| <div class="section"> | |
| <h2 class="section-title"> | |
| <i class="fas fa-database"></i> Database Connections | |
| </h2> | |
| <div class="grid grid-cols-2"> | |
| <!-- Source Database Card --> | |
| <div class="connection-card" id="source-card"> | |
| <div class="connection-card-inner"> | |
| <div class="connection-card-front"> | |
| <div class="db-icon"> | |
| <i class="fas fa-server"></i> | |
| </div> | |
| <h3 class="connection-card-title"> | |
| <i class="fas fa-database"></i> Source Database | |
| </h3> | |
| <div class="form-group mb-3"> | |
| <div class="input-group"> | |
| <input type="password" id="source-conn" class="form-control" placeholder="postgresql://username:password@hostname:5432/database"> | |
| <div class="input-group-append"> | |
| <span class="input-group-text" id="toggle-source-visibility"> | |
| <i class="fas fa-eye-slash"></i> | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="connection-card-actions"> | |
| <div id="source-status" class="connection-status not-connected"> | |
| <i class="fas fa-unlink"></i> | |
| <span>Not Connected</span> | |
| </div> | |
| <button id="test-source-btn" class="btn btn-primary btn-sm"> | |
| <i class="fas fa-plug"></i> Test Connection | |
| </button> | |
| </div> | |
| </div> | |
| <div class="connection-card-back"> | |
| <div id="source-info"> | |
| <div class="empty-state"> | |
| <div class="empty-state-icon"> | |
| <i class="fas fa-database"></i> | |
| </div> | |
| <h3 class="empty-state-title">No Connection</h3> | |
| <p class="empty-state-description">Test your connection to view database information</p> | |
| </div> | |
| </div> | |
| <div class="connection-card-actions"> | |
| <button id="source-flip-back" class="btn btn-outline-secondary btn-sm"> | |
| <i class="fas fa-arrow-left"></i> Back | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Target Database Card --> | |
| <div class="connection-card" id="target-card"> | |
| <div class="connection-card-inner"> | |
| <div class="connection-card-front"> | |
| <div class="db-icon"> | |
| <i class="fas fa-cloud-upload-alt"></i> | |
| </div> | |
| <h3 class="connection-card-title"> | |
| <i class="fas fa-database"></i> Target Database | |
| </h3> | |
| <div class="form-group mb-3"> | |
| <div class="input-group"> | |
| <input type="password" id="target-conn" class="form-control" placeholder="postgresql://username:password@hostname:5432/database"> | |
| <div class="input-group-append"> | |
| <span class="input-group-text" id="toggle-target-visibility"> | |
| <i class="fas fa-eye-slash"></i> | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="connection-card-actions"> | |
| <div id="target-status" class="connection-status not-connected"> | |
| <i class="fas fa-unlink"></i> | |
| <span>Not Connected</span> | |
| </div> | |
| <button id="test-target-btn" class="btn btn-primary btn-sm"> | |
| <i class="fas fa-plug"></i> Test Connection | |
| </button> | |
| </div> | |
| </div> | |
| <div class="connection-card-back"> | |
| <div id="target-info"> | |
| <div class="empty-state"> | |
| <div class="empty-state-icon"> | |
| <i class="fas fa-database"></i> | |
| </div> | |
| <h3 class="empty-state-title">No Connection</h3> | |
| <p class="empty-state-description">Test your connection to view database information</p> | |
| </div> | |
| </div> | |
| <div class="connection-card-actions"> | |
| <button id="target-flip-back" class="btn btn-outline-secondary btn-sm"> | |
| <i class="fas fa-arrow-left"></i> Back | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="separator">Next Steps</div> | |
| <div class="text-center mb-5"> | |
| <p>After connecting to your databases, proceed to the Dump tab to create a backup or the Restore tab to recover from a backup.</p> | |
| <div class="action-panel d-flex gap-3 justify-center mt-4"> | |
| <button id="goto-dump-btn" class="btn btn-primary"> | |
| <i class="fas fa-download"></i> Go to Dump | |
| </button> | |
| <button id="goto-restore-btn" class="btn btn-secondary"> | |
| <i class="fas fa-upload"></i> Go to Restore | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tab-content" id="dump-tab"> | |
| <div class="section"> | |
| <h2 class="section-title"> | |
| <i class="fas fa-download"></i> Database Dump | |
| </h2> | |
| <div class="card mb-5"> | |
| <div class="card-body"> | |
| <h3 class="mb-4">Dump Settings</h3> | |
| <div class="form-group"> | |
| <label class="form-label" for="dump-format">Format</label> | |
| <select id="dump-format" class="form-control"> | |
| <option value="c" selected>Custom (compressed, most flexible)</option> | |
| <option value="d">Directory (for largest databases)</option> | |
| <option value="p">Plain SQL (less efficient, readable)</option> | |
| <option value="t">TAR (older format)</option> | |
| </select> | |
| <span class="form-text">The output file format used by pg_dump</span> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label" for="dump-compression">Compression Level</label> | |
| <select id="dump-compression" class="form-control"> | |
| <option value="default" selected>Default</option> | |
| <option value="0">0 (no compression, fastest)</option> | |
| <option value="1">1 (minimal)</option> | |
| <option value="3">3</option> | |
| <option value="5">5 (moderate, balanced)</option> | |
| <option value="7">7</option> | |
| <option value="9">9 (maximum compression)</option> | |
| </select> | |
| <span class="form-text">Higher compression saves space but can take longer</span> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label" for="schema-filter">Schema Filter (Optional)</label> | |
| <input type="text" id="schema-filter" class="form-control" placeholder="public"> | |
| <span class="form-text">Leave empty for all schemas</span> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label" for="dump-filename">Output Filename</label> | |
| <input type="text" id="dump-filename" class="form-control" value="timescale_backup"> | |
| <span class="form-text">Appropriate file extension will be added automatically</span> | |
| </div> | |
| <div class="form-actions mt-4 d-flex gap-3"> | |
| <button id="start-dump-btn" class="btn btn-primary"> | |
| <i class="fas fa-play"></i> Start Dump | |
| </button> | |
| <button id="stop-dump-btn" class="btn btn-danger" disabled> | |
| <i class="fas fa-stop"></i> Stop | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="dump-progress-section" class="hidden"> | |
| <h3 class="mb-4">Dump Progress</h3> | |
| <div class="file-size-visualization"> | |
| <div class="file-size-header"> | |
| <div> | |
| <div class="file-size-title">Backup File Size</div> | |
| <div class="file-size-value" id="current-size">0 MB</div> | |
| <div class="file-size-subtitle" id="growth-rate">0 MB/s</div> | |
| </div> | |
| <div class="badge info" id="dump-status">In Progress</div> <!-- Default to info --> | |
| </div> | |
| <div class="file-size-chart"> | |
| <canvas id="size-chart"></canvas> | |
| </div> | |
| <div class="file-size-stats"> | |
| <div class="file-size-stat"> | |
| <div class="file-size-stat-title">Current Table</div> | |
| <div class="file-size-stat-value" id="current-table">-</div> | |
| </div> | |
| <div class="file-size-stat"> | |
| <div class="file-size-stat-title">Elapsed Time</div> | |
| <div class="file-size-stat-value" id="elapsed-time">00:00:00</div> | |
| </div> | |
| <div class="file-size-stat"> | |
| <div class="file-size-stat-title">Dump File</div> | |
| <div class="file-size-stat-value text-sm" id="dump-file-path">-</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="action-panel mt-4 d-flex gap-3"> | |
| <a id="download-dump-btn" href="#" class="btn btn-success" target="_blank" disabled> | |
| <i class="fas fa-download"></i> Download Backup | |
| </a> | |
| <button id="goto-restore-from-dump-btn" class="btn btn-secondary" disabled> | |
| <i class="fas fa-upload"></i> Restore This Backup | |
| </button> | |
| </div> | |
| </div> | |
| <div class="command-visualization" id="dump-command-preview"> | |
| <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> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tab-content" id="restore-tab"> | |
| <div class="section"> | |
| <h2 class="section-title"> | |
| <i class="fas fa-upload"></i> Database Restore | |
| </h2> | |
| <div class="card mb-5"> | |
| <div class="card-body"> | |
| <h3 class="mb-4">Restore Settings</h3> | |
| <div class="form-group mb-4"> | |
| <label class="form-label">Backup Source</label> | |
| <div class="d-flex gap-2 mb-2"> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="radio" name="backup-source" id="backup-source-server" value="server" checked> | |
| <label class="form-label" for="backup-source-server">Server Backup</label> | |
| </div> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="radio" name="backup-source" id="backup-source-upload" value="upload" disabled> <!-- File upload disabled for now --> | |
| <label class="form-label text-muted" for="backup-source-upload">Upload File (Coming Soon)</label> | |
| </div> | |
| </div> | |
| <div id="server-backup-options"> | |
| <select id="server-backup-file" class="form-control"> | |
| <option value="">-- Select a backup file --</option> | |
| </select> | |
| </div> | |
| <div id="upload-backup-options" class="hidden"> | |
| <div class="form-text mb-2">File upload not yet implemented</div> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Restore Options</label> | |
| <div class="form-check mb-2"> | |
| <input class="form-check-input" type="checkbox" id="timescaledb-pre-restore" checked> | |
| <label class="form-label" for="timescaledb-pre-restore">Run timescaledb_pre_restore() function</label> | |
| </div> | |
| <div class="form-check mb-2"> | |
| <input class="form-check-input" type="checkbox" id="timescaledb-post-restore" checked> | |
| <label class="form-label" for="timescaledb-post-restore">Run timescaledb_post_restore() & ANALYZE</label> | |
| </div> | |
| <div class="form-check mb-2"> | |
| <input class="form-check-input" type="checkbox" id="no-owner" checked> | |
| <label class="form-label" for="no-owner">Ignore object ownership (--no-owner)</label> | |
| </div> | |
| <div class="form-check mb-2"> | |
| <input class="form-check-input" type="checkbox" id="clean"> | |
| <label class="form-label" for="clean">Clean (drop) database objects before recreating (--clean)</label> | |
| </div> | |
| <div class="form-check mb-2"> | |
| <input class="form-check-input" type="checkbox" id="single-transaction"> | |
| <label class="form-label" for="single-transaction">Restore as a single transaction (--single-transaction)</label> | |
| </div> | |
| </div> | |
| <div class="form-actions mt-4 d-flex gap-3"> | |
| <button id="start-restore-btn" class="btn btn-primary"> | |
| <i class="fas fa-play"></i> Start Restore | |
| </button> | |
| <button id="stop-restore-btn" class="btn btn-danger" disabled> | |
| <i class="fas fa-stop"></i> Stop | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="restore-progress-section" class="hidden"> | |
| <h3 class="mb-4">Restore Progress</h3> | |
| <div class="progress-container mb-4"> | |
| <div class="progress-header"> | |
| <div class="progress-title">Restore Progress</div> | |
| <div class="progress-value" id="restore-progress-value">0%</div> | |
| </div> | |
| <div class="progress-bar-container"> | |
| <div class="progress-bar animated" id="restore-progress-bar" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| <div class="status-cards"> | |
| <div class="status-card"> | |
| <div class="status-card-header"> | |
| <div class="status-card-title">Current Task</div> | |
| <div class="status-card-icon"> | |
| <i class="fas fa-tasks"></i> | |
| </div> | |
| </div> | |
| <div class="status-card-value" id="restore-current-table">-</div> | |
| <div class="status-card-subtitle">Being restored</div> | |
| </div> | |
| <div class="status-card"> | |
| <div class="status-card-header"> | |
| <div class="status-card-title">Elapsed Time</div> | |
| <div class="status-card-icon"> | |
| <i class="fas fa-clock"></i> | |
| </div> | |
| </div> | |
| <div class="status-card-value" id="restore-elapsed-time">00:00:00</div> | |
| <div class="status-card-subtitle">Since start</div> | |
| </div> | |
| <div class="status-card"> | |
| <div class="status-card-header"> | |
| <div class="status-card-title">Status</div> | |
| <div class="status-card-icon"> | |
| <i class="fas fa-info-circle"></i> | |
| </div> | |
| </div> | |
| <div class="status-card-value" id="restore-status">Not Started</div> | |
| <div class="status-card-subtitle" id="restore-substatus"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="command-visualization" id="restore-command-preview"> | |
| <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> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tab-content" id="logs-tab"> | |
| <div class="section"> | |
| <h2 class="section-title"> | |
| <i class="fas fa-terminal"></i> Migration Logs | |
| </h2> | |
| <div class="terminal"> | |
| <div class="terminal-header"> | |
| <div class="terminal-controls"> | |
| <div class="terminal-control close"></div> | |
| <div class="terminal-control minimize"></div> | |
| <div class="terminal-control maximize"></div> | |
| </div> | |
| <div class="terminal-title">TimescaleDB Migrator Terminal</div> | |
| </div> | |
| <div class="terminal-body" id="terminal-output"> | |
| <div class="terminal-line"> | |
| <div class="terminal-prompt">$</div> | |
| <div class="terminal-command">Welcome to TimescaleDB Migrator</div> | |
| </div> | |
| <div class="terminal-line"> | |
| <div class="terminal-prompt">$</div> | |
| <div class="terminal-command">Ready to execute commands. Check logs below for details.</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="logs-container mt-4"> | |
| <div class="logs-header"> | |
| <div class="logs-title">Activity Log</div> | |
| <div class="d-flex align-center gap-3"> | |
| <div class="logs-filters"> | |
| <button class="log-filter active" data-level="all">All</button> | |
| <button class="log-filter" data-level="info">Info</button> | |
| <button class="log-filter" data-level="success">Success</button> | |
| <button class="log-filter" data-level="warning">Warning</button> | |
| <button class="log-filter" data-level="error">Error</button> | |
| </div> | |
| <div class="form-check" title="When enabled, the log viewer will not auto-scroll to new entries"> | |
| <input class="form-check-input" type="checkbox" id="scroll-lock"> | |
| <label class="form-label" for="scroll-lock">Scroll Lock</label> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="logs-body" id="logs-output"> | |
| <!-- Logs will be inserted here --> | |
| </div> | |
| </div> | |
| <div class="action-panel mt-4 d-flex gap-3"> | |
| <button id="clear-logs-btn" class="btn btn-outline-danger"> | |
| <i class="fas fa-trash-alt"></i> Clear Logs | |
| </button> | |
| <button id="export-logs-btn" class="btn btn-outline-primary"> | |
| <i class="fas fa-file-export"></i> Export Logs | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tab-content" id="about-tab"> | |
| <div class="section"> | |
| <h2 class="section-title"> | |
| <i class="fas fa-info-circle"></i> About TimescaleDB Migrator | |
| </h2> | |
| <div class="card"> | |
| <div class="card-body"> | |
| <h3 class="mb-4">What is TimescaleDB Migrator?</h3> | |
| <p>TimescaleDB Migrator is a tool designed to simplify the process of migrating data between TimescaleDB instances using the PostgreSQL native backup and restore utilities: <code>pg_dump</code> and <code>pg_restore</code>.</p> | |
| <h4 class="mt-4 mb-3">Key Features</h4> | |
| <ul class="mb-4"> | |
| <li><strong>Easy Database Migration:</strong> Migrate your entire TimescaleDB database with just a few clicks</li> | |
| <li><strong>Secure Connections:</strong> Support for secure connections with password protection</li> | |
| <li><strong>Backup Download:</strong> Download your database backup for safekeeping</li> | |
| <li><strong>Real-time Monitoring:</strong> Track the progress of your dump and restore operations</li> | |
| <li><strong>TimescaleDB-aware:</strong> Handles TimescaleDB-specific migration requirements</li> | |
| </ul> | |
| <div class="separator">How It Works</div> | |
| <div class="mb-4"> | |
| <h4 class="mb-3">Dump Operation</h4> | |
| <p>The dump operation uses <code>pg_dump</code> to create a backup of your source database. This backup can be in various formats (custom, directory, plain SQL, or tar) and with different compression levels.</p> | |
| <h4 class="mt-4 mb-3">Restore Operation</h4> | |
| <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> | |
| <h4 class="mt-4 mb-3">Commands Used</h4> | |
| <div class="command-visualization"> | |
| <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> | |
| </div> | |
| <div class="command-visualization mt-3"> | |
| <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> | |
| </div> | |
| <div class="command-visualization mt-3"> | |
| <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> | |
| </div> | |
| <div class="command-visualization mt-3"> | |
| <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> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Toast Container --> | |
| <div id="toast-container" class="toast-container"></div> | |
| <!-- Delete Confirmation Modal --> | |
| <div class="modal-backdrop" id="confirm-modal"> | |
| <div class="modal"> | |
| <div class="modal-header"> | |
| <h3 class="modal-title">Confirm Action</h3> | |
| <button class="modal-close" id="close-confirm-modal"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div class="modal-body" id="confirm-modal-body"> | |
| Are you sure you want to proceed with this action? | |
| </div> | |
| <div class="modal-footer"> | |
| <button class="btn btn-outline-secondary" id="cancel-confirm-btn">Cancel</button> | |
| <button class="btn btn-danger" id="confirm-action-btn">Confirm</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Scripts --> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <script> | |
| // NOTE: This JS is the previous version. | |
| // The user prompt included JS changes for the confirmation modal text, | |
| // but the request was only to output the modified Python code. | |
| // For a fully working system, the JS would also need updating. | |
| document.addEventListener('DOMContentLoaded', function() { | |
| // DOM Elements | |
| const tabs = document.querySelectorAll('.tab'); | |
| const tabContents = document.querySelectorAll('.tab-content'); | |
| const testSourceBtn = document.getElementById('test-source-btn'); | |
| const sourceFlipBack = document.getElementById('source-flip-back'); | |
| const testTargetBtn = document.getElementById('test-target-btn'); | |
| const targetFlipBack = document.getElementById('target-flip-back'); | |
| const sourceCard = document.getElementById('source-card'); | |
| const targetCard = document.getElementById('target-card'); | |
| const sourceConnInput = document.getElementById('source-conn'); | |
| const targetConnInput = document.getElementById('target-conn'); | |
| const toggleSourceVisibility = document.getElementById('toggle-source-visibility'); | |
| const toggleTargetVisibility = document.getElementById('toggle-target-visibility'); | |
| const sourceStatus = document.getElementById('source-status'); | |
| const targetStatus = document.getElementById('target-status'); | |
| const statusBadge = document.getElementById('status-badge'); | |
| const logsOutput = document.getElementById('logs-output'); | |
| const terminalOutput = document.getElementById('terminal-output'); | |
| const logFilters = document.querySelectorAll('.log-filter'); | |
| const startDumpBtn = document.getElementById('start-dump-btn'); | |
| const stopDumpBtn = document.getElementById('stop-dump-btn'); | |
| const startRestoreBtn = document.getElementById('start-restore-btn'); | |
| const stopRestoreBtn = document.getElementById('stop-restore-btn'); | |
| const dumpFormatSelect = document.getElementById('dump-format'); | |
| const dumpCompressionSelect = document.getElementById('dump-compression'); | |
| const schemaFilterInput = document.getElementById('schema-filter'); | |
| const dumpFilenameInput = document.getElementById('dump-filename'); | |
| const dumpCommandPreview = document.getElementById('dump-command-preview'); | |
| const restoreCommandPreview = document.getElementById('restore-command-preview'); | |
| const serverBackupFile = document.getElementById('server-backup-file'); | |
| const serverBackupOptions = document.getElementById('server-backup-options'); | |
| const uploadBackupOptions = document.getElementById('upload-backup-options'); | |
| const backupSourceInputs = document.querySelectorAll('input[name="backup-source"]'); | |
| const gotoDumpBtn = document.getElementById('goto-dump-btn'); | |
| const gotoRestoreBtn = document.getElementById('goto-restore-btn'); | |
| const clearLogsBtn = document.getElementById('clear-logs-btn'); | |
| const exportLogsBtn = document.getElementById('export-logs-btn'); | |
| const dumpProgressSection = document.getElementById('dump-progress-section'); | |
| const restoreProgressSection = document.getElementById('restore-progress-section'); | |
| const currentSizeElement = document.getElementById('current-size'); | |
| const growthRateElement = document.getElementById('growth-rate'); | |
| const dumpStatusElement = document.getElementById('dump-status'); | |
| const currentTableElement = document.getElementById('current-table'); | |
| const elapsedTimeElement = document.getElementById('elapsed-time'); | |
| const dumpFilePathElement = document.getElementById('dump-file-path'); | |
| const downloadDumpBtn = document.getElementById('download-dump-btn'); | |
| const gotoRestoreFromDumpBtn = document.getElementById('goto-restore-from-dump-btn'); | |
| const restoreCurrentTableElement = document.getElementById('restore-current-table'); | |
| const restoreElapsedTimeElement = document.getElementById('restore-elapsed-time'); | |
| const restoreStatusElement = document.getElementById('restore-status'); | |
| const restoreSubstatusElement = document.getElementById('restore-substatus'); | |
| const restoreProgressBar = document.getElementById('restore-progress-bar'); | |
| const restoreProgressValue = document.getElementById('restore-progress-value'); | |
| const confirmModal = document.getElementById('confirm-modal'); | |
| const closeConfirmModal = document.getElementById('close-confirm-modal'); | |
| const cancelConfirmBtn = document.getElementById('cancel-confirm-btn'); | |
| const confirmActionBtn = document.getElementById('confirm-action-btn'); | |
| const confirmModalBody = document.getElementById('confirm-modal-body'); | |
| const scrollLockCheckbox = document.getElementById('scroll-lock'); | |
| let scrollLock = JSON.parse(localStorage.getItem('scroll_lock') || 'false'); | |
| if (scrollLockCheckbox) { | |
| scrollLockCheckbox.checked = scrollLock; | |
| } | |
| function maybeAutoScroll(el) { | |
| if (!scrollLock && el) { | |
| el.scrollTop = el.scrollHeight; | |
| } | |
| } | |
| if (scrollLockCheckbox) { | |
| scrollLockCheckbox.addEventListener('change', () => { | |
| scrollLock = scrollLockCheckbox.checked; | |
| localStorage.setItem('scroll_lock', JSON.stringify(scrollLock)); | |
| }); | |
| } | |
| // State variables | |
| let sizeChart = null; | |
| let updateInterval = null; | |
| let chartData = { | |
| labels: [], | |
| datasets: [{ | |
| label: 'File Size (MB)', | |
| backgroundColor: 'rgba(6, 182, 212, 0.2)', | |
| borderColor: 'rgba(6, 182, 212, 1)', | |
| pointBackgroundColor: 'rgba(6, 182, 212, 1)', | |
| pointRadius: 3, | |
| pointHoverRadius: 5, | |
| data: [], | |
| fill: true, | |
| tension: 0.4 | |
| }] | |
| }; | |
| let currentLogFilter = 'all'; | |
| let lastLogId = -1; | |
| let elapsedTimeInterval = null; | |
| let startTime = null; | |
| let migration_state = {}; // Local copy of state for UI logic | |
| let confirmAction = null; | |
| // Initialize size chart | |
| const initSizeChart = () => { | |
| const ctx = document.getElementById('size-chart').getContext('2d'); | |
| if (sizeChart) { | |
| sizeChart.destroy(); | |
| } | |
| sizeChart = new Chart(ctx, { | |
| type: 'line', | |
| data: chartData, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| animation: false, | |
| scales: { | |
| x: { | |
| display: true, | |
| grid: { | |
| display: false, | |
| color: 'rgba(255, 255, 255, 0.1)' | |
| }, | |
| ticks: { | |
| color: 'rgba(255, 255, 255, 0.7)', | |
| maxTicksLimit: 8 | |
| } | |
| }, | |
| y: { | |
| display: true, | |
| beginAtZero: true, | |
| grid: { | |
| color: 'rgba(255, 255, 255, 0.1)' | |
| }, | |
| ticks: { | |
| color: 'rgba(255, 255, 255, 0.7)', | |
| callback: function(value) { | |
| return value + ' MB'; | |
| } | |
| } | |
| } | |
| }, | |
| plugins: { | |
| legend: { | |
| display: false | |
| }, | |
| tooltip: { | |
| backgroundColor: 'rgba(15, 23, 42, 0.9)', | |
| titleColor: 'rgba(255, 255, 255, 0.9)', | |
| bodyColor: 'rgba(255, 255, 255, 0.7)', | |
| borderColor: 'rgba(6, 182, 212, 0.5)', | |
| borderWidth: 1, | |
| padding: 10, | |
| cornerRadius: 6, | |
| displayColors: false, | |
| callbacks: { | |
| label: function(context) { | |
| return `${context.parsed.y.toFixed(2)} MB`; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| }; | |
| // Initialize chart | |
| initSizeChart(); | |
| // Tab switching | |
| tabs.forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| tabs.forEach(t => t.classList.remove('active')); | |
| tabContents.forEach(c => c.classList.remove('active')); | |
| tab.classList.add('active'); | |
| const tabId = tab.getAttribute('data-tab'); | |
| document.getElementById(`${tabId}-tab`).classList.add('active'); | |
| // Persist active tab | |
| localStorage.setItem('active_tab', tabId); | |
| }); | |
| }); | |
| // Log filter functionality | |
| logFilters.forEach(filter => { | |
| filter.addEventListener('click', () => { | |
| const level = filter.getAttribute('data-level'); | |
| currentLogFilter = level; | |
| logFilters.forEach(f => f.classList.remove('active')); | |
| filter.classList.add('active'); | |
| // Filter logs | |
| const logEntries = logsOutput.querySelectorAll('.log-entry'); | |
| logEntries.forEach(entry => { | |
| if (level === 'all' || entry.getAttribute('data-level') === level) { | |
| entry.style.display = 'flex'; | |
| } else { | |
| entry.style.display = 'none'; | |
| } | |
| }); | |
| }); | |
| }); | |
| // Show toast notification | |
| function showToast(type, title, message, duration = 5000) { | |
| const toastContainer = document.getElementById('toast-container'); | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| toast.innerHTML = ` | |
| <div class="toast-body"> | |
| <div class="toast-title">${title}</div> | |
| <div class="toast-message">${message}</div> | |
| <div class="toast-progress" style="animation-duration: ${duration}ms"></div> | |
| </div> | |
| <button class="toast-close"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| `; | |
| toastContainer.appendChild(toast); | |
| // Force reflow to trigger animation | |
| toast.offsetHeight; | |
| // Close toast handler | |
| const closeButton = toast.querySelector('.toast-close'); | |
| let timeoutId = null; | |
| const hideAndRemove = () => { | |
| clearTimeout(timeoutId); | |
| hideToast(toast); | |
| }; | |
| closeButton.addEventListener('click', hideAndRemove); | |
| // Auto close after duration | |
| timeoutId = setTimeout(hideAndRemove, duration); | |
| } | |
| function hideToast(toast) { | |
| toast.classList.add('hide'); | |
| setTimeout(() => { | |
| toast.remove(); | |
| }, 300); | |
| } | |
| // Toggle password visibility | |
| function setupPasswordToggle(inputId, toggleId) { | |
| const input = document.getElementById(inputId); | |
| const toggle = document.getElementById(toggleId); | |
| toggle.addEventListener('click', () => { | |
| const isPassword = input.type === 'password'; | |
| input.type = isPassword ? 'text' : 'password'; | |
| toggle.innerHTML = isPassword ? | |
| '<i class="fas fa-eye"></i>' : | |
| '<i class="fas fa-eye-slash"></i>'; | |
| }); | |
| } | |
| setupPasswordToggle('source-conn', 'toggle-source-visibility'); | |
| setupPasswordToggle('target-conn', 'toggle-target-visibility'); | |
| // Function to fetch and display additional DB info | |
| async function loadDatabaseInfo(connString, infoElementId) { | |
| const infoElement = document.getElementById(infoElementId); | |
| const tableRow = infoElement.querySelector('.info-row[data-info="tables"]'); | |
| const sizeRow = infoElement.querySelector('.info-row[data-info="size"]'); | |
| if (tableRow) tableRow.querySelector('.info-value').innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i>'; | |
| if (sizeRow) sizeRow.querySelector('.info-value').innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i>'; | |
| try { | |
| const response = await fetch('/database-info', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| connection_string: connString | |
| }) | |
| }); | |
| const infoData = await response.json(); | |
| if (infoData.success) { | |
| if (tableRow) { | |
| tableRow.querySelector('.info-value').textContent = infoData.table_count; | |
| } | |
| if (sizeRow) { | |
| sizeRow.querySelector('.info-value').textContent = infoData.database_size; | |
| } | |
| } else { | |
| if (tableRow) tableRow.querySelector('.info-value').textContent = 'Error'; | |
| if (sizeRow) sizeRow.querySelector('.info-value').textContent = 'Error'; | |
| console.error('Error loading database info:', infoData.message); | |
| } | |
| } catch (error) { | |
| if (tableRow) tableRow.querySelector('.info-value').textContent = 'Error'; | |
| if (sizeRow) sizeRow.querySelector('.info-value').textContent = 'Error'; | |
| console.error('Error fetching database info:', error); | |
| } | |
| } | |
| // Test Connection Function | |
| async function testConnection(connInput, statusElement, cardElement, infoElementId, flipButton, connType) { | |
| const connString = connInput.value; | |
| if (!connString) { | |
| showToast('error', 'Invalid Connection String', 'Please enter a valid connection string.'); | |
| return; | |
| } | |
| // Show loading state | |
| flipButton.disabled = true; | |
| flipButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing...'; | |
| try { | |
| const response = await fetch('/test-connection', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| connection_string: connString, | |
| connection_type: connType | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| showToast('success', 'Connection Successful', `Successfully connected to ${connType} database.`); | |
| statusElement.innerHTML = '<i class="fas fa-link"></i><span>Connected</span>'; | |
| statusElement.className = 'connection-status connected'; | |
| localStorage.setItem(`${connType}_conn`, connString); | |
| cardElement.classList.add('flipped'); | |
| // Add connection info with new layout | |
| const infoElement = document.getElementById(infoElementId); | |
| infoElement.innerHTML = ` | |
| <div class="connection-info-header"> | |
| <h4 class="mb-0">Connection Details</h4> | |
| <div class="badge success">Connected</div> | |
| </div> | |
| <div class="connection-info-content"> | |
| <div class="info-row"> | |
| <div class="info-label">Server:</div> | |
| <div class="info-value">${data.server || 'Unknown'}</div> | |
| </div> | |
| <div class="info-row"> | |
| <div class="info-label">Database:</div> | |
| <div class="info-value">${data.database || 'Unknown'}</div> | |
| </div> | |
| <div class="info-row"> | |
| <div class="info-label">Version:</div> | |
| <div class="info-value">${data.version || 'Unknown'}</div> | |
| </div> | |
| <div class="info-row"> | |
| <div class="info-label">TimescaleDB:</div> | |
| <div class="info-value">${data.is_timescaledb ? 'Yes' : 'No'}</div> | |
| </div> | |
| ${data.timescaledb_version ? ` | |
| <div class="info-row"> | |
| <div class="info-label">TS Version:</div> | |
| <div class="info-value">${data.timescaledb_version}</div> | |
| </div> | |
| ` : ''} | |
| <div class="info-row" data-info="tables"> | |
| <div class="info-label">Tables:</div> | |
| <div class="info-value">Loading...</div> | |
| </div> | |
| <div class="info-row" data-info="size"> | |
| <div class="info-label">Size:</div> | |
| <div class="info-value">Loading...</div> | |
| </div> | |
| ${connType === 'target' && !data.is_timescaledb ? '<div class="badge warning mt-2">Warning: TimescaleDB not detected</div>' : ''} | |
| </div> | |
| `; | |
| // Load additional database info | |
| loadDatabaseInfo(connString, infoElementId); | |
| if (connType === 'target' && !data.is_timescaledb) { | |
| showToast('warning', 'TimescaleDB Not Detected', 'The target database does not appear to have TimescaleDB installed. This may cause issues with the restoration.'); | |
| } | |
| } else { | |
| showToast('error', 'Connection Failed', data.message || 'Could not connect to the database.'); | |
| statusElement.innerHTML = '<i class="fas fa-unlink"></i><span>Not Connected</span>'; | |
| statusElement.className = 'connection-status not-connected'; | |
| } | |
| } catch (error) { | |
| console.error('Connection test error:', error); | |
| showToast('error', 'Connection Error', 'An error occurred while testing the connection.'); | |
| statusElement.innerHTML = '<i class="fas fa-unlink"></i><span>Not Connected</span>'; | |
| statusElement.className = 'connection-status not-connected'; | |
| } finally { | |
| flipButton.disabled = false; | |
| flipButton.innerHTML = '<i class="fas fa-plug"></i> Test Connection'; | |
| } | |
| } | |
| // Card Flip functionality | |
| testSourceBtn.addEventListener('click', () => testConnection(sourceConnInput, sourceStatus, sourceCard, 'source-info', testSourceBtn, 'source')); | |
| sourceFlipBack.addEventListener('click', () => sourceCard.classList.remove('flipped')); | |
| testTargetBtn.addEventListener('click', () => testConnection(targetConnInput, targetStatus, targetCard, 'target-info', testTargetBtn, 'target')); | |
| targetFlipBack.addEventListener('click', () => targetCard.classList.remove('flipped')); | |
| // Backup source radio change | |
| backupSourceInputs.forEach(input => { | |
| input.addEventListener('change', () => { | |
| if (input.value === 'server') { | |
| serverBackupOptions.classList.remove('hidden'); | |
| uploadBackupOptions.classList.add('hidden'); | |
| } else { | |
| serverBackupOptions.classList.add('hidden'); | |
| uploadBackupOptions.classList.remove('hidden'); | |
| } | |
| }); | |
| }); | |
| // Navigation buttons | |
| gotoDumpBtn.addEventListener('click', () => { | |
| document.querySelector('.tab[data-tab="dump"]').click(); | |
| }); | |
| gotoRestoreBtn.addEventListener('click', () => { | |
| document.querySelector('.tab[data-tab="restore"]').click(); | |
| }); | |
| // Format duration | |
| function formatDuration(seconds) { | |
| const hours = Math.floor(seconds / 3600); | |
| const minutes = Math.floor((seconds % 3600) / 60); | |
| const secs = Math.floor(seconds % 60); | |
| return [ | |
| hours.toString().padStart(2, '0'), | |
| minutes.toString().padStart(2, '0'), | |
| secs.toString().padStart(2, '0') | |
| ].join(':'); | |
| } | |
| // Update elapsed time | |
| function startElapsedTimeCounter() { | |
| // Use start_time from backend state if available and process is running | |
| if (migration_state && migration_state.running && migration_state.start_time) { | |
| startTime = migration_state.start_time * 1000; // Convert seconds to ms | |
| } else { | |
| startTime = Date.now(); | |
| } | |
| if (elapsedTimeInterval) { | |
| clearInterval(elapsedTimeInterval); | |
| } | |
| elapsedTimeInterval = setInterval(() => { | |
| const elapsed = Math.floor((Date.now() - startTime) / 1000); | |
| const formattedTime = formatDuration(elapsed); | |
| if (migration_state && migration_state.operation === 'dump') { | |
| elapsedTimeElement.textContent = formattedTime; | |
| } else if (migration_state && migration_state.operation === 'restore') { | |
| restoreElapsedTimeElement.textContent = formattedTime; | |
| } | |
| }, 1000); | |
| } | |
| function stopElapsedTimeCounter() { | |
| if (elapsedTimeInterval) { | |
| clearInterval(elapsedTimeInterval); | |
| elapsedTimeInterval = null; | |
| } | |
| // Final update based on end_time - start_time if available | |
| if(migration_state && migration_state.start_time && migration_state.end_time) { | |
| const elapsed = Math.floor(migration_state.end_time - migration_state.start_time); | |
| const formattedTime = formatDuration(elapsed >= 0 ? elapsed : 0); | |
| if (migration_state.operation === 'dump') { | |
| elapsedTimeElement.textContent = formattedTime; | |
| } else if (migration_state.operation === 'restore') { | |
| restoreElapsedTimeElement.textContent = formattedTime; | |
| } | |
| } | |
| } | |
| // Load server backups | |
| async function loadServerBackups() { | |
| try { | |
| const response = await fetch('/list-dumps'); | |
| const data = await response.json(); | |
| // Clear select options | |
| serverBackupFile.innerHTML = '<option value="">-- Select a backup file --</option>'; | |
| // Add new options | |
| if (data.dumps && data.dumps.length > 0) { | |
| data.dumps.forEach(dump => { | |
| const option = document.createElement('option'); | |
| option.value = dump.path; | |
| // Indicate if it's a directory | |
| const typeIndicator = dump.is_dir ? ' (Dir)' : ''; | |
| option.textContent = `${dump.name}${typeIndicator} (${dump.size_mb.toFixed(2)} MB, ${dump.date})`; | |
| serverBackupFile.appendChild(option); | |
| }); | |
| } else { | |
| const option = document.createElement('option'); | |
| option.disabled = true; | |
| option.textContent = 'No backup files available'; | |
| serverBackupFile.appendChild(option); | |
| } | |
| updateRestoreCommandPreview(); // Update preview after loading | |
| } catch (error) { | |
| console.error('Error loading backups:', error); | |
| showToast('error', 'Error', 'Failed to load backup files.'); | |
| } | |
| } | |
| // Generate command preview | |
| function updateDumpCommandPreview() { | |
| const format = dumpFormatSelect.value; | |
| const filename = dumpFilenameInput.value || 'timescale_backup'; | |
| const schema = schemaFilterInput.value; | |
| const compression = dumpCompressionSelect.value; | |
| let sourcePreview = sourceConnInput.value ? sourceConnInput.value.replace(/:\/\/[^:]+:[^@]+@/, '://user:***@') : 'postgres://user:***@hostname:5432/database'; | |
| let command = `<div class="command-part command-keyword">pg_dump</div> <div class="command-part command-string">"${sourcePreview}"</div> <div class="command-part command-flag">-F${format}</div> <div class="command-part command-flag">-v</div>`; | |
| if (compression && compression !== 'default') { | |
| command += ` <div class="command-part command-flag">-Z</div> <div class="command-part command-string">${compression}</div>`; | |
| } | |
| if (schema) { | |
| command += ` <div class="command-part command-flag">-n</div> <div class="command-part command-string">"${schema}"</div>`; | |
| } | |
| // Determine file extension | |
| let extension = '.dump'; | |
| switch (format) { | |
| case 'p': | |
| extension = '.sql'; | |
| break; | |
| case 'd': | |
| extension = ''; // Directory format | |
| break; | |
| case 't': | |
| extension = '.tar'; | |
| break; | |
| } | |
| command += ` <div class="command-part command-flag">-f</div> <div class="command-part command-string">"${filename}${extension}"</div>`; | |
| dumpCommandPreview.innerHTML = command; | |
| } | |
| function updateRestoreCommandPreview() { | |
| const noOwner = document.getElementById('no-owner').checked; | |
| const clean = document.getElementById('clean').checked; | |
| const singleTransaction = document.getElementById('single-transaction').checked; | |
| const selectedFilePath = serverBackupFile.value; | |
| const selectedFileName = selectedFilePath ? selectedFilePath.split(/[\\/]/).pop() : 'timescale_backup.dump'; | |
| let targetPreview = targetConnInput.value ? targetConnInput.value.replace(/:\/\/[^:]+:[^@]+@/, '://user:***@') : 'postgres://user:***@hostname:5432/database'; | |
| let command = `<div class="command-part command-keyword">pg_restore</div> <div class="command-part command-flag">-d</div> <div class="command-part command-string">"${targetPreview}"</div> <div class="command-part command-flag">-v</div>`; | |
| if (noOwner) { | |
| command += ' <div class="command-part command-option">--no-owner</div>'; | |
| } | |
| if (clean) { | |
| command += ' <div class="command-part command-option">--clean</div>'; | |
| } | |
| if (singleTransaction) { | |
| command += ' <div class="command-part command-option">--single-transaction</div>'; | |
| } | |
| command += ` <div class="command-part command-string">"${selectedFileName}"</div>`; | |
| restoreCommandPreview.innerHTML = command; | |
| } | |
| // Event listeners for command preview updates | |
| dumpFormatSelect.addEventListener('change', updateDumpCommandPreview); | |
| dumpCompressionSelect.addEventListener('change', updateDumpCommandPreview); | |
| schemaFilterInput.addEventListener('input', updateDumpCommandPreview); | |
| dumpFilenameInput.addEventListener('input', updateDumpCommandPreview); | |
| sourceConnInput.addEventListener('input', updateDumpCommandPreview); // Update on source change | |
| targetConnInput.addEventListener('input', updateRestoreCommandPreview); // Update on target change | |
| document.getElementById('no-owner').addEventListener('change', updateRestoreCommandPreview); | |
| document.getElementById('clean').addEventListener('change', updateRestoreCommandPreview); | |
| document.getElementById('single-transaction').addEventListener('change', updateRestoreCommandPreview); | |
| serverBackupFile.addEventListener('change', updateRestoreCommandPreview); | |
| // Initialize command previews | |
| updateDumpCommandPreview(); | |
| updateRestoreCommandPreview(); | |
| // Function to handle starting an operation (dump or restore) | |
| async function handleStartOperation(operationType) { | |
| // Fetch latest status before deciding | |
| await updateStatus(); | |
| if (migration_state && migration_state.running) { | |
| 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}?`; | |
| confirmAction = `start-${operationType}`; | |
| confirmModal.classList.add('show'); | |
| } else { | |
| if (operationType === 'dump') { | |
| await startDumpProcess(); | |
| } else if (operationType === 'restore') { | |
| await startRestoreProcess(); | |
| } | |
| } | |
| } | |
| // Start dump | |
| startDumpBtn.addEventListener('click', () => handleStartOperation('dump')); | |
| async function startDumpProcess() { | |
| const sourceConn = sourceConnInput.value; | |
| if (!sourceConn) { | |
| showToast('error', 'Missing Connection', 'Please enter and test the source database connection.'); | |
| return; | |
| } | |
| // Basic check if connection seems established (UI based) | |
| if (!sourceStatus.classList.contains('connected')) { | |
| showToast('warning', 'Connection Not Tested', 'Please test the source connection before starting the dump.'); | |
| return; | |
| } | |
| // Get dump options | |
| const format = dumpFormatSelect.value; | |
| const compression = dumpCompressionSelect.value; | |
| const schema = schemaFilterInput.value; | |
| const filename = dumpFilenameInput.value || 'timescale_backup'; | |
| startDumpBtn.disabled = true; | |
| startDumpBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Starting...'; | |
| stopDumpBtn.disabled = true; // Disable stop until started | |
| try { | |
| const response = await fetch('/start-dump', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| source_conn: sourceConn, | |
| options: { | |
| format, | |
| compression, | |
| schema, | |
| filename | |
| } | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| // Show dump progress section | |
| dumpProgressSection.classList.remove('hidden'); | |
| // Reset progress UI | |
| currentSizeElement.textContent = '0 MB'; | |
| growthRateElement.textContent = '0 MB/s'; | |
| dumpStatusElement.textContent = 'Starting'; | |
| dumpStatusElement.className = 'badge info'; | |
| currentTableElement.textContent = '-'; | |
| elapsedTimeElement.textContent = '00:00:00'; | |
| dumpFilePathElement.textContent = data.file_path || '-'; | |
| downloadDumpBtn.setAttribute('href', '#'); // Reset download link | |
| downloadDumpBtn.disabled = true; | |
| gotoRestoreFromDumpBtn.disabled = true; | |
| // Reset chart data | |
| chartData.labels = []; | |
| chartData.datasets[0].data = []; | |
| if (sizeChart) sizeChart.update(); // Update empty chart | |
| else initSizeChart(); | |
| // Update global status badge | |
| statusBadge.className = 'status-badge running'; | |
| statusBadge.innerHTML = '<span class="pulse-dot"></span> Running Dump'; | |
| showToast('success', 'Dump Started', 'Database dump process has started.'); | |
| // Start update interval | |
| startStatusUpdates(); | |
| // Start time counter | |
| startElapsedTimeCounter(); | |
| // Enable stop button | |
| stopDumpBtn.disabled = false; | |
| // Set terminal output | |
| addTerminalLine(`pg_dump ${data.command_preview || 'starting...'}`, 'command'); | |
| } else { | |
| showToast('error', 'Failed to Start Dump', data.message || 'An error occurred.'); | |
| startDumpBtn.disabled = false; | |
| startDumpBtn.innerHTML = '<i class="fas fa-play"></i> Start Dump'; | |
| } | |
| } catch (error) { | |
| console.error('Dump start error:', error); | |
| showToast('error', 'Error', 'Failed to start the dump process.'); | |
| startDumpBtn.disabled = false; | |
| startDumpBtn.innerHTML = '<i class="fas fa-play"></i> Start Dump'; | |
| } | |
| } | |
| // Stop dump | |
| stopDumpBtn.addEventListener('click', () => { | |
| // Use the updated text from the prompt | |
| 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?'; | |
| confirmAction = 'stop-dump'; | |
| confirmModal.classList.add('show'); | |
| }); | |
| async function stopDumpProcess() { | |
| stopDumpBtn.disabled = true; | |
| stopDumpBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Stopping...'; | |
| startDumpBtn.disabled = true; // Keep start disabled while stopping | |
| try { | |
| const response = await fetch('/stop-process', { | |
| method: 'POST' | |
| }); | |
| const data = await response.json(); | |
| // UI updates will be handled by the status poller detecting the change | |
| if (data.success) { | |
| showToast('warning', 'Stop Initiated', 'Attempting to stop the dump process...'); | |
| // The status poller will eventually update the UI state fully | |
| } else { | |
| showToast('error', 'Failed to Stop', data.message || 'Could not stop the process.'); | |
| stopDumpBtn.disabled = false; // Re-enable if stop failed | |
| stopDumpBtn.innerHTML = '<i class="fas fa-stop"></i> Stop'; | |
| startDumpBtn.disabled = false; // Re-enable start if stop failed | |
| } | |
| } catch (error) { | |
| console.error('Stop error:', error); | |
| showToast('error', 'Error', 'Failed to send stop command.'); | |
| stopDumpBtn.disabled = false; | |
| stopDumpBtn.innerHTML = '<i class="fas fa-stop"></i> Stop'; | |
| startDumpBtn.disabled = false; | |
| } | |
| } | |
| // Start restore | |
| startRestoreBtn.addEventListener('click', () => handleStartOperation('restore')); | |
| async function startRestoreProcess() { | |
| const targetConn = targetConnInput.value; | |
| const backupSource = document.querySelector('input[name="backup-source"]:checked').value; | |
| let dumpFile = null; | |
| if (!targetConn) { | |
| showToast('error', 'Missing Connection', 'Please enter and test the target database connection.'); | |
| return; | |
| } | |
| // Basic check if connection seems established (UI based) | |
| if (!targetStatus.classList.contains('connected')) { | |
| showToast('warning', 'Connection Not Tested', 'Please test the target connection before starting the restore.'); | |
| return; | |
| } | |
| if (backupSource === 'server') { | |
| dumpFile = serverBackupFile.value; | |
| if (!dumpFile) { | |
| showToast('error', 'No Backup Selected', 'Please select a backup file to restore.'); | |
| return; | |
| } | |
| } else { | |
| showToast('error', 'Not Implemented', 'File upload is not yet implemented. Please use server backup option.'); | |
| return; // Stop execution | |
| } | |
| const options = { | |
| timescaledb_pre_restore: document.getElementById('timescaledb-pre-restore').checked, | |
| timescaledb_post_restore: document.getElementById('timescaledb-post-restore').checked, | |
| no_owner: document.getElementById('no-owner').checked, | |
| clean: document.getElementById('clean').checked, | |
| single_transaction: document.getElementById('single-transaction').checked | |
| }; | |
| startRestoreBtn.disabled = true; | |
| startRestoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Starting...'; | |
| stopRestoreBtn.disabled = true; // Disable stop until started | |
| try { | |
| const response = await fetch('/start-restore', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| target_conn: targetConn, | |
| dump_file: dumpFile, | |
| options | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| // Show restore progress section | |
| restoreProgressSection.classList.remove('hidden'); | |
| // Reset progress UI | |
| restoreCurrentTableElement.textContent = '-'; | |
| restoreElapsedTimeElement.textContent = '00:00:00'; | |
| restoreStatusElement.textContent = 'Starting'; | |
| restoreSubstatusElement.textContent = 'Initializing restore process'; | |
| restoreProgressBar.style.width = '0%'; | |
| restoreProgressValue.textContent = '0%'; | |
| restoreProgressBar.classList.add('animated'); // Ensure animation is active | |
| // Update global status badge | |
| statusBadge.className = 'status-badge running'; | |
| statusBadge.innerHTML = '<span class="pulse-dot"></span> Running Restore'; | |
| showToast('success', 'Restore Started', 'Database restore process has started.'); | |
| // Start update interval | |
| startStatusUpdates(); | |
| // Start time counter | |
| startElapsedTimeCounter(); | |
| // Enable stop button | |
| stopRestoreBtn.disabled = false; | |
| // Set terminal output | |
| addTerminalLine(`pg_restore ${data.command_preview || 'starting...'}`, 'command'); | |
| } else { | |
| showToast('error', 'Failed to Start Restore', data.message || 'An error occurred.'); | |
| startRestoreBtn.disabled = false; | |
| startRestoreBtn.innerHTML = '<i class="fas fa-play"></i> Start Restore'; | |
| } | |
| } catch (error) { | |
| console.error('Restore start error:', error); | |
| showToast('error', 'Error', 'Failed to start the restore process.'); | |
| startRestoreBtn.disabled = false; | |
| startRestoreBtn.innerHTML = '<i class="fas fa-play"></i> Start Restore'; | |
| } | |
| } | |
| // Stop restore | |
| stopRestoreBtn.addEventListener('click', () => { | |
| // Use the updated text from the prompt | |
| 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?'; | |
| confirmAction = 'stop-restore'; | |
| confirmModal.classList.add('show'); | |
| }); | |
| async function stopRestoreProcess() { | |
| stopRestoreBtn.disabled = true; | |
| stopRestoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Stopping...'; | |
| startRestoreBtn.disabled = true; // Keep start disabled while stopping | |
| try { | |
| const response = await fetch('/stop-process', { | |
| method: 'POST' | |
| }); | |
| const data = await response.json(); | |
| // UI updates will be handled by the status poller detecting the change | |
| if (data.success) { | |
| showToast('warning', 'Stop Initiated', 'Attempting to stop the restore process...'); | |
| // The status poller will eventually update the UI state fully | |
| } else { | |
| showToast('error', 'Failed to Stop', data.message || 'Could not stop the process.'); | |
| stopRestoreBtn.disabled = false; // Re-enable if stop failed | |
| stopRestoreBtn.innerHTML = '<i class="fas fa-stop"></i> Stop'; | |
| startRestoreBtn.disabled = false; // Re-enable start if stop failed | |
| } | |
| } catch (error) { | |
| console.error('Stop error:', error); | |
| showToast('error', 'Error', 'Failed to send stop command.'); | |
| stopRestoreBtn.disabled = false; | |
| stopRestoreBtn.innerHTML = '<i class="fas fa-stop"></i> Stop'; | |
| startRestoreBtn.disabled = false; | |
| } | |
| } | |
| // Confirmation modal handlers | |
| closeConfirmModal.addEventListener('click', () => { | |
| confirmModal.classList.remove('show'); | |
| }); | |
| cancelConfirmBtn.addEventListener('click', () => { | |
| confirmModal.classList.remove('show'); | |
| }); | |
| confirmActionBtn.addEventListener('click', async () => { | |
| confirmModal.classList.remove('show'); | |
| // Execute the confirmed action | |
| switch (confirmAction) { | |
| case 'start-dump': | |
| // Stop current process first (no need to await fully, just initiate) | |
| fetch('/stop-process', { method: 'POST' }); | |
| await new Promise(resolve => setTimeout(resolve, 500)); // Short delay | |
| await startDumpProcess(); | |
| break; | |
| case 'stop-dump': | |
| await stopDumpProcess(); | |
| break; | |
| case 'start-restore': | |
| // Stop current process first | |
| fetch('/stop-process', { method: 'POST' }); | |
| await new Promise(resolve => setTimeout(resolve, 500)); // Short delay | |
| await startRestoreProcess(); | |
| break; | |
| case 'stop-restore': | |
| await stopRestoreProcess(); | |
| break; | |
| } | |
| confirmAction = null; // Reset action | |
| }); | |
| // Clear logs | |
| clearLogsBtn.addEventListener('click', async () => { | |
| try { | |
| const response = await fetch('/clear-logs', { | |
| method: 'POST' | |
| }); | |
| if (response.ok) { | |
| logsOutput.innerHTML = ''; | |
| lastLogId = -1; | |
| showToast('info', 'Logs Cleared', 'All logs have been cleared.'); | |
| // Clear terminal as well? Optional. | |
| // terminalOutput.innerHTML = ''; | |
| // addTerminalLine('Logs cleared.', 'info'); | |
| } else { | |
| showToast('error', 'Failed to Clear Logs', 'An error occurred while clearing logs.'); | |
| } | |
| } catch (error) { | |
| console.error('Clear logs error:', error); | |
| showToast('error', 'Error', 'Failed to clear logs.'); | |
| } | |
| }); | |
| // Export logs | |
| exportLogsBtn.addEventListener('click', () => { | |
| const logEntries = Array.from(logsOutput.querySelectorAll('.log-entry')); | |
| if (logEntries.length === 0) { | |
| showToast('warning', 'No Logs', 'There are no logs to export.'); | |
| return; | |
| } | |
| const logText = logEntries.map(entry => { | |
| const timestamp = entry.querySelector('.log-timestamp').textContent; | |
| const level = entry.querySelector('.log-level').textContent; | |
| const message = entry.querySelector('.log-message').textContent; | |
| return `[${timestamp}] [${level.toUpperCase()}] ${message}`; | |
| }).join('\\n'); | |
| const blob = new Blob([logText], { type: 'text/plain;charset=utf-8' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| const timestampStr = new Date().toISOString().replace(/[:.]/g, '-'); | |
| a.download = `timescaledb_migrator_logs_${timestampStr}.txt`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| showToast('info', 'Logs Exported', 'Logs have been exported to a text file.'); | |
| }); | |
| // Add terminal line | |
| function addTerminalLine(text, type = 'output') { | |
| const line = document.createElement('div'); | |
| line.className = 'terminal-line'; | |
| const sanitizedText = text.replace(/</g, "<").replace(/>/g, ">"); // Basic sanitization | |
| let icon = '$'; | |
| let className = 'terminal-command'; | |
| switch(type) { | |
| case 'error': icon = '!'; className = 'terminal-error'; break; | |
| case 'success': icon = '✓'; className = 'terminal-success'; break; | |
| case 'warning': icon = '⚠'; className = 'terminal-warning'; break; | |
| case 'info': icon = 'i'; className = 'terminal-output'; break; // Use 'i' for info | |
| case 'output': icon = ''; className = 'terminal-output'; break; // No icon for plain output | |
| default: icon = '$'; className = 'terminal-command'; // Default to command style | |
| } | |
| if (icon) { | |
| line.innerHTML = `<div class="terminal-prompt">${icon}</div> <div class="${className}">${sanitizedText}</div>`; | |
| } else { | |
| // For plain output, don't add a prompt div, just the message | |
| line.innerHTML = `<div class="${className}" style="padding-left: calc(1em + 0.5rem);">${sanitizedText}</div>`; | |
| } | |
| terminalOutput.appendChild(line); | |
| // Auto-scroll | |
| maybeAutoScroll(terminalOutput); | |
| } | |
| // Add log entry | |
| function addLogEntry(log) { | |
| // Avoid adding duplicate logs if status updates overlap slightly | |
| if (document.querySelector(`.log-entry[data-id="${log.id}"]`)) { | |
| return; | |
| } | |
| const logEntry = document.createElement('div'); | |
| logEntry.className = 'log-entry'; | |
| logEntry.setAttribute('data-level', log.level); | |
| logEntry.setAttribute('data-id', log.id); | |
| const sanitizedMessage = log.message.replace(/</g, "<").replace(/>/g, ">"); | |
| logEntry.innerHTML = ` | |
| <div class="log-timestamp">${log.timestamp}</div> | |
| <div class="log-level ${log.level}">${log.level}</div> | |
| <div class="log-message">${sanitizedMessage}</div> | |
| `; | |
| // Add to logs output | |
| logsOutput.appendChild(logEntry); | |
| // Apply filter | |
| if (currentLogFilter !== 'all' && log.level !== currentLogFilter) { | |
| logEntry.style.display = 'none'; | |
| } | |
| // Scroll to bottom | |
| maybeAutoScroll(logsOutput); | |
| // Also add to terminal, map log level to terminal type | |
| let terminalType = 'output'; | |
| if (log.level === 'error') terminalType = 'error'; | |
| else if (log.level === 'success') terminalType = 'success'; | |
| else if (log.level === 'warning') terminalType = 'warning'; | |
| else if (log.level === 'info') terminalType = 'info'; | |
| if (log.command) { | |
| addTerminalLine(log.command, 'command'); | |
| } | |
| // Only add non-command messages if they are not just verbose process output (heuristic) | |
| // This avoids cluttering the terminal too much with pg_dump/restore verbose lines | |
| if (!log.command && (log.level !== 'info' || !log.message.startsWith('pg_'))) { | |
| addTerminalLine(log.message, terminalType); | |
| } | |
| } | |
| // Check for new logs | |
| async function checkForNewLogs() { | |
| try { | |
| // Use the /status endpoint which already includes logs | |
| const response = await fetch('/status'); | |
| const data = await response.json(); | |
| migration_state = data; // Update local state copy | |
| if (data.log && data.log.length > 0) { | |
| // Find new logs | |
| const newLogs = data.log.filter(log => log.id > lastLogId); | |
| if (newLogs.length > 0) { | |
| // Add new logs to UI | |
| newLogs.forEach(log => addLogEntry(log)); | |
| // Update last log ID | |
| lastLogId = Math.max(...data.log.map(log => log.id)); | |
| } | |
| } | |
| } catch (error) { | |
| // Avoid spamming console if server is temporarily down | |
| // console.error('Failed to check for new logs:', error); | |
| } | |
| } | |
| // Start status updates | |
| function startStatusUpdates() { | |
| if (!updateInterval) { | |
| // Run immediately first time | |
| updateStatus(); | |
| updateInterval = setInterval(updateStatus, 1500); // Update slightly less frequently | |
| } | |
| } | |
| // Stop status updates | |
| function stopStatusUpdates() { | |
| if (updateInterval) { | |
| clearInterval(updateInterval); | |
| updateInterval = null; | |
| } | |
| } | |
| // Update status | |
| async function updateStatus() { | |
| try { | |
| const response = await fetch('/status'); | |
| if (!response.ok) { | |
| console.error(`Status update failed: ${response.status}`); | |
| // Potentially stop updates if server is consistently failing | |
| // stopStatusUpdates(); | |
| // showToast('error', 'Connection Lost', 'Could not retrieve status from server.'); | |
| return; | |
| } | |
| const data = await response.json(); | |
| const previousState = migration_state; // Store previous state for comparison | |
| migration_state = data; // Update local state copy | |
| // Check for new logs first | |
| if (data.log && data.log.length > 0) { | |
| const newLogs = data.log.filter(log => log.id > lastLogId); | |
| if (newLogs.length > 0) { | |
| newLogs.forEach(log => addLogEntry(log)); | |
| lastLogId = Math.max(...data.log.map(log => log.id)); | |
| } | |
| } | |
| // Update status based on operation | |
| if (data.running) { | |
| const operationText = data.operation === 'dump' ? 'Running Dump' : 'Running Restore'; | |
| statusBadge.className = 'status-badge running'; | |
| statusBadge.innerHTML = `<span class="pulse-dot"></span> ${operationText}`; | |
| if (data.operation === 'dump') { | |
| updateDumpProgress(data); | |
| // Ensure buttons reflect running state | |
| startDumpBtn.disabled = true; | |
| stopDumpBtn.disabled = false; | |
| startRestoreBtn.disabled = true; // Disable other operation | |
| stopRestoreBtn.disabled = true; | |
| if (!dumpProgressSection.classList.contains('hidden')) { | |
| dumpProgressSection.classList.remove('hidden'); // Ensure visible | |
| } | |
| } else if (data.operation === 'restore') { | |
| updateRestoreProgress(data); | |
| // Ensure buttons reflect running state | |
| startRestoreBtn.disabled = true; | |
| stopRestoreBtn.disabled = false; | |
| startDumpBtn.disabled = true; // Disable other operation | |
| stopDumpBtn.disabled = true; | |
| if (!restoreProgressSection.classList.contains('hidden')) { | |
| restoreProgressSection.classList.remove('hidden'); // Ensure visible | |
| } | |
| } | |
| // If the process just started, start the timer | |
| if (!previousState.running && data.running) { | |
| startElapsedTimeCounter(); | |
| startStatusUpdates(); // Ensure updates continue | |
| } | |
| } else { // Not running | |
| // If it *was* running previously, stop timers/updates | |
| if (previousState.running && !data.running) { | |
| stopStatusUpdates(); | |
| stopElapsedTimeCounter(); | |
| } | |
| // Check if dump just completed | |
| if (data.dump_completed && previousState.running && previousState.operation === 'dump') { | |
| statusBadge.className = 'status-badge success'; | |
| statusBadge.textContent = 'Dump Complete'; | |
| dumpStatusElement.textContent = 'Completed'; | |
| dumpStatusElement.className = 'badge success'; | |
| // Ensure final size/rate is displayed | |
| updateDumpProgress(data); | |
| // Enable download button | |
| if (data.dump_file) { | |
| const downloadPath = `/downloads/${data.dump_file.split(/[\\/]/).pop()}`; | |
| downloadDumpBtn.setAttribute('href', downloadPath); | |
| downloadDumpBtn.disabled = false; | |
| // Enable restore button | |
| gotoRestoreFromDumpBtn.disabled = false; | |
| } | |
| showToast('success', 'Dump Completed', 'Database dump completed successfully.'); | |
| addTerminalLine(`Dump completed: ${data.dump_file_size.toFixed(2)} MB`, 'success'); | |
| // Reset buttons | |
| startDumpBtn.disabled = false; | |
| startDumpBtn.innerHTML = '<i class="fas fa-play"></i> Start Dump'; | |
| stopDumpBtn.disabled = true; | |
| startRestoreBtn.disabled = false; // Re-enable restore | |
| // Reload backup list | |
| loadServerBackups(); | |
| } | |
| // Check if restore just completed | |
| else if (data.restore_completed && previousState.running && previousState.operation === 'restore') { | |
| statusBadge.className = 'status-badge success'; | |
| statusBadge.textContent = 'Restore Complete'; | |
| restoreStatusElement.textContent = 'Completed'; | |
| restoreSubstatusElement.textContent = 'Restore completed successfully'; | |
| restoreProgressBar.style.width = '100%'; | |
| restoreProgressBar.classList.remove('animated'); | |
| restoreProgressValue.textContent = '100%'; | |
| showToast('success', 'Restore Completed', 'Database restore completed successfully.'); | |
| addTerminalLine('Restore completed successfully', 'success'); | |
| // Reset buttons | |
| startRestoreBtn.disabled = false; | |
| startRestoreBtn.innerHTML = '<i class="fas fa-play"></i> Start Restore'; | |
| stopRestoreBtn.disabled = true; | |
| startDumpBtn.disabled = false; // Re-enable dump | |
| } | |
| // Handle cases where process stopped unexpectedly or was stopped manually | |
| else if (previousState.running && !data.running) { | |
| const stoppedOperation = previousState.operation; // Use previous state's operation | |
| statusBadge.className = 'status-badge warning'; | |
| statusBadge.textContent = `${stoppedOperation.charAt(0).toUpperCase() + stoppedOperation.slice(1)} Stopped`; | |
| if (stoppedOperation === 'dump') { | |
| dumpStatusElement.textContent = 'Stopped'; | |
| dumpStatusElement.className = 'badge warning'; | |
| startDumpBtn.disabled = false; | |
| startDumpBtn.innerHTML = '<i class="fas fa-play"></i> Start Dump'; | |
| stopDumpBtn.disabled = true; | |
| startRestoreBtn.disabled = false; // Ensure other op is enabled | |
| } else if (stoppedOperation === 'restore') { | |
| restoreStatusElement.textContent = 'Stopped'; | |
| restoreSubstatusElement.textContent = 'Operation was stopped or failed'; | |
| restoreProgressBar.classList.remove('animated'); | |
| startRestoreBtn.disabled = false; | |
| startRestoreBtn.innerHTML = '<i class="fas fa-play"></i> Start Restore'; | |
| stopRestoreBtn.disabled = true; | |
| startDumpBtn.disabled = false; // Ensure other op is enabled | |
| } | |
| } | |
| // Otherwise, we are truly idle | |
| else if (!data.running) { | |
| statusBadge.className = 'status-badge idle'; | |
| statusBadge.textContent = 'Idle'; | |
| startDumpBtn.disabled = false; | |
| stopDumpBtn.disabled = true; | |
| startRestoreBtn.disabled = false; | |
| stopRestoreBtn.disabled = true; | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Status update error:', error); | |
| // Potentially stop updates on error | |
| // stopStatusUpdates(); | |
| // statusBadge.className = 'status-badge error'; | |
| // statusBadge.textContent = 'Error'; | |
| } | |
| } | |
| // Update dump progress | |
| function updateDumpProgress(data) { | |
| if (data.dump_file_size !== undefined) { | |
| const currentSize = data.dump_file_size.toFixed(2); | |
| currentSizeElement.textContent = `${currentSize} MB`; | |
| // Update chart | |
| const now = new Date(); | |
| const timeString = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); | |
| // Keep chart limited to ~60 data points (e.g., 1 minute) | |
| if (chartData.labels.length >= 60) { | |
| chartData.labels.shift(); | |
| chartData.datasets[0].data.shift(); | |
| } | |
| // Add new data point | |
| chartData.labels.push(timeString); | |
| chartData.datasets[0].data.push(parseFloat(currentSize)); | |
| // Update chart | |
| if (sizeChart) sizeChart.update('none'); // Use 'none' for no animation during updates | |
| } | |
| if (data.progress) { | |
| if (data.progress.growth_rate_mb_per_sec !== undefined) { | |
| growthRateElement.textContent = `${data.progress.growth_rate_mb_per_sec.toFixed(2)} MB/s`; | |
| } | |
| if (data.progress.current_table) { | |
| currentTableElement.textContent = data.progress.current_table; | |
| } | |
| dumpStatusElement.textContent = 'In Progress'; | |
| dumpStatusElement.className = 'badge info'; | |
| } | |
| } | |
| // Update restore progress | |
| function updateRestoreProgress(data) { | |
| if (data.progress) { | |
| if (data.progress.current_table) { | |
| restoreCurrentTableElement.textContent = data.progress.current_table; | |
| restoreSubstatusElement.textContent = `Processing ${data.progress.current_table}`; | |
| } else { | |
| restoreSubstatusElement.textContent = `Processing database objects...`; | |
| } | |
| // Estimate progress based on tables completed (if total is known, otherwise just show activity) | |
| // This is a rough estimate as table sizes vary greatly. | |
| if (data.progress.tables_completed > 0) { | |
| // Simple visual indication of progress, not accurate percentage | |
| const pseudoPercent = Math.min(99, Math.max(5, (data.progress.tables_completed % 20) * 5)); // Cycle 5-99% based on table count | |
| restoreProgressBar.style.width = `${pseudoPercent}%`; | |
| restoreProgressValue.textContent = `~${pseudoPercent}%`; | |
| } else { | |
| restoreProgressBar.style.width = `2%`; // Show minimal progress initially | |
| restoreProgressValue.textContent = `~2%`; | |
| } | |
| restoreStatusElement.textContent = 'Running'; | |
| restoreProgressBar.classList.add('animated'); | |
| } | |
| } | |
| // Initialize | |
| async function initialize() { | |
| // Load connection strings from localStorage | |
| const savedSourceConn = localStorage.getItem('source_conn'); | |
| const savedTargetConn = localStorage.getItem('target_conn'); | |
| if (savedSourceConn) { | |
| sourceConnInput.value = savedSourceConn; | |
| } | |
| if (savedTargetConn) { | |
| targetConnInput.value = savedTargetConn; | |
| } | |
| // Update command previews based on loaded values | |
| updateDumpCommandPreview(); | |
| updateRestoreCommandPreview(); | |
| // Load server backups | |
| await loadServerBackups(); | |
| // Initial status check | |
| await updateStatus(); // This also updates migration_state | |
| // Add initial log check | |
| await checkForNewLogs(); | |
| const savedTab = localStorage.getItem('active_tab'); | |
| if (savedTab && document.querySelector(`.tab[data-tab="${savedTab}"]`)) { | |
| document.querySelector(`.tab[data-tab="${savedTab}"]`).click(); | |
| } | |
| // Reflect completed dump state on reload | |
| if (migration_state && migration_state.dump_file) { | |
| dumpProgressSection.classList.remove('hidden'); | |
| dumpFilePathElement.textContent = migration_state.dump_file; | |
| currentSizeElement.textContent = `${(migration_state.dump_file_size || 0).toFixed(2)} MB`; | |
| if (migration_state.dump_completed) { | |
| dumpStatusElement.textContent = 'Completed'; | |
| dumpStatusElement.className = 'badge success'; | |
| const downloadPath = `/downloads/${migration_state.dump_file.split(/[\\/]/).pop()}`; | |
| downloadDumpBtn.href = downloadPath; | |
| downloadDumpBtn.disabled = false; | |
| gotoRestoreFromDumpBtn.disabled = false; | |
| statusBadge.className = 'status-badge success'; | |
| statusBadge.textContent = 'Dump Complete'; | |
| } else if (migration_state.running && migration_state.operation === 'dump') { | |
| dumpStatusElement.textContent = 'In Progress'; | |
| dumpStatusElement.className = 'badge info'; | |
| } | |
| } | |
| // Reflect completed restore state on reload | |
| if (migration_state && migration_state.restore_completed) { | |
| restoreProgressSection.classList.remove('hidden'); | |
| restoreStatusElement.textContent = 'Completed'; | |
| restoreSubstatusElement.textContent = 'Restore completed successfully'; | |
| restoreProgressBar.style.width = '100%'; | |
| restoreProgressBar.classList.remove('animated'); | |
| restoreProgressValue.textContent = '100%'; | |
| statusBadge.className = 'status-badge success'; | |
| statusBadge.textContent = 'Restore Complete'; | |
| } | |
| // If a process was running when the page loaded, sync UI and start updates | |
| if (migration_state && migration_state.running) { | |
| startStatusUpdates(); | |
| startElapsedTimeCounter(); // Restart timer based on backend start_time | |
| if (migration_state.operation === 'dump') { | |
| dumpProgressSection.classList.remove('hidden'); | |
| stopDumpBtn.disabled = false; | |
| startDumpBtn.disabled = true; | |
| startRestoreBtn.disabled = true; | |
| if (migration_state.dump_file) dumpFilePathElement.textContent = migration_state.dump_file; | |
| // Restore chart data if possible (limited history) | |
| // This part is complex and might require storing recent points in backend state | |
| } else if (migration_state.operation === 'restore') { | |
| restoreProgressSection.classList.remove('hidden'); | |
| stopRestoreBtn.disabled = false; | |
| startRestoreBtn.disabled = true; | |
| startDumpBtn.disabled = true; | |
| } | |
| } | |
| } | |
| // Initialize app | |
| initialize(); | |
| // Restore button click (from dump section) | |
| gotoRestoreFromDumpBtn.addEventListener('click', () => { | |
| // Fetch latest status to get the correct dump file path | |
| fetch('/status').then(res => res.json()).then(data => { | |
| if (data && data.dump_file) { | |
| const dumpPath = data.dump_file; | |
| const options = serverBackupFile.options; | |
| let found = false; | |
| for (let i = 0; i < options.length; i++) { | |
| if (options[i].value === dumpPath) { | |
| serverBackupFile.selectedIndex = i; | |
| found = true; | |
| break; | |
| } | |
| } | |
| if (!found) { | |
| showToast('warning', 'Backup Not Found', 'The completed backup file is not in the dropdown list. Refreshing list...'); | |
| loadServerBackups().then(() => { // Reload and try again | |
| for (let i = 0; i < serverBackupFile.options.length; i++) { | |
| if (serverBackupFile.options[i].value === dumpPath) { | |
| serverBackupFile.selectedIndex = i; | |
| break; | |
| } | |
| } | |
| }); | |
| } | |
| // Switch to restore tab | |
| document.querySelector('.tab[data-tab="restore"]').click(); | |
| updateRestoreCommandPreview(); // Update preview with selected file | |
| } else { | |
| showToast('error', 'Error', 'Cannot determine which backup to restore.'); | |
| } | |
| }).catch(err => { | |
| console.error("Error fetching status for restore button:", err); | |
| showToast('error', 'Error', 'Could not get latest status.'); | |
| }); | |
| }); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # Update the file content | |
| with open("templates/index.html", "w", encoding="utf-8") as f: | |
| f.write(html_content) | |
| return templates.TemplateResponse("index.html", {"request": request}) | |
| async def test_connection_endpoint(data: Dict[str, str]): | |
| """Test a database connection and get basic info; if source, pre-load TimescaleDB chunk map.""" | |
| try: | |
| connection_string = data.get("connection_string") | |
| connection_type = data.get("connection_type", "source") | |
| if not connection_string: | |
| return JSONResponse( | |
| status_code=400, | |
| content={"success": False, "message": "Connection string is required"} | |
| ) | |
| if not test_connection_logic(connection_string): | |
| return JSONResponse( | |
| content={"success": False, "message": "Failed to connect to database"} | |
| ) | |
| conn = psycopg2.connect(connection_string) | |
| try: | |
| chunk_summary_resp = None | |
| with conn.cursor() as cur: | |
| cur.execute("SELECT version()") | |
| version_result = cur.fetchone() | |
| version = version_result[0] if version_result else "Unknown" | |
| # Check TimescaleDB | |
| is_timescaledb = False | |
| ts_version = None | |
| try: | |
| cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb');") | |
| if cur.fetchone()[0]: | |
| cur.execute("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'") | |
| ts_version_result = cur.fetchone() | |
| is_timescaledb = ts_version_result is not None | |
| ts_version = ts_version_result[0] if ts_version_result else None | |
| except psycopg2.Error as ts_err: | |
| logger.warning(f"Could not check TimescaleDB extension: {ts_err}") | |
| cur.execute("SELECT current_database()") | |
| db_result = cur.fetchone() | |
| database = db_result[0] if db_result else "Unknown" | |
| server_match = "Unknown" | |
| try: | |
| host_part = connection_string.split('@')[-1].split('/')[0].split(':')[0] | |
| if host_part: | |
| server_match = host_part | |
| elif " on " in version: | |
| server_match = version.split(" on ")[-1].split(",")[0] | |
| except Exception: | |
| logger.warning("Could not parse server host from connection string or version.") | |
| # If it's the source and TimescaleDB is present, build and cache chunk info | |
| if connection_type == "source" and is_timescaledb: | |
| chunk_info = build_timescaledb_chunk_info(conn) | |
| with migration_lock: | |
| migration_state["chunk_info_internal"] = chunk_info | |
| if chunk_info and chunk_info.get("summary"): | |
| total_mb = round(chunk_info["summary"]["total_effective_size_bytes"] / (1024 * 1024), 2) | |
| chunk_summary_resp = { | |
| "chunks_count": chunk_info["summary"]["chunks_count"], | |
| "hypertables_count": chunk_info["summary"]["hypertables_count"], | |
| "total_effective_size_mb": total_mb | |
| } | |
| log_message( | |
| f"Loaded TimescaleDB chunk map: {chunk_summary_resp['chunks_count']} chunks across " | |
| f"{chunk_summary_resp['hypertables_count']} hypertables, ~{total_mb} MB effective data.", | |
| "info" | |
| ) | |
| log_message(f"Successful connection test to {connection_type} database: {database} on {server_match}", "success") | |
| return JSONResponse(content={ | |
| "success": True, | |
| "version": version, | |
| "is_timescaledb": is_timescaledb, | |
| "timescaledb_version": ts_version, | |
| "database": database, | |
| "server": server_match, | |
| "chunk_summary": chunk_summary_resp # May be None if not source or no TSDB | |
| }) | |
| finally: | |
| conn.close() | |
| except psycopg2.Error as db_err: | |
| log_message(f"Database connection error during info fetch: {str(db_err)}", "error") | |
| return JSONResponse( | |
| content={"success": False, "message": f"Database error: {str(db_err)}"} | |
| ) | |
| except Exception as e: | |
| log_message(f"Connection test failed unexpectedly: {str(e)}", "error") | |
| return JSONResponse( | |
| status_code=500, | |
| content={"success": False, "message": f"An unexpected error occurred: {str(e)}"} | |
| ) | |
| async def get_database_info(data: Dict[str, str]): | |
| """Get additional database information like table count and size""" | |
| try: | |
| connection_string = data.get("connection_string") | |
| if not connection_string: | |
| return JSONResponse( | |
| status_code=400, | |
| content={"success": False, "message": "Connection string is required"} | |
| ) | |
| # Use the tested connection logic first | |
| if not test_connection_logic(connection_string): | |
| return JSONResponse( | |
| content={"success": False, "message": "Connection failed"} | |
| ) | |
| conn = psycopg2.connect(connection_string) | |
| try: | |
| table_count = 0 | |
| db_size = "Unknown" | |
| with conn.cursor() as cur: | |
| # Get table count (excluding system, temp, and TOAST schemas) | |
| cur.execute(""" | |
| SELECT count(*) FROM information_schema.tables | |
| WHERE table_schema NOT IN ('pg_catalog', 'information_schema') | |
| AND table_schema NOT LIKE 'pg_toast%' | |
| AND table_schema NOT LIKE 'pg_temp%' | |
| AND table_type = 'BASE TABLE' | |
| """) | |
| count_result = cur.fetchone() | |
| table_count = count_result[0] if count_result else 0 | |
| # Get database size | |
| cur.execute("SELECT pg_size_pretty(pg_database_size(current_database()))") | |
| size_result = cur.fetchone() | |
| db_size = size_result[0] if size_result else "Unknown" | |
| return JSONResponse(content={ | |
| "success": True, | |
| "table_count": table_count, | |
| "database_size": db_size | |
| }) | |
| finally: | |
| conn.close() | |
| except psycopg2.Error as db_err: | |
| log_message(f"Failed to get database info: {str(db_err)}", "error") | |
| return JSONResponse( | |
| content={"success": False, "message": f"Database query error: {str(db_err)}"} | |
| ) | |
| except Exception as e: | |
| log_message(f"Failed to get database info: {str(e)}", "error") | |
| return JSONResponse( | |
| status_code=500, | |
| content={"success": False, "message": f"An unexpected error occurred: {str(e)}"} | |
| ) | |
| async def start_dump(data: Dict[str, Any], background_tasks: BackgroundTasks): | |
| """Start a database dump process""" | |
| try: | |
| source_conn = data.get("source_conn") | |
| options = data.get("options", {}) | |
| if not source_conn: | |
| return JSONResponse( | |
| status_code=400, | |
| content={"success": False, "message": "Source connection string is required"} | |
| ) | |
| if not test_connection_logic(source_conn): | |
| return JSONResponse( | |
| status_code=400, | |
| content={"success": False, "message": "Source connection failed. Cannot start dump."} | |
| ) | |
| if migration_state["running"]: | |
| logger.warning("Another process is running. Stopping it before starting dump.") | |
| stopped = stop_current_process() | |
| if not stopped: | |
| logger.error("Failed to stop the existing process. Cannot start dump.") | |
| return JSONResponse( | |
| status_code=500, | |
| content={"success": False, "message": "Failed to stop the currently running process."} | |
| ) | |
| time.sleep(0.5) | |
| filename = options.get("filename", "timescale_backup").strip() | |
| filename = filename.replace(" ", "_").replace("..", "").replace("/", "").replace("\\", "") | |
| if not filename: | |
| filename = "timescale_backup" | |
| format_flag = options.get("format", "c") | |
| extension = ".dump" | |
| if format_flag == "p": | |
| extension = ".sql" | |
| elif format_flag == "d": | |
| extension = "" | |
| elif format_flag == "t": | |
| extension = ".tar" | |
| dumps_dir = Path("dumps").resolve() | |
| file_path = dumps_dir / f"{filename}{extension}" | |
| if not str(file_path).startswith(str(dumps_dir)): | |
| logger.error(f"Invalid filename resulted in path traversal attempt: {filename}") | |
| return JSONResponse( | |
| status_code=400, | |
| content={"success": False, "message": "Invalid filename specified."} | |
| ) | |
| # Read expected totals from chunk info (if any loaded from source test) | |
| with migration_lock: | |
| chunk_info = migration_state.get("chunk_info_internal") | |
| expected_bytes = 0 | |
| chunks_total = 0 | |
| if chunk_info and "summary" in chunk_info: | |
| expected_bytes = int(chunk_info["summary"].get("total_effective_size_bytes", 0) or 0) | |
| chunks_total = int(chunk_info["summary"].get("chunks_count", 0) or 0) | |
| migration_state["id"] = str(uuid.uuid4()) | |
| migration_state["running"] = False | |
| migration_state["operation"] = "dump" | |
| migration_state["start_time"] = None | |
| migration_state["end_time"] = None | |
| migration_state["dump_file"] = str(file_path) | |
| migration_state["dump_file_size"] = 0 | |
| migration_state["previous_size"] = 0 | |
| migration_state["dump_completed"] = False | |
| migration_state["restore_completed"] = False | |
| migration_state["last_activity"] = time.time() | |
| migration_state["process"] = None | |
| migration_state["progress"] = { | |
| "current_table": None, | |
| "tables_completed": 0, | |
| "total_tables": 0, | |
| "current_size_mb": 0, | |
| "growth_rate_mb_per_sec": 0, | |
| "estimated_time_remaining": None, | |
| "percent_complete": 0, | |
| "total_expected_bytes": expected_bytes, | |
| "bytes_completed": 0, | |
| "chunks_completed": 0, | |
| "chunks_total": chunks_total, | |
| "counted_chunk_names": [] | |
| } | |
| if expected_bytes > 0: | |
| log_message(f"Using chunk map for dump progress: {round(expected_bytes / (1024*1024), 2)} MB across {chunks_total} chunks.", "info") | |
| background_tasks.add_task(run_dump, source_conn, str(file_path), options) | |
| try: | |
| source_safe_preview = source_conn.replace(source_conn.split('://')[1].split(':')[1].split('@')[0], '***') | |
| except: | |
| source_safe_preview = "postgres://user:***@host/db" | |
| cmd_preview = f'"{source_safe_preview}" -F{format_flag} -v' | |
| if options.get("compression") and options["compression"] != "default": | |
| cmd_preview += f' -Z {options["compression"]}' | |
| if options.get("schema"): | |
| cmd_preview += f' -n "{options["schema"]}"' | |
| cmd_preview += f' -f "{os.path.basename(file_path)}"' | |
| return JSONResponse(content={ | |
| "success": True, | |
| "message": "Dump process initiated", | |
| "file_path": str(file_path), | |
| "command_preview": cmd_preview | |
| }) | |
| except Exception as e: | |
| log_message(f"Failed to start dump: {str(e)}", "error") | |
| return JSONResponse( | |
| status_code=500, | |
| content={"success": False, "message": f"An unexpected error occurred: {str(e)}"} | |
| ) | |
| async def start_restore(data: Dict[str, Any], background_tasks: BackgroundTasks): | |
| """Start a database restore process""" | |
| try: | |
| target_conn = data.get("target_conn") | |
| dump_file = data.get("dump_file") | |
| options = data.get("options", {}) | |
| if not target_conn: | |
| return JSONResponse( | |
| status_code=400, | |
| content={"success": False, "message": "Target connection string is required"} | |
| ) | |
| if not dump_file: | |
| return JSONResponse( | |
| status_code=400, | |
| content={"success": False, "message": "Dump file is required"} | |
| ) | |
| if not test_connection_logic(target_conn): | |
| return JSONResponse( | |
| status_code=400, | |
| content={"success": False, "message": "Target connection failed. Cannot start restore."} | |
| ) | |
| dumps_dir = Path("dumps").resolve() | |
| dump_file_path = Path(dump_file).resolve() | |
| if not dump_file_path.exists() or not str(dump_file_path).startswith(str(dumps_dir)): | |
| logger.error(f"Invalid or non-existent dump file specified: {dump_file}") | |
| return JSONResponse( | |
| status_code=400, | |
| content={"success": False, "message": "Invalid or non-existent dump file selected."} | |
| ) | |
| if migration_state["running"]: | |
| logger.warning("Another process is running. Stopping it before starting restore.") | |
| stopped = stop_current_process() | |
| if not stopped: | |
| logger.error("Failed to stop the existing process. Cannot start restore.") | |
| return JSONResponse( | |
| status_code=500, | |
| content={"success": False, "message": "Failed to stop the currently running process."} | |
| ) | |
| time.sleep(0.5) | |
| with migration_lock: | |
| chunk_info = migration_state.get("chunk_info_internal") | |
| expected_bytes = 0 | |
| chunks_total = 0 | |
| if chunk_info and "summary" in chunk_info: | |
| expected_bytes = int(chunk_info["summary"].get("total_effective_size_bytes", 0) or 0) | |
| chunks_total = int(chunk_info["summary"].get("chunks_count", 0) or 0) | |
| migration_state["id"] = str(uuid.uuid4()) | |
| migration_state["running"] = False | |
| migration_state["operation"] = "restore" | |
| migration_state["start_time"] = None | |
| migration_state["end_time"] = None | |
| migration_state["dump_file"] = None | |
| migration_state["dump_file_size"] = 0 | |
| migration_state["previous_size"] = 0 | |
| migration_state["dump_completed"] = False | |
| migration_state["restore_completed"] = False | |
| migration_state["last_activity"] = time.time() | |
| migration_state["process"] = None | |
| migration_state["progress"] = { | |
| "current_table": None, | |
| "tables_completed": 0, | |
| "total_tables": 0, | |
| "current_size_mb": 0, | |
| "growth_rate_mb_per_sec": 0, | |
| "estimated_time_remaining": None, | |
| "percent_complete": 0, | |
| "total_expected_bytes": expected_bytes, | |
| "bytes_completed": 0, | |
| "chunks_completed": 0, | |
| "chunks_total": chunks_total, | |
| "counted_chunk_names": [] | |
| } | |
| if expected_bytes > 0: | |
| log_message(f"Using chunk map for restore progress: {round(expected_bytes / (1024*1024), 2)} MB across {chunks_total} chunks.", "info") | |
| background_tasks.add_task(run_restore, target_conn, str(dump_file_path), options) | |
| try: | |
| target_safe_preview = target_conn.replace(target_conn.split('://')[1].split(':')[1].split('@')[0], '***') | |
| except: | |
| target_safe_preview = "postgres://user:***@host/db" | |
| cmd_preview = f'-d "{target_safe_preview}" -v' | |
| if options.get("no_owner", True): | |
| cmd_preview += " --no-owner" | |
| if options.get("clean", False): | |
| cmd_preview += " --clean" | |
| # Default OFF now for single-transaction | |
| if options.get("single_transaction", False): | |
| cmd_preview += " --single-transaction" | |
| cmd_preview += f' "{os.path.basename(dump_file)}"' | |
| return JSONResponse(content={ | |
| "success": True, | |
| "message": "Restore process initiated", | |
| "command_preview": cmd_preview | |
| }) | |
| except Exception as e: | |
| log_message(f"Failed to start restore: {str(e)}", "error") | |
| return JSONResponse( | |
| status_code=500, | |
| content={"success": False, "message": f"An unexpected error occurred: {str(e)}"} | |
| ) | |
| async def stop_process_endpoint(): | |
| """Stop the current database process""" | |
| try: | |
| stopped = stop_current_process() | |
| if stopped: | |
| return JSONResponse(content={ | |
| "success": True, | |
| "message": "Process stop initiated successfully" # Changed message slightly | |
| }) | |
| else: | |
| # Check if it wasn't running in the first place or if stop failed | |
| with migration_lock: | |
| is_running = migration_state["running"] | |
| was_process = migration_state["process"] is not None # Check if we *thought* a process existed | |
| if not is_running and not was_process: | |
| return JSONResponse(content={ | |
| "success": False, # Technically not an error, but no action taken | |
| "message": "No process was running to stop" | |
| }) | |
| else: # Stop was called, but failed internally or process already gone | |
| # The stop_current_process function now returns False on failure or if already stopped | |
| # Check logs for specific reason | |
| return JSONResponse( | |
| status_code=200, # Return 200, but indicate potential issue in message | |
| content={ | |
| "success": False, # Indicate stop wasn't fully successful *now* | |
| "message": "Stop command executed. Check logs for termination status." | |
| }) | |
| except Exception as e: | |
| log_message(f"Failed to stop process via endpoint: {str(e)}", "error") | |
| return JSONResponse( | |
| status_code=500, | |
| content={"success": False, "message": f"An unexpected error occurred: {str(e)}"} | |
| ) | |
| async def get_status(): | |
| """Get the current migration status (without heavy internal structures).""" | |
| with migration_lock: | |
| state_copy = migration_state.copy() | |
| state_copy["process"] = None | |
| # Remove heavy internal chunk details; expose a small summary instead | |
| if state_copy.get("chunk_info_internal"): | |
| chunk_info = state_copy["chunk_info_internal"] | |
| summary = chunk_info.get("summary", {}) if chunk_info else {} | |
| if "total_effective_size_bytes" in summary: | |
| total_mb = round(summary["total_effective_size_bytes"] / (1024 * 1024), 2) | |
| else: | |
| total_mb = None | |
| state_copy["chunk_summary"] = { | |
| "chunks_count": summary.get("chunks_count"), | |
| "hypertables_count": summary.get("hypertables_count"), | |
| "total_effective_size_mb": total_mb | |
| } | |
| del state_copy["chunk_info_internal"] | |
| # Trim internal counted names (can be large) | |
| if "progress" in state_copy and "counted_chunk_names" in state_copy["progress"]: | |
| state_copy["progress"]["counted_chunk_names"] = [] | |
| return state_copy | |
| async def clear_logs(): | |
| """Clear all logs""" | |
| with migration_lock: | |
| migration_state["log"] = [] | |
| log_message("Logs cleared by user.", "info") | |
| return JSONResponse(content={"success": True, "message": "Logs cleared"}) | |
| async def list_dumps(): | |
| """List available dump files""" | |
| try: | |
| dumps_dir = Path("dumps") | |
| if not dumps_dir.exists(): | |
| dumps_dir.mkdir() | |
| return JSONResponse(content={"success": True, "dumps": []}) | |
| dump_files = [] | |
| for f in dumps_dir.iterdir(): # Use iterdir for potentially large directories | |
| # List files AND directories (for format=d) | |
| if f.is_file() or f.is_dir(): | |
| try: | |
| stat_result = f.stat() | |
| file_size = stat_result.st_size | |
| modified_time = datetime.datetime.fromtimestamp(stat_result.st_mtime) | |
| # Calculate size for directories (basic sum of top-level files) | |
| if f.is_dir(): | |
| try: | |
| dir_size = sum(item.stat().st_size for item in f.iterdir() if item.is_file()) | |
| file_size = dir_size | |
| except Exception as dir_err: | |
| logger.warning(f"Could not calculate size for directory {f.name}: {dir_err}") | |
| file_size = 0 # Or mark as unknown size | |
| dump_files.append({ | |
| "name": f.name, | |
| "path": str(f), # Send full path back | |
| "size_mb": file_size / (1024 * 1024), | |
| "date": modified_time.strftime("%Y-%m-%d %H:%M:%S"), | |
| "is_dir": f.is_dir() # Indicate if it's a directory dump | |
| }) | |
| except OSError as stat_err: | |
| logger.error(f"Could not stat file/dir {f.name}: {stat_err}") | |
| # Skip this file/dir if cannot access stats | |
| # Sort by modified time, newest first | |
| dump_files.sort(key=lambda x: x["date"], reverse=True) | |
| return JSONResponse(content={"success": True, "dumps": dump_files}) | |
| except Exception as e: | |
| log_message(f"Failed to list dumps: {str(e)}", "error") | |
| return JSONResponse( | |
| status_code=500, | |
| content={"success": False, "message": f"An unexpected error occurred: {str(e)}"} | |
| ) | |
| # Use path parameter to allow slashes if needed (though unlikely now) | |
| async def download_file(file_name: str): | |
| """Download a dump file (does not support directory download)""" | |
| try: | |
| dumps_dir = Path("dumps").resolve() | |
| # Sanitize file_name to prevent path traversal | |
| file_name = file_name.replace("..", "").replace("/", "").replace("\\", "") | |
| file_path = dumps_dir / file_name | |
| # Check existence and that it's within the dumps dir and is a file | |
| if not file_path.exists() or not str(file_path).startswith(str(dumps_dir)): | |
| raise HTTPException(status_code=404, detail="File not found or invalid path") | |
| if file_path.is_dir(): | |
| raise HTTPException(status_code=400, detail="Directory downloads are not supported via this endpoint.") | |
| return FileResponse( | |
| path=str(file_path), | |
| filename=file_name, # Suggest original (sanitized) name to browser | |
| media_type="application/octet-stream" # Generic binary type | |
| ) | |
| except HTTPException: | |
| raise # Re-raise HTTPException | |
| except Exception as e: | |
| logger.error(f"Error preparing file download for {file_name}: {e}") | |
| raise HTTPException(status_code=500, detail="Could not process file download.") | |
| if __name__ == "__main__": | |
| import uvicorn | |
| # Use reload=True for development, but turn off for production | |
| # Assuming the file is named main.py for reload to work correctly | |
| uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=True) | |
| # For production: uvicorn.run(app, host="0.0.0.0", port=7860) |