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 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, | |
"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 | |
} | |
} | |
# 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 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""" | |
try: | |
# Convert to absolute path | |
absolute_file_path = os.path.abspath(file_path) | |
# Log the path conversion | |
log_message(f"Converting path: {file_path} -> {absolute_file_path}", "info") | |
# Clear any existing file | |
if os.path.exists(absolute_file_path): | |
os.remove(absolute_file_path) | |
# Set environment variables for connection | |
env = os.environ.copy() | |
# Build pg_dump command | |
format_flag = "-F" + options.get("format", "c") # Default to custom format | |
cmd = ["pg_dump", source_conn, format_flag, "-v", "-f", absolute_file_path] | |
# Add schema if specified | |
if options.get("schema"): | |
cmd.extend(["-n", options["schema"]]) | |
# Add compression level if specified | |
if options.get("compression") and options["compression"] != "default": | |
cmd.extend(["-Z", options["compression"]]) | |
log_message(f"Starting database dump to {absolute_file_path}", "info", " ".join(cmd)) | |
# Start monitoring thread for file size | |
monitor_thread = threading.Thread(target=monitor_dump_size, daemon=True) | |
monitor_thread.start() | |
# Start the dump process | |
with migration_lock: | |
migration_state["start_time"] = time.time() | |
migration_state["running"] = True | |
migration_state["operation"] = "dump" | |
migration_state["dump_file"] = absolute_file_path | |
migration_state["dump_completed"] = False | |
process = subprocess.Popen( | |
cmd, | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
env=env, | |
text=True, | |
bufsize=1 # Line buffered to get real-time output | |
) | |
with migration_lock: | |
migration_state["process"] = process | |
# Function to read stderr in real-time | |
def read_stderr(): | |
for line in iter(process.stderr.readline, ''): | |
line = line.strip() | |
if line: | |
log_message(line, "info") | |
if "Dumping" in line and "table" in line: | |
try: | |
table_parts = line.split("Dumping") | |
if len(table_parts) > 1: | |
table_info = table_parts[1].strip() | |
table_parts = table_info.split(" ") | |
if len(table_parts) > 1: | |
table_name = table_parts[1].strip('"') | |
with migration_lock: | |
migration_state["progress"]["current_table"] = table_name | |
migration_state["progress"]["tables_completed"] += 1 | |
except Exception as e: | |
log_message(f"Error parsing table name: {str(e)}", "warning") | |
# Start stderr reading thread | |
stderr_thread = threading.Thread(target=read_stderr, daemon=True) | |
stderr_thread.start() | |
# Wait for process to complete | |
exit_code = process.wait() | |
# Wait a moment for the stderr thread to catch up | |
stderr_thread.join(timeout=2.0) | |
if exit_code == 0: | |
# Verify file exists and has content | |
if os.path.exists(absolute_file_path): | |
final_size = os.path.getsize(absolute_file_path) | |
if final_size > 0: | |
# Success - file exists and has content | |
with migration_lock: | |
migration_state["dump_file_size"] = final_size / (1024 * 1024) # Convert to MB | |
migration_state["progress"]["current_size_mb"] = round(final_size / (1024 * 1024), 2) | |
migration_state["dump_completed"] = True | |
migration_state["end_time"] = time.time() | |
migration_state["running"] = False | |
migration_state["process"] = None | |
total_time = migration_state["end_time"] - migration_state["start_time"] | |
log_message( | |
f"Database dump completed successfully. Size: {round(final_size / (1024 * 1024), 2)} MB. Time: {round(total_time, 2)} seconds", | |
"success" | |
) | |
return True | |
else: | |
log_message(f"Dump file exists but is empty (0 bytes): {absolute_file_path}", "error") | |
else: | |
log_message(f"Dump completed but file not found: {absolute_file_path}", "error") | |
# If we get here, something went wrong with the file | |
with migration_lock: | |
migration_state["dump_completed"] = False | |
migration_state["end_time"] = time.time() | |
migration_state["running"] = False | |
migration_state["process"] = None | |
return False | |
else: | |
log_message(f"Database dump failed with exit code {exit_code}", "error") | |
with migration_lock: | |
migration_state["running"] = False | |
migration_state["process"] = None | |
return False | |
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""" | |
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 # Ensure state is consistent | |
return False | |
# Set environment variables for connection | |
env = os.environ.copy() | |
# Run timescaledb_pre_restore() if specified | |
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 # Ensure state is consistent | |
return False | |
# Build pg_restore command | |
cmd = ["pg_restore", "-d", target_conn, "-v"] | |
# Add no-owner flag if specified | |
if options.get("no_owner", True): | |
cmd.append("--no-owner") | |
# Add clean flag if specified | |
if options.get("clean", False): | |
cmd.append("--clean") | |
# Add single transaction flag if specified | |
if options.get("single_transaction", True): | |
cmd.append("--single-transaction") | |
# Add file path | |
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 # Reset counter | |
# Use preexec_fn=os.setsid to create a new process group | |
preexec_fn_to_use = None | |
if hasattr(os, 'setsid'): | |
preexec_fn_to_use = os.setsid | |
process = subprocess.Popen( | |
cmd, | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
env=env, | |
text=True, | |
bufsize=1, # Line buffering | |
universal_newlines=True, | |
preexec_fn=preexec_fn_to_use # Create a new process group | |
) | |
with migration_lock: | |
migration_state["process"] = process | |
# Process output | |
if process.stderr: | |
for line in iter(process.stderr.readline, ''): | |
line = line.strip() | |
if not line: | |
continue | |
# Log verbose output | |
log_message(line, "info") | |
# Try to parse table name (pg_restore output format varies) | |
if "processing" in line.lower() and ("table data" in line.lower() or "table" in line.lower()): | |
try: | |
# Attempt to extract table name, might need refinement | |
parts = line.split() | |
table_index = -1 | |
if "table" in parts: table_index = parts.index("table") + 1 | |
elif "data" in parts: table_index = parts.index("data") + 1 | |
if table_index > 0 and table_index < len(parts): | |
table_name = parts[table_index].strip('."') | |
with migration_lock: | |
migration_state["progress"]["current_table"] = table_name | |
migration_state["progress"]["tables_completed"] += 1 | |
else: | |
logger.warning(f"Could not parse table name from restore line: {line}") | |
except Exception as parse_err: | |
logger.warning(f"Error parsing restore line '{line}': {parse_err}") | |
# Check if process is still running | |
with migration_lock: | |
if not migration_state["running"]: | |
break # Stop processing if process was terminated | |
# Wait for process to complete | |
stdout, stderr = process.communicate() | |
exit_code = process.returncode | |
post_restore_success = True | |
# Run timescaledb_post_restore() if specified and restore was successful so far | |
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 # Mark post-restore as failed | |
with migration_lock: | |
# Ensure running state is updated based on process completion | |
if migration_state["running"]: # Only update if not stopped manually | |
if exit_code == 0 and post_restore_success: | |
migration_state["restore_completed"] = True | |
migration_state["end_time"] = time.time() | |
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") | |
# If post_restore failed, it's already logged. | |
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" checked> | |
<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="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> | |
<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'); | |
// 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'); | |
}); | |
}); | |
// 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 | |
terminalOutput.scrollTop = terminalOutput.scrollHeight; | |
} | |
// 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 | |
logsOutput.scrollTop = logsOutput.scrollHeight; | |
// 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(); | |
// 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""" | |
try: | |
connection_string = data.get("connection_string") | |
connection_type = data.get("connection_type", "source") # Added type | |
if not connection_string: | |
return JSONResponse( | |
status_code=400, | |
content={"success": False, "message": "Connection string is required"} | |
) | |
# Test the connection using internal logic | |
if not test_connection_logic(connection_string): | |
# Error is logged within test_connection_logic | |
return JSONResponse( | |
content={"success": False, "message": "Failed to connect to database"} | |
) | |
# If connection successful, get database info | |
conn = psycopg2.connect(connection_string) | |
try: | |
with conn.cursor() as cur: | |
# Get server info | |
cur.execute("SELECT version()") | |
version_result = cur.fetchone() | |
version = version_result[0] if version_result else "Unknown" | |
# Check if TimescaleDB is installed and get version | |
is_timescaledb = False | |
ts_version = None | |
try: | |
# Use EXISTS for better performance and error handling if extension not present | |
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}") | |
# Continue without TimescaleDB info if query fails | |
# Get database name | |
cur.execute("SELECT current_database()") | |
db_result = cur.fetchone() | |
database = db_result[0] if db_result else "Unknown" | |
# Extract server host/IP (best effort parsing) | |
server_match = "Unknown" | |
try: | |
# Extract from connection string if possible (more reliable) | |
host_part = connection_string.split('@')[-1].split('/')[0].split(':')[0] | |
if host_part: | |
server_match = host_part | |
# Fallback to parsing version string if needed | |
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.") | |
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, # Add this line | |
"database": database, | |
"server": server_match | |
}) | |
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"} | |
) | |
# Basic validation: Test connection before starting dump | |
if not test_connection_logic(source_conn): | |
return JSONResponse( | |
status_code=400, | |
content={"success": False, "message": "Source connection failed. Cannot start dump."} | |
) | |
# Stop any running process first (important!) | |
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, # Internal Server Error might be appropriate | |
content={"success": False, "message": "Failed to stop the currently running process."} | |
) | |
# Add a small delay to allow the process to fully terminate | |
time.sleep(0.5) | |
# Create dump file path | |
filename = options.get("filename", "timescale_backup").strip() | |
# Basic filename sanitization (replace spaces, avoid path traversal) | |
filename = filename.replace(" ", "_").replace("..", "").replace("/", "").replace("\\", "") | |
if not filename: filename = "timescale_backup" # Fallback if sanitization results in empty name | |
format_flag = options.get("format", "c") | |
# Determine file extension | |
extension = ".dump" | |
if format_flag == "p": | |
extension = ".sql" | |
elif format_flag == "d": | |
extension = "" # Directory format has no extension | |
elif format_flag == "t": | |
extension = ".tar" | |
# Generate file path carefully | |
dumps_dir = Path("dumps").resolve() # Ensure absolute path | |
file_path = dumps_dir / f"{filename}{extension}" | |
# Prevent potential directory traversal if filename somehow still contains harmful chars | |
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."} | |
) | |
# Reset state before starting background task | |
with migration_lock: | |
migration_state["id"] = str(uuid.uuid4()) # New ID for new operation | |
migration_state["running"] = False # Will be set true by run_dump | |
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() | |
# Keep logs or clear them? Let's keep them for now. | |
# migration_state["log"] = [] | |
migration_state["process"] = None | |
migration_state["progress"] = { # Reset 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 | |
} | |
# Start dump in background | |
background_tasks.add_task(run_dump, source_conn, str(file_path), options) | |
# Create command preview (with redacted password) | |
# Redact password more carefully | |
try: | |
source_safe_preview = source_conn.replace(source_conn.split('://')[1].split(':')[1].split('@')[0], '***') | |
except: | |
source_safe_preview = "postgres://user:***@host/db" # Fallback preview | |
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"} | |
) | |
# Basic validation: Test connection before starting restore | |
if not test_connection_logic(target_conn): | |
return JSONResponse( | |
status_code=400, | |
content={"success": False, "message": "Target connection failed. Cannot start restore."} | |
) | |
# Validate dump file path exists and is within the dumps directory | |
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."} | |
) | |
# Stop any running process first (important!) | |
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) # Allow time for termination | |
# Reset state before starting background task | |
with migration_lock: | |
migration_state["id"] = str(uuid.uuid4()) # New ID for new operation | |
migration_state["running"] = False # Will be set true by run_restore | |
migration_state["operation"] = "restore" | |
migration_state["start_time"] = None | |
migration_state["end_time"] = None | |
migration_state["dump_file"] = None # Not relevant for restore state itself | |
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() | |
# Keep logs | |
migration_state["process"] = None | |
migration_state["progress"] = { # Reset progress | |
"current_table": None, | |
"tables_completed": 0, | |
"total_tables": 0, # We don't easily know this for restore | |
"current_size_mb": 0, | |
"growth_rate_mb_per_sec": 0, | |
"estimated_time_remaining": None, | |
"percent_complete": 0 | |
} | |
# Start restore in background | |
background_tasks.add_task(run_restore, target_conn, str(dump_file_path), options) | |
# Create command preview (with redacted password) | |
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" | |
if options.get("single_transaction", True): | |
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""" | |
# Return a copy to avoid potential modification issues if state grows complex | |
with migration_lock: | |
state_copy = migration_state.copy() | |
# Ensure process object is not sent over JSON | |
state_copy["process"] = None | |
# Optionally limit log size sent back if it gets large | |
# MAX_LOGS_IN_STATUS = 100 | |
# if len(state_copy["log"]) > MAX_LOGS_IN_STATUS: | |
# state_copy["log"] = state_copy["log"][-MAX_LOGS_IN_STATUS:] | |
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) |