added new db downloader

This commit is contained in:
Luca Haid
2025-12-29 07:34:20 +01:00
parent c33df73697
commit 15c217cc0a
5 changed files with 1120 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
FROM python:3.12-slim-bookworm
RUN apt-get update && apt-get install -y \
mariadb-client \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY templates/ ./templates/
COPY static/ ./static/
RUN mkdir -p /app/downloads /app/ssh-keys
ENV FLASK_APP=app:app
ENV FLASK_ENV=production
ENV PYTHONUNBUFFERED=1
EXPOSE 8082
CMD ["gunicorn", "--bind", "0.0.0.0:8082", "--workers", "2", "--threads", "4", "app:app"]

444
docker/db-downloader/app.py Normal file
View File

@@ -0,0 +1,444 @@
import os
import stat
import uuid
import gzip
import struct
import subprocess
import threading
import time
from datetime import datetime
from flask import Flask, render_template, request, jsonify, session
import paramiko
# =============================================================================
# Configuration
# =============================================================================
class Config:
SCP_HOST = os.getenv('SCP_HOST', 'localhost')
SCP_PORT = int(os.getenv('SCP_PORT', 22))
SCP_USERNAME = os.getenv('SCP_USERNAME', 'root')
SCP_DEFAULT_PATH = os.getenv('SCP_DEFAULT_PATH', '/backups')
DB_HOST = os.getenv('DB_HOST', 'db')
DB_PORT = int(os.getenv('DB_PORT', 3306))
DB_USER = os.getenv('DB_USER', 'root')
DB_PASSWORD = os.getenv('DB_PASSWORD', '')
DB_AVAILABLE = os.getenv('DB_AVAILABLE', 'thetool,addressdb').split(',')
DOWNLOAD_PATH = '/app/downloads'
SSH_KEYS_PATH = '/app/ssh-keys'
SECRET_KEY = os.getenv('SECRET_KEY', os.urandom(24).hex())
# =============================================================================
# SFTP Client
# =============================================================================
class SFTPClient:
def __init__(self, host, port, username):
self.host = host
self.port = port
self.username = username
self.client = None
self.sftp = None
def connect_password(self, password):
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
hostname=self.host, port=self.port, username=self.username,
password=password, look_for_keys=False, allow_agent=False
)
self.sftp = self.client.open_sftp()
def connect_key(self, key_path, passphrase=None):
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
hostname=self.host, port=self.port, username=self.username,
key_filename=key_path, passphrase=passphrase,
look_for_keys=False, allow_agent=False
)
self.sftp = self.client.open_sftp()
def list_directory(self, path):
entries = []
for entry in self.sftp.listdir_attr(path):
is_dir = stat.S_ISDIR(entry.st_mode)
entries.append({
'name': entry.filename,
'size': entry.st_size,
'size_human': self._human_size(entry.st_size),
'mtime': entry.st_mtime,
'mtime_human': datetime.fromtimestamp(entry.st_mtime).strftime('%Y-%m-%d %H:%M'),
'is_dir': is_dir,
'is_sql': entry.filename.endswith(('.sql', '.sql.gz')),
'path': os.path.join(path, entry.filename)
})
return sorted(entries, key=lambda x: (not x['is_dir'], -x['mtime']))
def get_file_info(self, path):
entry = self.sftp.stat(path)
return {
'name': os.path.basename(path),
'size': entry.st_size,
'size_human': self._human_size(entry.st_size),
'mtime': entry.st_mtime,
'mtime_human': datetime.fromtimestamp(entry.st_mtime).strftime('%Y-%m-%d %H:%M'),
'path': path
}
def download_file(self, remote_path, local_path, callback=None):
self.sftp.get(remote_path, local_path, callback=callback)
def close(self):
if self.sftp:
self.sftp.close()
if self.client:
self.client.close()
@staticmethod
def _human_size(size):
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} PB"
# =============================================================================
# Database Restore
# =============================================================================
class DatabaseRestore:
def __init__(self):
self.host = Config.DB_HOST
self.port = Config.DB_PORT
self.user = Config.DB_USER
self.password = Config.DB_PASSWORD
self.available_dbs = Config.DB_AVAILABLE
self.cancelled = False
def cancel(self):
self.cancelled = True
@staticmethod
def get_gzip_uncompressed_size(filepath):
with open(filepath, 'rb') as f:
f.seek(-4, 2)
return struct.unpack('<I', f.read(4))[0]
def _mysql_cmd(self, *extra_args):
return ['mysql', '-h', self.host, '-P', str(self.port), '-u', self.user, f'-p{self.password}'] + list(extra_args)
def ensure_database_exists(self, target_db):
if target_db not in self.available_dbs:
raise ValueError(f"Invalid database: {target_db}")
cmd = self._mysql_cmd('-e', f"CREATE DATABASE IF NOT EXISTS `{target_db}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise Exception(f"Failed to create database: {result.stderr}")
def clear_database(self, target_db):
cmd = self._mysql_cmd('-N', '-e', f"SELECT table_name FROM information_schema.tables WHERE table_schema='{target_db}'")
result = subprocess.run(cmd, capture_output=True, text=True)
tables = [t.strip() for t in result.stdout.strip().split('\n') if t.strip()]
if tables:
drop_sql = "SET FOREIGN_KEY_CHECKS=0; " + "; ".join(f"DROP TABLE IF EXISTS `{t}`" for t in tables) + "; SET FOREIGN_KEY_CHECKS=1;"
subprocess.run(self._mysql_cmd(target_db, '-e', drop_sql), check=True, capture_output=True)
return len(tables)
def restore_from_file(self, file_path, target_db, progress_callback=None):
if target_db not in self.available_dbs:
raise ValueError(f"Invalid database: {target_db}")
self.cancelled = False
self.ensure_database_exists(target_db)
tables_dropped = self.clear_database(target_db)
if self.cancelled:
raise Exception("Restore cancelled by user")
mysql_cmd = self._mysql_cmd(target_db)
process = None
try:
if file_path.endswith('.gz'):
with gzip.open(file_path, 'rb') as f:
process = subprocess.Popen(mysql_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
bytes_read = 0
while True:
if self.cancelled:
process.terminate()
raise Exception("Restore cancelled by user")
chunk = f.read(1024 * 1024)
if not chunk:
break
if process.poll() is not None:
raise Exception(f"MySQL terminated: {process.stderr.read().decode()}")
try:
process.stdin.write(chunk)
process.stdin.flush()
except BrokenPipeError:
raise Exception(f"MySQL connection lost: {process.stderr.read().decode()}")
bytes_read += len(chunk)
if progress_callback:
progress_callback(bytes_read)
process.stdin.close()
process.wait(timeout=300)
if process.returncode != 0:
raise Exception(f"MySQL restore failed: {process.stderr.read().decode()}")
else:
with open(file_path, 'rb') as f:
result = subprocess.run(mysql_cmd, stdin=f, capture_output=True, timeout=600)
if result.returncode != 0:
raise Exception(f"MySQL restore failed: {result.stderr.decode()}")
except subprocess.TimeoutExpired:
if process:
process.kill()
raise Exception("MySQL restore timed out")
return {'tables_dropped': tables_dropped, 'file': os.path.basename(file_path), 'database': target_db}
# =============================================================================
# Flask Application
# =============================================================================
app = Flask(__name__)
app.config['SECRET_KEY'] = Config.SECRET_KEY
# Job storage
jobs = {}
restorers = {}
@app.route('/')
def index():
return render_template('index.html', databases=Config.DB_AVAILABLE, scp_host=Config.SCP_HOST, scp_username=Config.SCP_USERNAME)
@app.route('/health')
def health():
return {'status': 'ok'}
@app.route('/api/keys', methods=['GET'])
def list_keys():
keys = []
if os.path.exists(Config.SSH_KEYS_PATH):
keys = [f for f in os.listdir(Config.SSH_KEYS_PATH) if not f.endswith('.pub') and not f.startswith('.')]
return jsonify({'success': True, 'keys': keys})
@app.route('/api/connect', methods=['POST'])
def connect():
data = request.json
auth_type = data.get('auth_type', 'password')
try:
client = SFTPClient(Config.SCP_HOST, Config.SCP_PORT, Config.SCP_USERNAME)
if auth_type == 'password':
if not data.get('password'):
return jsonify({'success': False, 'error': 'Password is required'}), 400
client.connect_password(data['password'])
else:
if not data.get('key_file'):
return jsonify({'success': False, 'error': 'SSH key file is required'}), 400
key_path = os.path.join(Config.SSH_KEYS_PATH, data['key_file'])
if not os.path.exists(key_path):
return jsonify({'success': False, 'error': 'SSH key file not found'}), 400
client.connect_key(key_path, data.get('key_passphrase'))
files = client.list_directory(Config.SCP_DEFAULT_PATH)
client.close()
session['sftp_auth'] = {
'type': auth_type,
'password': data.get('password'),
'key_file': data.get('key_file'),
'key_passphrase': data.get('key_passphrase')
}
session['connected'] = True
return jsonify({'success': True, 'files': files, 'path': Config.SCP_DEFAULT_PATH, 'host': Config.SCP_HOST, 'username': Config.SCP_USERNAME})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 400
@app.route('/api/browse', methods=['POST'])
def browse():
if not session.get('connected'):
return jsonify({'success': False, 'error': 'Not connected'}), 401
auth = session.get('sftp_auth')
if not auth:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
path = request.json.get('path', Config.SCP_DEFAULT_PATH)
try:
client = SFTPClient(Config.SCP_HOST, Config.SCP_PORT, Config.SCP_USERNAME)
if auth['type'] == 'password':
client.connect_password(auth['password'])
else:
client.connect_key(os.path.join(Config.SSH_KEYS_PATH, auth['key_file']), auth.get('key_passphrase'))
files = client.list_directory(path)
client.close()
return jsonify({'success': True, 'files': files, 'path': path})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 400
@app.route('/api/disconnect', methods=['POST'])
def disconnect():
session.pop('sftp_auth', None)
session.pop('connected', None)
return jsonify({'success': True})
@app.route('/api/databases', methods=['GET'])
def list_databases():
return jsonify({'success': True, 'databases': Config.DB_AVAILABLE})
@app.route('/api/restore', methods=['POST'])
def restore():
if not session.get('connected'):
return jsonify({'success': False, 'error': 'Not connected to SFTP'}), 401
data = request.json
remote_file = data.get('file')
target_db = data.get('database')
if not remote_file:
return jsonify({'success': False, 'error': 'No file selected'}), 400
if target_db not in Config.DB_AVAILABLE:
return jsonify({'success': False, 'error': f'Invalid database'}), 400
auth = session.get('sftp_auth')
if not auth:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
job_id = str(uuid.uuid4())
jobs[job_id] = {
'status': 'starting', 'progress': 0, 'file': os.path.basename(remote_file),
'database': target_db, 'started_at': time.time(), 'message': 'Initializing...'
}
thread = threading.Thread(target=run_restore, args=(job_id, remote_file, target_db, dict(auth)))
thread.daemon = True
thread.start()
return jsonify({'success': True, 'job_id': job_id})
def run_restore(job_id, remote_file, target_db, auth):
local_file = os.path.join(Config.DOWNLOAD_PATH, os.path.basename(remote_file))
try:
if jobs[job_id].get('cancelled'):
jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'})
return
jobs[job_id].update({'status': 'downloading', 'message': 'Connecting to remote server...'})
client = SFTPClient(Config.SCP_HOST, Config.SCP_PORT, Config.SCP_USERNAME)
if auth['type'] == 'password':
client.connect_password(auth['password'])
else:
client.connect_key(os.path.join(Config.SSH_KEYS_PATH, auth['key_file']), auth.get('key_passphrase'))
file_info = client.get_file_info(remote_file)
jobs[job_id]['file_size'] = file_info['size_human']
jobs[job_id]['message'] = f'Downloading {file_info["size_human"]}...'
def download_progress(transferred, total):
if jobs[job_id].get('cancelled'):
raise Exception("Download cancelled by user")
jobs[job_id].update({'progress': int((transferred / total) * 45) if total > 0 else 0, 'downloaded': transferred, 'total': total})
client.download_file(remote_file, local_file, callback=download_progress)
client.close()
if jobs[job_id].get('cancelled'):
jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'})
if os.path.exists(local_file):
os.remove(local_file)
return
jobs[job_id].update({'progress': 45, 'message': 'Download complete. Preparing restore...', 'status': 'restoring'})
jobs[job_id]['progress'] = 50
jobs[job_id]['message'] = f'Clearing database {target_db}...'
restorer = DatabaseRestore()
restorers[job_id] = restorer
uncompressed_size = restorer.get_gzip_uncompressed_size(local_file) if local_file.endswith('.gz') else os.path.getsize(local_file)
def restore_progress(bytes_processed):
if jobs[job_id].get('cancelled'):
restorer.cancel()
pct = 50 + min(45, int((bytes_processed / uncompressed_size) * 45)) if uncompressed_size > 0 else 50
jobs[job_id].update({'progress': pct, 'message': f'Restoring to {target_db}... ({bytes_processed // (1024*1024)} MB / {uncompressed_size // (1024*1024)} MB)'})
result = restorer.restore_from_file(local_file, target_db, progress_callback=restore_progress)
if os.path.exists(local_file):
os.remove(local_file)
jobs[job_id].update({
'status': 'completed', 'progress': 100,
'message': f'Restore complete! Dropped {result["tables_dropped"]} tables and imported {result["file"]}',
'completed_at': time.time(), 'duration': time.time() - jobs[job_id]['started_at']
})
except Exception as e:
error_msg = str(e)
if 'cancelled' in error_msg.lower():
jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'})
else:
jobs[job_id].update({'status': 'error', 'error': error_msg, 'message': f'Error: {error_msg}'})
if os.path.exists(local_file):
os.remove(local_file)
finally:
restorers.pop(job_id, None)
@app.route('/api/status/<job_id>')
def status(job_id):
if job_id not in jobs:
return jsonify({'success': False, 'error': 'Job not found'}), 404
job = jobs[job_id].copy()
job['success'] = True
if 'started_at' in job:
elapsed = (job.get('completed_at') or time.time()) - job['started_at']
job['elapsed'] = f'{int(elapsed // 60)}m {int(elapsed % 60)}s'
return jsonify(job)
@app.route('/api/jobs', methods=['GET'])
def list_jobs():
return jsonify({'success': True, 'jobs': dict(jobs)})
@app.route('/api/cancel/<job_id>', methods=['POST'])
def cancel(job_id):
if job_id not in jobs:
return jsonify({'success': False, 'error': 'Job not found'}), 404
if jobs[job_id]['status'] in ('completed', 'error', 'cancelled'):
return jsonify({'success': False, 'error': 'Job already finished'}), 400
jobs[job_id]['cancelled'] = True
if job_id in restorers:
restorers[job_id].cancel()
return jsonify({'success': True, 'message': 'Cancel signal sent'})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8082, debug=True)

View File

@@ -0,0 +1,5 @@
flask==3.0.0
gunicorn==21.2.0
paramiko==3.4.0
mysql-connector-python==8.2.0
python-dotenv==1.0.0

View File

@@ -0,0 +1,457 @@
// DB Restore Tool - Frontend JavaScript
let currentPath = '';
let selectedFile = null;
let isConnected = false;
let currentJobId = null;
let pollInterval = null;
// Initialize
document.addEventListener('DOMContentLoaded', function() {
loadAvailableKeys();
setupEventListeners();
});
function setupEventListeners() {
// Auth type toggle
document.getElementById('auth-type').addEventListener('change', function() {
const passwordAuth = document.getElementById('password-auth');
const keyAuth = document.getElementById('key-auth');
if (this.value === 'password') {
passwordAuth.classList.remove('hidden');
keyAuth.classList.add('hidden');
} else {
passwordAuth.classList.add('hidden');
keyAuth.classList.remove('hidden');
}
});
// Connect form
document.getElementById('connect-form').addEventListener('submit', function(e) {
e.preventDefault();
connect();
});
// Disconnect button
document.getElementById('disconnect-btn').addEventListener('click', disconnect);
// Restore button
document.getElementById('restore-btn').addEventListener('click', startRestore);
// Cancel button
document.getElementById('cancel-btn').addEventListener('click', cancelRestore);
}
async function loadAvailableKeys() {
try {
const response = await fetch('/api/keys');
const data = await response.json();
const select = document.getElementById('key-file');
select.innerHTML = '<option value="">Select a key...</option>';
data.keys.forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = key;
select.appendChild(option);
});
} catch (error) {
console.error('Failed to load keys:', error);
}
}
async function connect() {
const authType = document.getElementById('auth-type').value;
const password = document.getElementById('password').value;
const keyFile = document.getElementById('key-file').value;
const keyPassphrase = document.getElementById('key-passphrase').value;
const btn = document.getElementById('connect-btn');
btn.disabled = true;
btn.textContent = 'Connecting...';
try {
const response = await fetch('/api/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
auth_type: authType,
password: password,
key_file: keyFile,
key_passphrase: keyPassphrase
})
});
const data = await response.json();
if (data.success) {
isConnected = true;
currentPath = data.path;
showStatus('Connected to ' + data.host, 'success');
renderFiles(data.files, data.path);
updateBreadcrumb(data.path);
// Toggle buttons
btn.classList.add('hidden');
document.getElementById('disconnect-btn').classList.remove('hidden');
// Clear password field for security
document.getElementById('password').value = '';
} else {
showStatus('Connection failed: ' + data.error, 'error');
}
} catch (error) {
showStatus('Connection error: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Connect';
}
}
async function disconnect() {
try {
await fetch('/api/disconnect', { method: 'POST' });
} catch (e) {}
isConnected = false;
selectedFile = null;
currentPath = '';
// Reset UI
document.getElementById('connect-btn').classList.remove('hidden');
document.getElementById('disconnect-btn').classList.add('hidden');
document.getElementById('file-browser').innerHTML = `
<div class="p-8 text-center text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z"/>
</svg>
<p>Connect to browse remote files</p>
</div>
`;
document.getElementById('breadcrumb').innerHTML = '<span class="text-gray-400">Not connected</span>';
document.getElementById('selected-file-info').innerHTML = '<p class="text-gray-500 text-sm">No file selected</p>';
document.getElementById('restore-btn').disabled = true;
hideStatus();
}
async function browse(path) {
if (!isConnected) return;
try {
const response = await fetch('/api/browse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path })
});
const data = await response.json();
if (data.success) {
currentPath = data.path;
renderFiles(data.files, data.path);
updateBreadcrumb(data.path);
} else {
showStatus('Browse failed: ' + data.error, 'error');
}
} catch (error) {
showStatus('Browse error: ' + error.message, 'error');
}
}
function renderFiles(files, path) {
const container = document.getElementById('file-browser');
if (files.length === 0) {
container.innerHTML = '<div class="p-8 text-center text-gray-400">Empty directory</div>';
return;
}
let html = '<div class="divide-y">';
// Parent directory link
if (path !== '/') {
const parentPath = path.split('/').slice(0, -1).join('/') || '/';
html += `
<div class="file-item p-3 cursor-pointer flex items-center" onclick="browse('${parentPath}')">
<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12"/>
</svg>
<span class="text-gray-600">..</span>
</div>
`;
}
files.forEach(file => {
const isSelected = selectedFile && selectedFile.path === file.path;
const selectedClass = isSelected ? 'selected' : '';
if (file.is_dir) {
html += `
<div class="file-item p-3 cursor-pointer flex items-center ${selectedClass}" onclick="browse('${file.path}')">
<svg class="w-5 h-5 mr-3 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z"/>
</svg>
<span class="flex-1 font-medium">${file.name}</span>
</div>
`;
} else if (file.is_sql) {
html += `
<div class="file-item p-3 cursor-pointer flex items-center ${selectedClass}" onclick="selectFile(${JSON.stringify(file).replace(/"/g, '&quot;')})">
<svg class="w-5 h-5 mr-3 text-green-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/>
<path d="M14 2v6h6"/>
</svg>
<span class="flex-1">${file.name}</span>
<span class="text-sm text-gray-500 mr-4">${file.size_human}</span>
<span class="text-sm text-gray-400">${file.mtime_human}</span>
</div>
`;
} else {
html += `
<div class="file-item p-3 flex items-center opacity-50">
<svg class="w-5 h-5 mr-3 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/>
</svg>
<span class="flex-1 text-gray-500">${file.name}</span>
<span class="text-sm text-gray-400">${file.size_human}</span>
</div>
`;
}
});
html += '</div>';
container.innerHTML = html;
}
function selectFile(file) {
selectedFile = file;
document.getElementById('selected-file-info').innerHTML = `
<p class="font-medium text-gray-800">${file.name}</p>
<p class="text-sm text-gray-500">${file.size_human} - ${file.mtime_human}</p>
`;
document.getElementById('restore-btn').disabled = false;
// Auto-detect target database from filename
const filename = file.name.toLowerCase();
const targetDbSelect = document.getElementById('target-db');
const availableDbs = Array.from(targetDbSelect.options).map(o => o.value);
for (const db of availableDbs) {
if (filename.includes(db.toLowerCase())) {
targetDbSelect.value = db;
break;
}
}
// Re-render to show selection
browse(currentPath);
}
function updateBreadcrumb(path) {
const parts = path.split('/').filter(p => p);
let html = `<span class="cursor-pointer hover:text-blue-600" onclick="browse('/')">/</span>`;
let currentPathBuild = '';
parts.forEach((part, index) => {
currentPathBuild += '/' + part;
const isLast = index === parts.length - 1;
html += `
<span class="mx-1">/</span>
<span class="${isLast ? 'font-medium' : 'cursor-pointer hover:text-blue-600'}"
${isLast ? '' : `onclick="browse('${currentPathBuild}')"`}>${part}</span>
`;
});
document.getElementById('breadcrumb').innerHTML = html;
}
async function startRestore() {
if (!selectedFile) return;
const targetDb = document.getElementById('target-db').value;
if (!confirm(`Are you sure you want to restore ${selectedFile.name} to database "${targetDb}"?\n\nThis will DROP ALL TABLES in the database!`)) {
return;
}
const btn = document.getElementById('restore-btn');
btn.disabled = true;
btn.textContent = 'Starting...';
try {
const response = await fetch('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file: selectedFile.path,
database: targetDb
})
});
const data = await response.json();
if (data.success) {
currentJobId = data.job_id;
showProgressPanel();
startPolling();
} else {
showStatus('Restore failed: ' + data.error, 'error');
btn.disabled = false;
btn.textContent = 'Start Restore';
}
} catch (error) {
showStatus('Restore error: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = 'Start Restore';
}
}
function showProgressPanel() {
document.getElementById('progress-panel').classList.remove('hidden');
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('progress-bar').classList.remove('bg-green-600', 'bg-red-600', 'bg-yellow-600');
document.getElementById('progress-bar').classList.add('bg-blue-600');
document.getElementById('progress-percent').textContent = '0%';
document.getElementById('progress-status').textContent = 'Starting...';
document.getElementById('progress-message').textContent = 'Initializing...';
document.getElementById('cancel-btn').classList.remove('hidden');
document.getElementById('cancel-btn').disabled = false;
document.getElementById('cancel-btn').textContent = 'Cancel Restore';
}
function startPolling() {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(async () => {
try {
const response = await fetch(`/api/status/${currentJobId}`);
const data = await response.json();
if (data.success) {
updateProgress(data);
if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') {
stopPolling();
document.getElementById('restore-btn').disabled = false;
document.getElementById('restore-btn').textContent = 'Start Restore';
if (data.status === 'completed') {
showStatus('Restore completed successfully!', 'success');
} else if (data.status === 'cancelled') {
showStatus('Restore was cancelled', 'error');
} else {
showStatus('Restore failed: ' + data.error, 'error');
}
}
}
} catch (error) {
console.error('Polling error:', error);
}
}, 250);
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
async function cancelRestore() {
if (!currentJobId) return;
if (!confirm('Are you sure you want to cancel the restore?')) {
return;
}
const btn = document.getElementById('cancel-btn');
btn.disabled = true;
btn.textContent = 'Cancelling...';
try {
const response = await fetch(`/api/cancel/${currentJobId}`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showStatus('Cancellation requested...', 'error');
} else {
showStatus('Cancel failed: ' + data.error, 'error');
btn.disabled = false;
btn.textContent = 'Cancel Restore';
}
} catch (error) {
showStatus('Cancel error: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = 'Cancel Restore';
}
}
function updateProgress(data) {
const bar = document.getElementById('progress-bar');
const percent = document.getElementById('progress-percent');
const status = document.getElementById('progress-status');
const message = document.getElementById('progress-message');
const elapsed = document.getElementById('progress-elapsed');
const cancelBtn = document.getElementById('cancel-btn');
bar.style.width = data.progress + '%';
percent.textContent = data.progress + '%';
// Update status label
const statusLabels = {
'starting': 'Starting',
'downloading': 'Downloading',
'restoring': 'Restoring',
'completed': 'Completed',
'error': 'Error',
'cancelled': 'Cancelled'
};
status.textContent = statusLabels[data.status] || data.status;
// Update color based on status
bar.classList.remove('bg-green-600', 'bg-red-600', 'bg-yellow-600');
if (data.status === 'completed') {
bar.classList.remove('bg-blue-600');
bar.classList.add('bg-green-600');
} else if (data.status === 'error') {
bar.classList.remove('bg-blue-600');
bar.classList.add('bg-red-600');
} else if (data.status === 'cancelled') {
bar.classList.remove('bg-blue-600');
bar.classList.add('bg-yellow-600');
}
// Show/hide cancel button based on job status
if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') {
cancelBtn.classList.add('hidden');
} else {
cancelBtn.classList.remove('hidden');
cancelBtn.disabled = false;
cancelBtn.textContent = 'Cancel Restore';
}
message.textContent = data.message || '';
if (data.elapsed) {
elapsed.textContent = 'Elapsed: ' + data.elapsed;
}
}
function showStatus(message, type) {
const status = document.getElementById('connection-status');
status.classList.remove('hidden', 'bg-green-100', 'bg-red-100', 'text-green-800', 'text-red-800');
if (type === 'success') {
status.classList.add('bg-green-100', 'text-green-800');
} else if (type === 'error') {
status.classList.add('bg-red-100', 'text-red-800');
}
status.textContent = message;
}
function hideStatus() {
document.getElementById('connection-status').classList.add('hidden');
}

View File

@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DB Restore Tool</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.file-item:hover { background-color: #f3f4f6; }
.file-item.selected { background-color: #dbeafe; }
.spinner { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<div class="container mx-auto px-4 py-8 max-w-6xl">
<!-- Header -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-gray-800">Database Restore Tool</h1>
<p class="text-gray-600">Browse and restore database backups from remote server</p>
</header>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Connection Panel -->
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow p-6" id="connection-panel">
<h2 class="text-xl font-semibold mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z"/>
</svg>
Connection
</h2>
<div id="connection-status" class="mb-4 p-3 rounded hidden"></div>
<form id="connect-form">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Server</label>
<input type="text" value="{{ scp_host }}" disabled
class="w-full px-3 py-2 border rounded bg-gray-50 text-gray-600">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input type="text" value="{{ scp_username }}" disabled
class="w-full px-3 py-2 border rounded bg-gray-50 text-gray-600">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Authentication</label>
<select id="auth-type" class="w-full px-3 py-2 border rounded">
<option value="password">Password</option>
<option value="key">SSH Key</option>
</select>
</div>
<!-- Password Auth -->
<div id="password-auth">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" id="password" placeholder="Enter password"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- Key Auth -->
<div id="key-auth" class="hidden">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">SSH Key</label>
<select id="key-file" class="w-full px-3 py-2 border rounded">
<option value="">Select a key...</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Key Passphrase (optional)</label>
<input type="password" id="key-passphrase" placeholder="Enter passphrase if required"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500">
</div>
</div>
<button type="submit" id="connect-btn"
class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition">
Connect
</button>
<button type="button" id="disconnect-btn"
class="w-full bg-red-600 text-white py-2 px-4 rounded hover:bg-red-700 transition hidden mt-2">
Disconnect
</button>
</form>
</div>
<!-- Restore Panel -->
<div class="bg-white rounded-lg shadow p-6 mt-6" id="restore-panel">
<h2 class="text-xl font-semibold mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
</svg>
Restore
</h2>
<div id="selected-file-info" class="mb-4 p-3 bg-gray-50 rounded">
<p class="text-gray-500 text-sm">No file selected</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Target Database</label>
<select id="target-db" class="w-full px-3 py-2 border rounded">
{% for db in databases %}
<option value="{{ db }}">{{ db }}</option>
{% endfor %}
</select>
</div>
<div class="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<p class="text-yellow-800 text-sm">
<strong>Warning:</strong> This will DROP all tables in the selected database before restoring!
</p>
</div>
<button type="button" id="restore-btn" disabled
class="w-full bg-green-600 text-white py-2 px-4 rounded hover:bg-green-700 transition disabled:bg-gray-400 disabled:cursor-not-allowed">
Start Restore
</button>
</div>
</div>
<!-- File Browser Panel -->
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
Remote Browser
</h2>
<!-- Breadcrumb -->
<div id="breadcrumb" class="mb-4 flex items-center text-sm text-gray-600 overflow-x-auto">
<span class="text-gray-400">Not connected</span>
</div>
<!-- File List -->
<div id="file-browser" class="border rounded min-h-[400px] max-h-[600px] overflow-y-auto">
<div class="p-8 text-center text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z"/>
</svg>
<p>Connect to browse remote files</p>
</div>
</div>
</div>
<!-- Progress Panel -->
<div id="progress-panel" class="bg-white rounded-lg shadow p-6 mt-6 hidden">
<h2 class="text-xl font-semibold mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 spinner" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Restore Progress
</h2>
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span id="progress-status">Initializing...</span>
<span id="progress-percent">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4">
<div id="progress-bar" class="bg-blue-600 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<div id="progress-details" class="text-sm text-gray-600">
<p id="progress-message">Starting...</p>
<p id="progress-elapsed" class="mt-1"></p>
</div>
<button type="button" id="cancel-btn"
class="mt-4 w-full bg-red-600 text-white py-2 px-4 rounded hover:bg-red-700 transition hidden">
Cancel Restore
</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>