From fb87c9f7c332e5ae3ef0cce812cd88859553a5da Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 4 Jun 2026 20:15:31 -0400 Subject: [PATCH] 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' --- backend/main.py | 138 +++++++++++++++++++++++ frontend/index.html | 21 +++- frontend/js/backup-manager.js | 200 ++++++++++++++++++++++++++++++++++ frontend/js/backups.js | 9 ++ frontend/js/config.js | 2 + frontend/style.css | 116 ++++++++++++++++++++ 6 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 frontend/js/backup-manager.js diff --git a/backend/main.py b/backend/main.py index f6b7e4f..43f19b9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -735,6 +735,17 @@ def _backup_file(file_path: Path, vault_name: str, relative_path: str): backup_path = backup_dir / backup_name shutil.copy2(file_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: 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 +# --------------------------------------------------------------------------- +# 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 # --------------------------------------------------------------------------- @@ -3283,6 +3420,7 @@ _DEFAULT_CONFIG = { "tag_boost": 2.0, "prefix_max_expansions": 50, "recent_files_limit": 20, + "max_backups_per_file": 10, } diff --git a/frontend/index.html b/frontend/index.html index 96308a2..8837a8a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1284,10 +1284,29 @@ value="20" /> Entre 5 et 100 fichiers (sauvegardé sur le + >Entre 5 et 100 fichiers (sauvegarde sur le serveur) +
+ + + Nombre maximum de versions backup conservees par fichier (1-50) +
diff --git a/frontend/js/backup-manager.js b/frontend/js/backup-manager.js new file mode 100644 index 0000000..84ecd43 --- /dev/null +++ b/frontend/js/backup-manager.js @@ -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 = + '
' + + '

Gestion des backups

' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + + 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 = '
'; + + try { + var data = await api('/api/backups'); + state.backups = data.backups || []; + updateStats(data); + render(); + } catch (err) { + list.innerHTML = '
Erreur: ' + escapeHtml(err.message || 'Inconnue') + '
'; + } +} + +function updateStats(data) { + var stats = document.getElementById('bm-stats'); + if (!stats) return; + var totalSize = formatSize(data.total_size_bytes || 0); + stats.innerHTML = + '' + data.total + ' backups' + + '' + totalSize + ' total'; +} + +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 = '
Aucun backup trouve.
'; + 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 += '
'; + html += '

' + escapeHtml(vault) + ' (' + grouped[vault].length + ')

'; + + // 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 += '
'; + html += '
'; + html += ' ' + escapeHtml(file) + ''; + html += '' + backups.length + ' versions · ' + formatSize(totalFileSize) + ''; + html += ''; + html += '
'; + + html += '
'; + 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 += '
'; + html += '' + ds + ''; + html += '' + formatSize(b.size) + ''; + html += ''; + html += '
'; + }); + html += '
'; + html += '
'; + }); + + html += '
'; + }); + + 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; +} diff --git a/frontend/js/backups.js b/frontend/js/backups.js index c51b26b..ff21204 100644 --- a/frontend/js/backups.js +++ b/frontend/js/backups.js @@ -213,6 +213,7 @@ function renderMainView() { ''; setBody(html); @@ -249,6 +250,14 @@ function bindEvents() { } 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) { modeBtn.addEventListener('click', function() { state.diffMode = state.diffMode === 'sidebyside' ? 'unified' : 'sidebyside'; diff --git a/frontend/js/config.js b/frontend/js/config.js index ce59ef4..53ff49b 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -665,6 +665,7 @@ async function loadConfigFields() { _setField("cfg-tag-boost", data.tag_boost); _setField("cfg-prefix-exp", data.prefix_max_expansions); _setField("cfg-recent-limit", data.recent_files_limit || 20); + _setField("cfg-max-backups", data.max_backups_per_file || 10); // Watcher config _setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false); _setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true); @@ -716,6 +717,7 @@ async function saveBackendConfig() { tag_boost: _getFieldNum("cfg-tag-boost", 2.0), prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50), recent_files_limit: _getFieldNum("cfg-recent-limit", 20), + max_backups_per_file: _getFieldNum("cfg-max-backups", 10), watcher_enabled: _getCheckbox("cfg-watcher-enabled"), watcher_use_polling: _getCheckbox("cfg-watcher-polling"), watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0), diff --git a/frontend/style.css b/frontend/style.css index 6639be5..35ae68c 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -7499,3 +7499,119 @@ body.popup-mode .content-area { width: 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); }