#15: Click backup item to preview content in side panel (100KB limit) #16: Purge all backups per vault (red 'Vider' button) #17: /api/backups/compress — gzip backups older than N days #18: /api/backups/auto — backup files modified since N hours #19: Restore button per backup in manager with timestamp extraction + Professional side-panel preview, action buttons on hover, vault grouping
This commit is contained in:
+117
@@ -3400,6 +3400,123 @@ async def api_backups_purge(
|
||||
return {"deleted": 0}
|
||||
|
||||
|
||||
|
||||
@app.get("/api/backups/content")
|
||||
async def api_backups_content(
|
||||
path: str = Query(..., description="Full path to backup file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return the content of a specific backup file."""
|
||||
try:
|
||||
fpath = Path(path)
|
||||
if ".obsigate-backup" not in str(fpath):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if not fpath.exists() or not fpath.is_file():
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
content = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
# Truncate large files to 100KB
|
||||
if len(content) > 102400:
|
||||
content = content[:102400] + "\n\n... (tronque a 100 Ko)"
|
||||
return {"content": content, "name": fpath.name, "size": len(content)}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/backups/compress")
|
||||
async def api_backups_compress(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Compress backups older than N days. Body: {older_than_days: 30, dry_run: false}"""
|
||||
import gzip as gz_mod
|
||||
older_than = body.get("older_than_days", 30)
|
||||
dry_run = body.get("dry_run", False)
|
||||
cutoff = time.time() - (older_than * 86400)
|
||||
compressed = 0
|
||||
saved_bytes = 0
|
||||
|
||||
for vault_name in index.keys():
|
||||
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_dir = backup_root / vault_name
|
||||
if not vault_dir.exists():
|
||||
continue
|
||||
for fpath in vault_dir.rglob("*.bak"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
if fpath.name.endswith(".bak.gz"):
|
||||
continue
|
||||
mtime = fpath.stat().st_mtime
|
||||
if mtime > cutoff:
|
||||
continue
|
||||
if not dry_run:
|
||||
try:
|
||||
gz_path = fpath.with_suffix(fpath.suffix + ".gz")
|
||||
data = fpath.read_bytes()
|
||||
with gz_mod.open(str(gz_path), "wb", compresslevel=6) as gzf:
|
||||
gzf.write(data)
|
||||
orig_size = len(data)
|
||||
gz_size = gz_path.stat().st_size
|
||||
if gz_size < orig_size:
|
||||
fpath.unlink()
|
||||
saved_bytes += (orig_size - gz_size)
|
||||
else:
|
||||
gz_path.unlink() # compression didn't help
|
||||
compressed += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compress {fpath}: {e}")
|
||||
else:
|
||||
compressed += 1
|
||||
|
||||
return {"compressed": compressed, "saved_bytes": saved_bytes, "dry_run": dry_run}
|
||||
|
||||
|
||||
@app.post("/api/backups/auto")
|
||||
async def api_backups_auto(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create backups for files modified since a given time. Body: {since_hours: 24}"""
|
||||
since_hours = body.get("since_hours", 24)
|
||||
cutoff = time.time() - (since_hours * 3600)
|
||||
backed_up = 0
|
||||
|
||||
for vault_name in index.keys():
|
||||
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"])
|
||||
for fpath in vault_root.rglob("*"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
if fpath.name.startswith('.'):
|
||||
continue
|
||||
if any(p.startswith('.') or p in {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'} for p in fpath.relative_to(vault_root).parts):
|
||||
continue
|
||||
mtime = fpath.stat().st_mtime
|
||||
if mtime < cutoff:
|
||||
continue
|
||||
try:
|
||||
rel = str(fpath.relative_to(vault_root)).replace("\\", "/")
|
||||
_backup_file(fpath, vault_name, rel)
|
||||
backed_up += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto-backup failed for {rel}: {e}")
|
||||
|
||||
return {"backed_up": backed_up, "since_hours": since_hours}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+144
-20
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* 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.
|
||||
* Full-page view to browse, preview, restore, and delete backups across vaults.
|
||||
* Accessible from the backup/diff viewer footer.
|
||||
*/
|
||||
import { api } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
var state = { backups: [], filter: '' };
|
||||
var state = { backups: [], filter: '', preview: null };
|
||||
|
||||
export function openBackupManager() {
|
||||
var area = document.getElementById('content-area');
|
||||
@@ -28,9 +28,14 @@ export function openBackupManager() {
|
||||
'</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 class="backup-manager-body">' +
|
||||
'<div class="backup-manager-list" id="bm-list"></div>' +
|
||||
'<div class="backup-manager-preview" id="bm-preview" style="display:none">' +
|
||||
'<div class="bm-preview-header">' +
|
||||
'<h3 id="bm-preview-title">Apercu</h3>' +
|
||||
'<button class="backup-manager-btn" id="bm-preview-close" title="Fermer"><i data-lucide="x" style="width:14px;height:14px"></i></button>' +
|
||||
'</div>' +
|
||||
'<pre class="bm-preview-content" id="bm-preview-content"></pre>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
@@ -42,7 +47,11 @@ export function openBackupManager() {
|
||||
render();
|
||||
});
|
||||
document.getElementById('bm-refresh').addEventListener('click', load);
|
||||
document.getElementById('bm-preview-close').addEventListener('click', function() {
|
||||
document.getElementById('bm-preview').style.display = 'none';
|
||||
});
|
||||
|
||||
state.preview = null;
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -85,7 +94,6 @@ function render() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by vault
|
||||
var grouped = {};
|
||||
filtered.forEach(function(b) {
|
||||
if (!grouped[b.vault]) grouped[b.vault] = [];
|
||||
@@ -95,12 +103,18 @@ function render() {
|
||||
var html = '';
|
||||
var vaultNames = Object.keys(grouped).sort();
|
||||
vaultNames.forEach(function(vault) {
|
||||
var vaultBackups = grouped[vault];
|
||||
var vaultSize = vaultBackups.reduce(function(s, b) { return s + b.size; }, 0);
|
||||
|
||||
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>';
|
||||
html += '<div class="bm-vault-header">';
|
||||
html += '<h3 class="bm-vault-name"><i data-lucide="database" style="width:14px;height:14px"></i> ' + escapeHtml(vault) + '</h3>';
|
||||
html += '<span class="bm-vault-meta">' + vaultBackups.length + ' backups · ' + formatSize(vaultSize) + '</span>';
|
||||
html += '<button class="bm-vault-purge" data-vault="' + escapeHtml(vault) + '" title="Supprimer tous les backups de ce vault"><i data-lucide="trash-2" style="width:14px;height:14px"></i> Vider</button>';
|
||||
html += '</div>';
|
||||
|
||||
// Group by file
|
||||
var byFile = {};
|
||||
grouped[vault].forEach(function(b) {
|
||||
vaultBackups.forEach(function(b) {
|
||||
if (!byFile[b.file]) byFile[b.file] = [];
|
||||
byFile[b.file].push(b);
|
||||
});
|
||||
@@ -115,17 +129,21 @@ function render() {
|
||||
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 += '<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:12px;height:12px"></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 += '<div class="bm-backup-item' + (state.preview === b.full_path ? ' active' : '') + '" data-path="' + escapeHtml(b.full_path) + '" data-vault="' + escapeHtml(b.vault) + '" data-file="' + escapeHtml(b.file) + '">';
|
||||
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 class="bm-backup-actions">';
|
||||
html += '<button class="bm-backup-preview" title="Apercu"><i data-lucide="eye" style="width:12px;height:12px"></i></button>';
|
||||
html += '<button class="bm-backup-restore" title="Restaurer cette version"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>';
|
||||
html += '<button class="bm-backup-delete" data-path="' + escapeHtml(b.full_path) + '" title="Supprimer"><i data-lucide="x" style="width:12px;height:12px"></i></button>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
@@ -138,23 +156,115 @@ function render() {
|
||||
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 vault purge
|
||||
list.querySelectorAll('.bm-vault-purge').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Supprimer TOUS les backups du vault "' + btn.dataset.vault + '" ?')) {
|
||||
purgeVault(btn.dataset.vault);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Bind purge buttons
|
||||
// Bind file purge
|
||||
list.querySelectorAll('.bm-file-purge').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Supprimer TOUS les backups de "' + btn.dataset.file + '" ?')) {
|
||||
purgeFile(btn.dataset.vault, btn.dataset.file);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Bind backup item clicks (preview) and action buttons
|
||||
list.querySelectorAll('.bm-backup-item').forEach(function(item) {
|
||||
item.addEventListener('click', function(e) {
|
||||
// Don't trigger preview if clicking an action button
|
||||
if (e.target.closest('button')) return;
|
||||
state.preview = item.dataset.path;
|
||||
render();
|
||||
previewBackup(item.dataset.path);
|
||||
});
|
||||
});
|
||||
|
||||
// Bind action buttons
|
||||
list.querySelectorAll('.bm-backup-preview').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var item = btn.closest('.bm-backup-item');
|
||||
state.preview = item.dataset.path;
|
||||
render();
|
||||
previewBackup(item.dataset.path);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.bm-backup-restore').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var item = btn.closest('.bm-backup-item');
|
||||
restoreBackup(item.dataset.vault, item.dataset.file);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.bm-backup-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Supprimer ce backup ?')) {
|
||||
deleteBackups([btn.dataset.path]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function previewBackup(fpath) {
|
||||
var panel = document.getElementById('bm-preview');
|
||||
var content = document.getElementById('bm-preview-content');
|
||||
var title = document.getElementById('bm-preview-title');
|
||||
panel.style.display = 'block';
|
||||
content.textContent = 'Chargement...';
|
||||
|
||||
try {
|
||||
var data = await api('/api/backups/content?path=' + encodeURIComponent(fpath));
|
||||
content.textContent = data.content || '(fichier vide)';
|
||||
title.textContent = 'Apercu - ' + (data.name || '');
|
||||
} catch (err) {
|
||||
content.textContent = 'Erreur: ' + (err.message || 'Inconnue');
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup(vault, file) {
|
||||
// Find the backup timestamp from the currently selected backup
|
||||
var item = document.querySelector('.bm-backup-item.active');
|
||||
if (!item) { showToast('Selectionnez un backup a restaurer', 'error'); return; }
|
||||
|
||||
var backupPath = item.dataset.path;
|
||||
// Extract timestamp from the filename
|
||||
var parts = backupPath.replace(/\\/g, '/').split('/').pop().split('.');
|
||||
// The timestamp is the second-to-last component before .bak
|
||||
var ts = null;
|
||||
for (var i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i] === 'bak' && i > 0 && /^\d+$/.test(parts[i-1])) {
|
||||
ts = parseInt(parts[i-1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ts) { showToast('Impossible de trouver le timestamp du backup', 'error'); return; }
|
||||
|
||||
if (!confirm('Restaurer "' + file + '" a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.')) return;
|
||||
|
||||
try {
|
||||
var resp = await api('/api/file/' + encodeURIComponent(vault) + '/restore?path=' + encodeURIComponent(file), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: ts }),
|
||||
});
|
||||
if (resp && resp.success) {
|
||||
showToast('Fichier restaure avec succes', 'success');
|
||||
load();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Erreur: ' + (err.message || 'Inconnue'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackups(paths) {
|
||||
@@ -185,6 +295,20 @@ async function purgeFile(vault, file) {
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeVault(vault) {
|
||||
try {
|
||||
var resp = await api('/api/backups/purge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vault: vault }),
|
||||
});
|
||||
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';
|
||||
|
||||
@@ -7615,3 +7615,59 @@ body.popup-mode .content-area {
|
||||
text-align: center; padding: 2rem; color: var(--text-muted); font-size: 0.85rem;
|
||||
}
|
||||
.backup-manager-error { color: var(--danger); }
|
||||
|
||||
/* Manager body: list + preview side panel */
|
||||
.backup-manager-body { display: flex; gap: 0; min-height: 400px; }
|
||||
|
||||
.backup-manager-list { flex: 1; min-width: 0; }
|
||||
|
||||
.backup-manager-preview {
|
||||
width: 400px; flex-shrink: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.bm-preview-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.bm-preview-header h3 { margin: 0; font-size: 0.85rem; }
|
||||
.bm-preview-content {
|
||||
flex: 1; overflow: auto; padding: 12px 14px;
|
||||
font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.55;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
color: var(--text-secondary); margin: 0;
|
||||
}
|
||||
|
||||
/* Vault header with purge button */
|
||||
.bm-vault-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px 0; border-bottom: 1px solid var(--border); margin-bottom: 8px;
|
||||
}
|
||||
.bm-vault-meta { font-size: 0.7rem; color: var(--text-muted); flex: 1; }
|
||||
|
||||
.bm-vault-purge {
|
||||
padding: 3px 10px; border: 1px solid var(--border); border-radius: 4px;
|
||||
background: transparent; color: var(--danger); cursor: pointer;
|
||||
font-size: 0.7rem; display: flex; align-items: center; gap: 4px;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.bm-vault-purge:hover { background: rgba(239,68,68,0.08); border-color: var(--danger); }
|
||||
|
||||
/* Backup item actions */
|
||||
.bm-backup-actions { display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s; }
|
||||
.bm-backup-item:hover .bm-backup-actions { opacity: 1; }
|
||||
|
||||
.bm-backup-preview,
|
||||
.bm-backup-restore {
|
||||
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;
|
||||
}
|
||||
.bm-backup-preview:hover { color: var(--accent); background: rgba(59,130,246,0.1); }
|
||||
.bm-backup-restore:hover { color: #22c55e; background: rgba(34,197,94,0.1); }
|
||||
|
||||
.bm-backup-item.active {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user