feat: backup management page + max backups config + auto-cleanup
- Config: max_backups_per_file (default 10) in config.json + UI field - Auto-cleanup: _backup_file deletes oldest backups when limit exceeded - New endpoints: GET /api/backups (list all), POST /api/backups/delete, POST /api/backups/purge (by file or vault) - New module: backup-manager.js - full-page view with filter, grouped by vault/file, individual delete, per-file purge, stats - Link from backup viewer footer: 'Gerer tous les backups'
This commit is contained in:
+138
@@ -735,6 +735,17 @@ def _backup_file(file_path: Path, vault_name: str, relative_path: str):
|
|||||||
backup_path = backup_dir / backup_name
|
backup_path = backup_dir / backup_name
|
||||||
shutil.copy2(file_path, backup_path)
|
shutil.copy2(file_path, backup_path)
|
||||||
logger.info(f"Backup created: {relative_path} -> {backup_path}")
|
logger.info(f"Backup created: {relative_path} -> {backup_path}")
|
||||||
|
|
||||||
|
# Auto-cleanup: keep only the N most recent backups
|
||||||
|
config = _load_config()
|
||||||
|
max_backups = config.get("max_backups_per_file", 10)
|
||||||
|
all_backups = sorted(
|
||||||
|
[f for f in backup_dir.iterdir() if f.is_file() and f.name.startswith(file_path.name + ".") and f.name.endswith(".bak")],
|
||||||
|
key=lambda f: f.stat().st_mtime, reverse=True
|
||||||
|
)
|
||||||
|
for old in all_backups[max_backups:]:
|
||||||
|
old.unlink()
|
||||||
|
logger.debug(f"Auto-cleanup: removed old backup {old.name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to backup {relative_path} (vault={vault_name}): {e}", exc_info=True)
|
logger.warning(f"Failed to backup {relative_path} (vault={vault_name}): {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -3258,6 +3269,132 @@ async def api_get_all_vault_settings(current_user=Depends(require_auth)):
|
|||||||
return all_settings
|
return all_settings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backup Management API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/api/backups")
|
||||||
|
async def api_backups_list(
|
||||||
|
vault: Optional[str] = Query(None, description="Filter by vault name"),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""List all backups across vaults, grouped by file."""
|
||||||
|
result = []
|
||||||
|
for vault_name in index.keys():
|
||||||
|
if vault and vault_name != vault:
|
||||||
|
continue
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
continue
|
||||||
|
vd = get_vault_data(vault_name)
|
||||||
|
if not vd:
|
||||||
|
continue
|
||||||
|
vault_root = Path(vd["path"])
|
||||||
|
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||||
|
if not backup_root.is_absolute():
|
||||||
|
backup_root = vault_root / backup_root
|
||||||
|
vault_backup_dir = backup_root / vault_name
|
||||||
|
if not vault_backup_dir.exists():
|
||||||
|
continue
|
||||||
|
for fpath in vault_backup_dir.rglob("*.bak"):
|
||||||
|
if not fpath.is_file():
|
||||||
|
continue
|
||||||
|
stat = fpath.st_size
|
||||||
|
ts_part = fpath.name.rsplit(".", 2)
|
||||||
|
if len(ts_part) < 3 or not ts_part[-2].isdigit():
|
||||||
|
continue
|
||||||
|
ts = int(ts_part[-2])
|
||||||
|
rel_dir = str(fpath.parent.relative_to(vault_backup_dir)).replace("\\", "/")
|
||||||
|
rel_file = rel_dir + "/" + ts_part[0] if rel_dir != "." else ts_part[0]
|
||||||
|
result.append({
|
||||||
|
"vault": vault_name,
|
||||||
|
"file": rel_file,
|
||||||
|
"backup_file": fpath.name,
|
||||||
|
"timestamp": ts,
|
||||||
|
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
|
||||||
|
"size": stat,
|
||||||
|
"full_path": str(fpath),
|
||||||
|
})
|
||||||
|
|
||||||
|
result.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||||
|
total_size = sum(r["size"] for r in result)
|
||||||
|
return {"backups": result, "total": len(result), "total_size_bytes": total_size}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/backups/delete")
|
||||||
|
async def api_backups_delete(
|
||||||
|
body: dict = Body(...),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Delete one or more backup files."""
|
||||||
|
paths = body.get("paths", [])
|
||||||
|
if not paths:
|
||||||
|
raise HTTPException(status_code=400, detail="No backup paths provided")
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for p in paths:
|
||||||
|
try:
|
||||||
|
fpath = Path(p)
|
||||||
|
# Security: ensure path is within a backup directory
|
||||||
|
if ".obsigate-backup" not in str(fpath):
|
||||||
|
continue
|
||||||
|
if fpath.exists() and fpath.is_file():
|
||||||
|
fpath.unlink()
|
||||||
|
deleted += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete backup {p}: {e}")
|
||||||
|
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/backups/purge")
|
||||||
|
async def api_backups_purge(
|
||||||
|
body: dict = Body(...),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Purge all backups for a specific file or entire vault."""
|
||||||
|
vault_name = body.get("vault")
|
||||||
|
file_path = body.get("file") # optional
|
||||||
|
|
||||||
|
if not vault_name:
|
||||||
|
raise HTTPException(status_code=400, detail="Vault name required")
|
||||||
|
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
vd = get_vault_data(vault_name)
|
||||||
|
if not vd:
|
||||||
|
raise HTTPException(status_code=404, detail="Vault not found")
|
||||||
|
|
||||||
|
vault_root = Path(vd["path"])
|
||||||
|
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||||
|
if not backup_root.is_absolute():
|
||||||
|
backup_root = vault_root / backup_root
|
||||||
|
|
||||||
|
if file_path:
|
||||||
|
# Delete backups for specific file
|
||||||
|
backup_dir = backup_root / vault_name / Path(file_path).parent
|
||||||
|
if backup_dir.exists():
|
||||||
|
fname = Path(file_path).name
|
||||||
|
deleted = 0
|
||||||
|
for f in backup_dir.iterdir():
|
||||||
|
if f.is_file() and f.name.startswith(fname + ".") and f.name.endswith(".bak"):
|
||||||
|
f.unlink()
|
||||||
|
deleted += 1
|
||||||
|
return {"deleted": deleted}
|
||||||
|
return {"deleted": 0}
|
||||||
|
else:
|
||||||
|
# Delete all backups for vault
|
||||||
|
vault_backup_dir = backup_root / vault_name
|
||||||
|
if vault_backup_dir.exists():
|
||||||
|
deleted = 0
|
||||||
|
for f in vault_backup_dir.rglob("*.bak"):
|
||||||
|
if f.is_file():
|
||||||
|
f.unlink()
|
||||||
|
deleted += 1
|
||||||
|
return {"deleted": deleted}
|
||||||
|
return {"deleted": 0}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration API
|
# Configuration API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -3283,6 +3420,7 @@ _DEFAULT_CONFIG = {
|
|||||||
"tag_boost": 2.0,
|
"tag_boost": 2.0,
|
||||||
"prefix_max_expansions": 50,
|
"prefix_max_expansions": 50,
|
||||||
"recent_files_limit": 20,
|
"recent_files_limit": 20,
|
||||||
|
"max_backups_per_file": 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -1284,10 +1284,29 @@
|
|||||||
value="20"
|
value="20"
|
||||||
/>
|
/>
|
||||||
<span class="config-hint"
|
<span class="config-hint"
|
||||||
>Entre 5 et 100 fichiers (sauvegardé sur le
|
>Entre 5 et 100 fichiers (sauvegarde sur le
|
||||||
serveur)</span
|
serveur)</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<label
|
||||||
|
class="config-label"
|
||||||
|
for="cfg-max-backups"
|
||||||
|
>Max backups par fichier</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="cfg-max-backups"
|
||||||
|
class="config-input config-input--num"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
step="1"
|
||||||
|
value="10"
|
||||||
|
/>
|
||||||
|
<span class="config-hint"
|
||||||
|
>Nombre maximum de versions backup conservees par fichier (1-50)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Performance Settings - Backend -->
|
<!-- Performance Settings - Backend -->
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Backup Manager module for ObsiGate.
|
||||||
|
*
|
||||||
|
* Provides a full-page view to browse, inspect, and delete all backups
|
||||||
|
* across all vaults. Accessible from the backup/diff viewer.
|
||||||
|
*/
|
||||||
|
import { api } from './auth.js';
|
||||||
|
import { showToast } from './ui.js';
|
||||||
|
import { safeCreateIcons } from './utils.js';
|
||||||
|
|
||||||
|
var state = { backups: [], filter: '' };
|
||||||
|
|
||||||
|
export function openBackupManager() {
|
||||||
|
var area = document.getElementById('content-area');
|
||||||
|
if (!area) return;
|
||||||
|
area.innerHTML = '';
|
||||||
|
|
||||||
|
var container = document.createElement('div');
|
||||||
|
container.className = 'backup-manager';
|
||||||
|
container.id = 'backup-manager';
|
||||||
|
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="backup-manager-header">' +
|
||||||
|
'<h2><i data-lucide="archive" style="width:20px;height:20px"></i> Gestion des backups</h2>' +
|
||||||
|
'<div class="backup-manager-actions">' +
|
||||||
|
'<input type="text" id="bm-filter" class="backup-manager-filter" placeholder="Filtrer par fichier...">' +
|
||||||
|
'<button class="backup-manager-btn" id="bm-refresh" title="Rafraichir"><i data-lucide="refresh-cw" style="width:14px;height:14px"></i></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="backup-manager-stats" id="bm-stats"></div>' +
|
||||||
|
'<div class="backup-manager-list" id="bm-list">' +
|
||||||
|
'<div class="tree-loading" style="padding:2rem;text-align:center">' +
|
||||||
|
'<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
area.appendChild(container);
|
||||||
|
safeCreateIcons();
|
||||||
|
|
||||||
|
document.getElementById('bm-filter').addEventListener('input', function() {
|
||||||
|
state.filter = this.value.toLowerCase();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.getElementById('bm-refresh').addEventListener('click', load);
|
||||||
|
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
var list = document.getElementById('bm-list');
|
||||||
|
list.innerHTML = '<div class="tree-loading" style="padding:2rem;text-align:center"><div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var data = await api('/api/backups');
|
||||||
|
state.backups = data.backups || [];
|
||||||
|
updateStats(data);
|
||||||
|
render();
|
||||||
|
} catch (err) {
|
||||||
|
list.innerHTML = '<div class="backup-manager-error">Erreur: ' + escapeHtml(err.message || 'Inconnue') + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(data) {
|
||||||
|
var stats = document.getElementById('bm-stats');
|
||||||
|
if (!stats) return;
|
||||||
|
var totalSize = formatSize(data.total_size_bytes || 0);
|
||||||
|
stats.innerHTML =
|
||||||
|
'<span class="bm-stat"><strong>' + data.total + '</strong> backups</span>' +
|
||||||
|
'<span class="bm-stat"><strong>' + totalSize + '</strong> total</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var list = document.getElementById('bm-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
var filtered = state.backups;
|
||||||
|
if (state.filter) {
|
||||||
|
filtered = state.backups.filter(function(b) {
|
||||||
|
return b.file.toLowerCase().indexOf(state.filter) !== -1 || b.vault.toLowerCase().indexOf(state.filter) !== -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
list.innerHTML = '<div class="backup-manager-empty">Aucun backup trouve.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by vault
|
||||||
|
var grouped = {};
|
||||||
|
filtered.forEach(function(b) {
|
||||||
|
if (!grouped[b.vault]) grouped[b.vault] = [];
|
||||||
|
grouped[b.vault].push(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
var vaultNames = Object.keys(grouped).sort();
|
||||||
|
vaultNames.forEach(function(vault) {
|
||||||
|
html += '<div class="bm-vault-group">';
|
||||||
|
html += '<h3 class="bm-vault-name"><i data-lucide="database" style="width:14px;height:14px"></i> ' + escapeHtml(vault) + ' (' + grouped[vault].length + ')</h3>';
|
||||||
|
|
||||||
|
// Group by file
|
||||||
|
var byFile = {};
|
||||||
|
grouped[vault].forEach(function(b) {
|
||||||
|
if (!byFile[b.file]) byFile[b.file] = [];
|
||||||
|
byFile[b.file].push(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
var fileNames = Object.keys(byFile).sort();
|
||||||
|
fileNames.forEach(function(file) {
|
||||||
|
var backups = byFile[file];
|
||||||
|
backups.sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||||
|
var totalFileSize = backups.reduce(function(s, b) { return s + b.size; }, 0);
|
||||||
|
|
||||||
|
html += '<div class="bm-file-group">';
|
||||||
|
html += '<div class="bm-file-header">';
|
||||||
|
html += '<span class="bm-file-name"><i data-lucide="file-text" style="width:14px;height:14px"></i> ' + escapeHtml(file) + '</span>';
|
||||||
|
html += '<span class="bm-file-meta">' + backups.length + ' versions · ' + formatSize(totalFileSize) + '</span>';
|
||||||
|
html += '<button class="bm-file-purge" data-vault="' + escapeHtml(vault) + '" data-file="' + escapeHtml(file) + '" title="Supprimer tous les backups de ce fichier"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
html += '<div class="bm-backup-list">';
|
||||||
|
backups.forEach(function(b) {
|
||||||
|
var d = new Date(b.timestamp * 1000);
|
||||||
|
var ds = d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
html += '<div class="bm-backup-item">';
|
||||||
|
html += '<span class="bm-backup-date">' + ds + '</span>';
|
||||||
|
html += '<span class="bm-backup-size">' + formatSize(b.size) + '</span>';
|
||||||
|
html += '<button class="bm-backup-delete" data-path="' + escapeHtml(b.full_path) + '" title="Supprimer ce backup"><i data-lucide="x" style="width:12px;height:12px"></i></button>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
list.innerHTML = html;
|
||||||
|
safeCreateIcons();
|
||||||
|
|
||||||
|
// Bind delete buttons
|
||||||
|
list.querySelectorAll('.bm-backup-delete').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
if (confirm('Supprimer ce backup ?')) {
|
||||||
|
deleteBackups([btn.dataset.path]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind purge buttons
|
||||||
|
list.querySelectorAll('.bm-file-purge').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
if (confirm('Supprimer TOUS les backups de "' + btn.dataset.file + '" ?')) {
|
||||||
|
purgeFile(btn.dataset.vault, btn.dataset.file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBackups(paths) {
|
||||||
|
try {
|
||||||
|
var resp = await api('/api/backups/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paths: paths }),
|
||||||
|
});
|
||||||
|
showToast(resp.deleted + ' backup(s) supprime(s)', 'success');
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Erreur: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeFile(vault, file) {
|
||||||
|
try {
|
||||||
|
var resp = await api('/api/backups/purge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ vault: vault, file: file }),
|
||||||
|
});
|
||||||
|
showToast(resp.deleted + ' backup(s) supprime(s)', 'success');
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Erreur: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' o';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' Ko';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n) { return String(n).padStart(2, '0'); }
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
@@ -213,6 +213,7 @@ function renderMainView() {
|
|||||||
|
|
||||||
'<div class="backup-footer">' +
|
'<div class="backup-footer">' +
|
||||||
'<span class="backup-footer-hint">Backups crees lors de la sauvegarde manuelle (Ctrl+S)</span>' +
|
'<span class="backup-footer-hint">Backups crees lors de la sauvegarde manuelle (Ctrl+S)</span>' +
|
||||||
|
'<button class="backup-footer-manage-btn" id="backup-manage-btn">Gerer tous les backups</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
setBody(html);
|
setBody(html);
|
||||||
@@ -249,6 +250,14 @@ function bindEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var modeBtn = document.getElementById('backup-diff-mode-btn');
|
var modeBtn = document.getElementById('backup-diff-mode-btn');
|
||||||
|
var manageBtn = document.getElementById('backup-manage-btn');
|
||||||
|
if (manageBtn) {
|
||||||
|
manageBtn.addEventListener('click', function() {
|
||||||
|
close();
|
||||||
|
import('./backup-manager.js').then(function(m) { m.openBackupManager(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (modeBtn) {
|
if (modeBtn) {
|
||||||
modeBtn.addEventListener('click', function() {
|
modeBtn.addEventListener('click', function() {
|
||||||
state.diffMode = state.diffMode === 'sidebyside' ? 'unified' : 'sidebyside';
|
state.diffMode = state.diffMode === 'sidebyside' ? 'unified' : 'sidebyside';
|
||||||
|
|||||||
@@ -665,6 +665,7 @@ async function loadConfigFields() {
|
|||||||
_setField("cfg-tag-boost", data.tag_boost);
|
_setField("cfg-tag-boost", data.tag_boost);
|
||||||
_setField("cfg-prefix-exp", data.prefix_max_expansions);
|
_setField("cfg-prefix-exp", data.prefix_max_expansions);
|
||||||
_setField("cfg-recent-limit", data.recent_files_limit || 20);
|
_setField("cfg-recent-limit", data.recent_files_limit || 20);
|
||||||
|
_setField("cfg-max-backups", data.max_backups_per_file || 10);
|
||||||
// Watcher config
|
// Watcher config
|
||||||
_setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false);
|
_setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false);
|
||||||
_setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true);
|
_setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true);
|
||||||
@@ -716,6 +717,7 @@ async function saveBackendConfig() {
|
|||||||
tag_boost: _getFieldNum("cfg-tag-boost", 2.0),
|
tag_boost: _getFieldNum("cfg-tag-boost", 2.0),
|
||||||
prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50),
|
prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50),
|
||||||
recent_files_limit: _getFieldNum("cfg-recent-limit", 20),
|
recent_files_limit: _getFieldNum("cfg-recent-limit", 20),
|
||||||
|
max_backups_per_file: _getFieldNum("cfg-max-backups", 10),
|
||||||
watcher_enabled: _getCheckbox("cfg-watcher-enabled"),
|
watcher_enabled: _getCheckbox("cfg-watcher-enabled"),
|
||||||
watcher_use_polling: _getCheckbox("cfg-watcher-polling"),
|
watcher_use_polling: _getCheckbox("cfg-watcher-polling"),
|
||||||
watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0),
|
watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0),
|
||||||
|
|||||||
@@ -7499,3 +7499,119 @@ body.popup-mode .content-area {
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Backup Manager ── */
|
||||||
|
.backup-manager {
|
||||||
|
padding: 16px 24px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-manager-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-manager-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-manager-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-manager-filter {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-manager-btn {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-manager-btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.backup-manager-stats { display: flex; gap: 16px; margin-bottom: 14px; }
|
||||||
|
.bm-stat { font-size: 0.8rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.bm-vault-group { margin-bottom: 16px; }
|
||||||
|
.bm-vault-name {
|
||||||
|
font-size: 0.85rem; font-weight: 600; margin: 0 0 6px 0;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bm-file-group {
|
||||||
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
margin-bottom: 8px; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bm-file-header {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 8px 12px; background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bm-file-name { font-size: 0.8rem; font-weight: 500; display: flex; align-items: center; gap: 4px; }
|
||||||
|
.bm-file-meta { font-size: 0.7rem; color: var(--text-muted); flex: 1; }
|
||||||
|
|
||||||
|
.bm-file-purge {
|
||||||
|
width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: transparent; color: var(--text-muted); cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.bm-file-purge:hover { color: var(--danger); border-color: var(--danger); }
|
||||||
|
|
||||||
|
.bm-backup-list { padding: 2px 0; }
|
||||||
|
|
||||||
|
.bm-backup-item {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 4px 12px; font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.bm-backup-item:hover { background: var(--bg-secondary); }
|
||||||
|
|
||||||
|
.bm-backup-date { color: var(--text-secondary); min-width: 130px; font-family: var(--font-mono); }
|
||||||
|
.bm-backup-size { color: var(--text-muted); flex: 1; }
|
||||||
|
|
||||||
|
.bm-backup-delete {
|
||||||
|
width: 22px; height: 22px; border: none; border-radius: 4px;
|
||||||
|
background: transparent; color: var(--text-muted); cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.bm-backup-item:hover .bm-backup-delete { opacity: 1; }
|
||||||
|
.bm-backup-delete:hover { color: var(--danger); background: rgba(239,68,68,0.1); }
|
||||||
|
|
||||||
|
.backup-footer-manage-btn {
|
||||||
|
background: none; border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; color: var(--accent);
|
||||||
|
font-size: 0.7rem; cursor: pointer; padding: 3px 10px;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
.backup-footer-manage-btn:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||||
|
|
||||||
|
.backup-manager-empty,
|
||||||
|
.backup-manager-error {
|
||||||
|
text-align: center; padding: 2rem; color: var(--text-muted); font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.backup-manager-error { color: var(--danger); }
|
||||||
|
|||||||
Reference in New Issue
Block a user