Migrate / app.py
arcticaurora's picture
Update app.py
c5981f2 verified
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
@app.get("/", response_class=HTMLResponse)
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() &amp; 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, "&lt;").replace(/>/g, "&gt;"); // 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, "&lt;").replace(/>/g, "&gt;");
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})
@app.post("/test-connection")
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)}"}
)
@app.post("/database-info")
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)}"}
)
@app.post("/start-dump")
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)}"}
)
@app.post("/start-restore")
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)}"}
)
@app.post("/stop-process")
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)}"}
)
@app.get("/status")
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
@app.post("/clear-logs")
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"})
@app.get("/list-dumps")
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)}"}
)
@app.get("/downloads/{file_name:path}") # 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)