added new db downloader
This commit is contained in:
25
docker/db-downloader/Dockerfile
Normal file
25
docker/db-downloader/Dockerfile
Normal 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
444
docker/db-downloader/app.py
Normal 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)
|
||||
5
docker/db-downloader/requirements.txt
Normal file
5
docker/db-downloader/requirements.txt
Normal 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
|
||||
457
docker/db-downloader/static/app.js
Normal file
457
docker/db-downloader/static/app.js
Normal 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, '"')})">
|
||||
<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');
|
||||
}
|
||||
189
docker/db-downloader/templates/index.html
Normal file
189
docker/db-downloader/templates/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user