Add Backup Manager dashboard tool card with list, download, restore & delete
This commit is contained in:
parent
b1cb9f9964
commit
266d044797
4 changed files with 400 additions and 33 deletions
116
web_server.py
116
web_server.py
|
|
@ -15255,15 +15255,17 @@ def stop_database_update():
|
||||||
else:
|
else:
|
||||||
return jsonify({"success": False, "error": "No update is currently running."}), 404
|
return jsonify({"success": False, "error": "No update is currently running."}), 404
|
||||||
|
|
||||||
|
_BACKUP_FILENAME_RE = re.compile(r'^music_library\.db\.backup_\d{8}_\d{6}$')
|
||||||
|
|
||||||
@app.route('/api/database/backup', methods=['POST'])
|
@app.route('/api/database/backup', methods=['POST'])
|
||||||
def backup_database_endpoint():
|
def backup_database_endpoint():
|
||||||
"""Create a rolling backup of the database (max 3)."""
|
"""Create a rolling backup of the database (max 5)."""
|
||||||
try:
|
try:
|
||||||
import sqlite3, glob as _glob
|
import sqlite3, glob as _glob
|
||||||
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
|
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
|
||||||
if not os.path.exists(db_path):
|
if not os.path.exists(db_path):
|
||||||
return jsonify({"success": False, "error": "Database file not found"}), 404
|
return jsonify({"success": False, "error": "Database file not found"}), 404
|
||||||
max_backups = 3
|
max_backups = 5
|
||||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
backup_path = f"{db_path}.backup_{timestamp}"
|
backup_path = f"{db_path}.backup_{timestamp}"
|
||||||
src = sqlite3.connect(db_path)
|
src = sqlite3.connect(db_path)
|
||||||
|
|
@ -15283,6 +15285,116 @@ def backup_database_endpoint():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/database/backups', methods=['GET'])
|
||||||
|
def list_backups_endpoint():
|
||||||
|
"""List all database backups with metadata."""
|
||||||
|
try:
|
||||||
|
import glob as _glob
|
||||||
|
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
|
||||||
|
backup_files = sorted(
|
||||||
|
_glob.glob(f"{db_path}.backup_*"),
|
||||||
|
key=os.path.getmtime,
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
backups = []
|
||||||
|
for fp in backup_files:
|
||||||
|
fname = os.path.basename(fp)
|
||||||
|
if not _BACKUP_FILENAME_RE.match(fname):
|
||||||
|
continue
|
||||||
|
stat = os.stat(fp)
|
||||||
|
backups.append({
|
||||||
|
'filename': fname,
|
||||||
|
'size_mb': round(stat.st_size / (1024 * 1024), 2),
|
||||||
|
'created': datetime.utcfromtimestamp(stat.st_mtime).isoformat()
|
||||||
|
})
|
||||||
|
db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2) if os.path.exists(db_path) else 0
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'backups': backups,
|
||||||
|
'count': len(backups),
|
||||||
|
'db_size_mb': db_size_mb
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/database/backups/<filename>', methods=['DELETE'])
|
||||||
|
def delete_backup_endpoint(filename):
|
||||||
|
"""Delete a specific database backup."""
|
||||||
|
try:
|
||||||
|
if not _BACKUP_FILENAME_RE.match(filename) or '/' in filename or '\\' in filename or '..' in filename:
|
||||||
|
return jsonify({"success": False, "error": "Invalid backup filename"}), 400
|
||||||
|
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
|
||||||
|
backup_path = os.path.join(os.path.dirname(db_path), filename)
|
||||||
|
if not os.path.exists(backup_path):
|
||||||
|
return jsonify({"success": False, "error": "Backup not found"}), 404
|
||||||
|
os.remove(backup_path)
|
||||||
|
return jsonify({"success": True, "deleted": filename})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/database/backups/<filename>/restore', methods=['POST'])
|
||||||
|
def restore_backup_endpoint(filename):
|
||||||
|
"""Restore the database from a specific backup."""
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
if not _BACKUP_FILENAME_RE.match(filename) or '/' in filename or '\\' in filename or '..' in filename:
|
||||||
|
return jsonify({"success": False, "error": "Invalid backup filename"}), 400
|
||||||
|
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
|
||||||
|
db_dir = os.path.dirname(db_path)
|
||||||
|
backup_path = os.path.join(db_dir, filename)
|
||||||
|
if not os.path.exists(backup_path):
|
||||||
|
return jsonify({"success": False, "error": "Backup not found"}), 404
|
||||||
|
|
||||||
|
# Create safety backup of current DB before restoring
|
||||||
|
safety_ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
safety_filename = f"music_library.db.backup_{safety_ts}"
|
||||||
|
safety_path = os.path.join(db_dir, safety_filename)
|
||||||
|
src_conn = sqlite3.connect(db_path)
|
||||||
|
dst_conn = sqlite3.connect(safety_path)
|
||||||
|
src_conn.backup(dst_conn)
|
||||||
|
dst_conn.close()
|
||||||
|
src_conn.close()
|
||||||
|
|
||||||
|
# Restore using SQLite backup API (handles concurrent access safely)
|
||||||
|
from database.music_database import close_database, get_database
|
||||||
|
close_database()
|
||||||
|
|
||||||
|
src_restore = sqlite3.connect(backup_path)
|
||||||
|
dst_restore = sqlite3.connect(db_path)
|
||||||
|
src_restore.backup(dst_restore)
|
||||||
|
dst_restore.close()
|
||||||
|
src_restore.close()
|
||||||
|
|
||||||
|
# Reinitialize database and verify
|
||||||
|
db = get_database()
|
||||||
|
with db._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM artists")
|
||||||
|
artist_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"restored_from": filename,
|
||||||
|
"safety_backup": safety_filename,
|
||||||
|
"artist_count": artist_count
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/database/backups/<filename>/download', methods=['GET'])
|
||||||
|
def download_backup_endpoint(filename):
|
||||||
|
"""Download a specific database backup file."""
|
||||||
|
try:
|
||||||
|
if not _BACKUP_FILENAME_RE.match(filename) or '/' in filename or '\\' in filename or '..' in filename:
|
||||||
|
return jsonify({"success": False, "error": "Invalid backup filename"}), 400
|
||||||
|
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
|
||||||
|
backup_path = os.path.join(os.path.dirname(db_path), filename)
|
||||||
|
if not os.path.exists(backup_path):
|
||||||
|
return jsonify({"success": False, "error": "Backup not found"}), 404
|
||||||
|
return send_file(backup_path, as_attachment=True, download_name=filename)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
# ===============================
|
# ===============================
|
||||||
# == QUALITY SCANNER ==
|
# == QUALITY SCANNER ==
|
||||||
# ===============================
|
# ===============================
|
||||||
|
|
|
||||||
|
|
@ -502,12 +502,8 @@
|
||||||
<div class="tool-card" id="db-updater-card">
|
<div class="tool-card" id="db-updater-card">
|
||||||
<div class="tool-card-header">
|
<div class="tool-card-header">
|
||||||
<h4 class="tool-card-title">Database Updater</h4>
|
<h4 class="tool-card-title">Database Updater</h4>
|
||||||
<div style="display:flex;gap:4px;">
|
<button class="tool-help-button" data-tool="db-updater"
|
||||||
<button class="tool-help-button" id="db-backup-button"
|
title="Learn more about this tool">?</button>
|
||||||
title="Create a rolling backup of the database"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></button>
|
|
||||||
<button class="tool-help-button" data-tool="db-updater"
|
|
||||||
title="Learn more about this tool">?</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="tool-card-info">Last Full Refresh: <span id="db-last-refresh">Never</span></p>
|
<p class="tool-card-info">Last Full Refresh: <span id="db-last-refresh">Never</span></p>
|
||||||
<div class="tool-card-stats">
|
<div class="tool-card-stats">
|
||||||
|
|
@ -748,6 +744,37 @@
|
||||||
request</p>
|
request</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-card" id="backup-manager-card">
|
||||||
|
<div class="tool-card-header">
|
||||||
|
<h4 class="tool-card-title">Backup Manager</h4>
|
||||||
|
<button class="tool-help-button" data-tool="backup-manager"
|
||||||
|
title="Learn more about this tool">?</button>
|
||||||
|
</div>
|
||||||
|
<p class="tool-card-info">Create, download, restore and manage database backups</p>
|
||||||
|
<div class="tool-card-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-label">Last Backup:</span>
|
||||||
|
<span class="stat-item-value" id="backup-stat-last">Never</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-label">Backups:</span>
|
||||||
|
<span class="stat-item-value" id="backup-stat-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-label">Latest Size:</span>
|
||||||
|
<span class="stat-item-value" id="backup-stat-latest-size">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-label">DB Size:</span>
|
||||||
|
<span class="stat-item-value" id="backup-stat-db-size">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool-card-controls">
|
||||||
|
<button id="backup-now-button">Backup Now</button>
|
||||||
|
</div>
|
||||||
|
<div id="backup-list-container" class="backup-list-container"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15249,6 +15249,132 @@ function stopDuplicateCleanerPolling() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// == BACKUP MANAGER ==
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async function loadBackupList() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/database/backups');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
updateBackupManagerUI(data);
|
||||||
|
renderBackupList(data.backups);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load backup list:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBackupManagerUI(data) {
|
||||||
|
const lastEl = document.getElementById('backup-stat-last');
|
||||||
|
const countEl = document.getElementById('backup-stat-count');
|
||||||
|
const latestSizeEl = document.getElementById('backup-stat-latest-size');
|
||||||
|
const dbSizeEl = document.getElementById('backup-stat-db-size');
|
||||||
|
|
||||||
|
if (countEl) countEl.textContent = data.count;
|
||||||
|
if (dbSizeEl) dbSizeEl.textContent = data.db_size_mb + ' MB';
|
||||||
|
|
||||||
|
if (data.backups && data.backups.length > 0) {
|
||||||
|
const newest = data.backups[0];
|
||||||
|
if (lastEl) lastEl.textContent = timeAgo(newest.created);
|
||||||
|
if (latestSizeEl) latestSizeEl.textContent = newest.size_mb + ' MB';
|
||||||
|
} else {
|
||||||
|
if (lastEl) lastEl.textContent = 'Never';
|
||||||
|
if (latestSizeEl) latestSizeEl.textContent = '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBackupList(backups) {
|
||||||
|
const container = document.getElementById('backup-list-container');
|
||||||
|
if (!container) return;
|
||||||
|
if (!backups || backups.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = backups.map(b => {
|
||||||
|
const date = new Date(b.created + (b.created.includes('Z') ? '' : 'Z'));
|
||||||
|
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
+ ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const safeName = escapeForInlineJs(b.filename);
|
||||||
|
return `<div class="backup-list-item">
|
||||||
|
<div class="backup-list-info">
|
||||||
|
<span class="backup-list-date">${escapeHtml(dateStr)}</span>
|
||||||
|
<span class="backup-list-size">${b.size_mb} MB</span>
|
||||||
|
</div>
|
||||||
|
<div class="backup-list-actions">
|
||||||
|
<button class="backup-dl-btn" onclick="downloadBackup('${safeName}')" title="Download">DL</button>
|
||||||
|
<button class="backup-restore-btn" onclick="restoreBackup('${safeName}')" title="Restore">Restore</button>
|
||||||
|
<button class="backup-delete-btn" onclick="deleteBackup('${safeName}')" title="Delete">Del</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBackupNowClick() {
|
||||||
|
const button = document.getElementById('backup-now-button');
|
||||||
|
if (!button) return;
|
||||||
|
const origText = button.textContent;
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Backing up...';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/database/backup', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast(`Database backed up (${data.size_mb} MB)`, 'success');
|
||||||
|
await loadBackupList();
|
||||||
|
} else {
|
||||||
|
showToast(`Backup failed: ${data.error}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Backup request failed', 'error');
|
||||||
|
}
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = origText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBackup(filename) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/api/database/backups/${encodeURIComponent(filename)}/download`;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBackup(filename) {
|
||||||
|
if (!confirm(`Restore database from "${filename}"?\n\nA safety backup of the current database will be created first.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}/restore`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast(`Database restored from ${data.restored_from} (${data.artist_count} artists). Safety backup: ${data.safety_backup}`, 'success');
|
||||||
|
await loadBackupList();
|
||||||
|
} else {
|
||||||
|
showToast(`Restore failed: ${data.error}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Restore request failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBackup(filename) {
|
||||||
|
if (!confirm(`Delete backup "${filename}"? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast(`Backup deleted: ${data.deleted}`, 'success');
|
||||||
|
await loadBackupList();
|
||||||
|
} else {
|
||||||
|
showToast(`Delete failed: ${data.error}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Delete request failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// == TOOL HELP MODAL ==
|
// == TOOL HELP MODAL ==
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -16278,6 +16404,35 @@ const TOOL_HELP_CONTENT = {
|
||||||
<h4>Combining with notifications</h4>
|
<h4>Combining with notifications</h4>
|
||||||
<p>You can add up to 3 then-actions per automation. For example: Fire Signal + Discord notification + Telegram notification — all run after the action completes.</p>
|
<p>You can add up to 3 then-actions per automation. For example: Fire Signal + Discord notification + Telegram notification — all run after the action completes.</p>
|
||||||
`
|
`
|
||||||
|
},
|
||||||
|
'backup-manager': {
|
||||||
|
title: 'Backup Manager',
|
||||||
|
content: `
|
||||||
|
<h4>What does this tool do?</h4>
|
||||||
|
<p>The Backup Manager lets you create, view, download, restore, and delete database backups directly from the dashboard.</p>
|
||||||
|
|
||||||
|
<h4>Features</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Backup Now:</strong> Create an instant backup of the current database using SQLite's hot-copy API</li>
|
||||||
|
<li><strong>Download:</strong> Download any backup file to your local machine</li>
|
||||||
|
<li><strong>Restore:</strong> Roll back the database to a previous backup state</li>
|
||||||
|
<li><strong>Delete:</strong> Remove old backups you no longer need</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Auto-Backups</h4>
|
||||||
|
<p>SoulSync automatically creates a backup every 3 days via the automation engine. Up to 5 rolling backups are kept (oldest are removed when the limit is exceeded).</p>
|
||||||
|
|
||||||
|
<h4>Restore Safety</h4>
|
||||||
|
<p>When you restore from a backup, a <strong>safety backup</strong> of your current database is created first. This means you can always undo a restore if something goes wrong.</p>
|
||||||
|
|
||||||
|
<h4>Stats Explained</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Last Backup:</strong> When the most recent backup was created</li>
|
||||||
|
<li><strong>Backups:</strong> Total number of backup files available</li>
|
||||||
|
<li><strong>Latest Size:</strong> Size of the most recent backup</li>
|
||||||
|
<li><strong>DB Size:</strong> Current size of the live database</li>
|
||||||
|
</ul>
|
||||||
|
`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -16931,11 +17086,6 @@ async function loadDashboardData() {
|
||||||
updateButton.addEventListener('click', handleDbUpdateButtonClick);
|
updateButton.addEventListener('click', handleDbUpdateButtonClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backupButton = document.getElementById('db-backup-button');
|
|
||||||
if (backupButton) {
|
|
||||||
backupButton.addEventListener('click', handleDbBackupButtonClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach event listeners for the metadata updater tool
|
// Attach event listeners for the metadata updater tool
|
||||||
const metadataButton = document.getElementById('metadata-update-button');
|
const metadataButton = document.getElementById('metadata-update-button');
|
||||||
if (metadataButton) {
|
if (metadataButton) {
|
||||||
|
|
@ -16975,6 +17125,11 @@ async function loadDashboardData() {
|
||||||
// Check active media server and show media scan tool only for Plex
|
// Check active media server and show media scan tool only for Plex
|
||||||
await checkAndShowMediaScanForPlex();
|
await checkAndShowMediaScanForPlex();
|
||||||
|
|
||||||
|
// Attach event listener for the backup manager
|
||||||
|
const backupNowButton = document.getElementById('backup-now-button');
|
||||||
|
if (backupNowButton) backupNowButton.addEventListener('click', handleBackupNowClick);
|
||||||
|
loadBackupList();
|
||||||
|
|
||||||
// Attach event listeners for tool help buttons
|
// Attach event listeners for tool help buttons
|
||||||
initializeToolHelpButtons();
|
initializeToolHelpButtons();
|
||||||
|
|
||||||
|
|
@ -19047,26 +19202,6 @@ async function handleDbUpdateButtonClick() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDbBackupButtonClick() {
|
|
||||||
const button = document.getElementById('db-backup-button');
|
|
||||||
const origText = button.textContent;
|
|
||||||
button.disabled = true;
|
|
||||||
button.textContent = 'Backing up...';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/database/backup', { method: 'POST' });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
showToast(`Database backed up (${data.size_mb} MB)`, 'success');
|
|
||||||
} else {
|
|
||||||
showToast(`Backup failed: ${data.error}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Backup request failed', 'error');
|
|
||||||
}
|
|
||||||
button.disabled = false;
|
|
||||||
button.textContent = origText;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleWishlistButtonClick() {
|
async function handleWishlistButtonClick() {
|
||||||
try {
|
try {
|
||||||
const playlistId = 'wishlist';
|
const playlistId = 'wishlist';
|
||||||
|
|
|
||||||
|
|
@ -5423,6 +5423,99 @@ body {
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Backup Manager List */
|
||||||
|
.backup-list-container {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-container:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-size {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-actions button {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #b3b3b3;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-actions .backup-dl-btn:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.25);
|
||||||
|
color: rgb(var(--accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-actions .backup-restore-btn:hover {
|
||||||
|
background: rgba(255, 183, 77, 0.25);
|
||||||
|
color: #ffb74d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-actions .backup-delete-btn:hover {
|
||||||
|
background: rgba(244, 67, 54, 0.25);
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-container::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-container::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Activity Feed */
|
/* Activity Feed */
|
||||||
.activity-feed-container {
|
.activity-feed-container {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue