diff --git a/public_html/admin/index.php b/public_html/admin/index.php
index 6558168..c2e9c75 100644
--- a/public_html/admin/index.php
+++ b/public_html/admin/index.php
@@ -312,6 +312,47 @@ if ($action) {
}
j(['ok' => true]);
+ // ── BACKUPS ───────────────────────────────────────────────────────────
+ case 'backups_list':
+ $dir = '/var/backups/jarvis';
+ $lock = "$dir/backup.lock";
+ $log = "$dir/backup.log";
+ $running = file_exists($lock) && (time() - filemtime($lock)) < 3600;
+ $files = [];
+ foreach (glob("$dir/jarvis_backup_*.tar.gz") ?: [] as $f) {
+ $files[] = [
+ 'file' => basename($f),
+ 'size' => filesize($f),
+ 'size_mb' => round(filesize($f)/1048576, 1),
+ 'date' => date('Y-m-d H:i:s', filemtime($f)),
+ ];
+ }
+ usort($files, fn($a,$b) => strcmp($b['date'], $a['date']));
+ $lastLog = $log && file_exists($log) ? trim(shell_exec("tail -3 " . escapeshellarg($log))) : '';
+ j(['running' => $running, 'files' => $files, 'last_log' => $lastLog]);
+
+ case 'backup_trigger':
+ $lock = '/var/backups/jarvis/backup.lock';
+ if (file_exists($lock) && (time() - filemtime($lock)) < 3600) {
+ j(['ok' => false, 'message' => 'Backup already running']);
+ }
+ shell_exec('nohup /usr/local/bin/jarvis-backup.sh > /dev/null 2>&1 &');
+ sleep(1);
+ j(['ok' => true, 'message' => 'Backup started']);
+
+ case 'backup_download':
+ $file = basename($_GET['file'] ?? '');
+ if (!preg_match('/^jarvis_backup_[\d_-]+\.tar\.gz$/', $file)) bad('Invalid filename');
+ $path = '/var/backups/jarvis/' . $file;
+ if (!file_exists($path)) bad('File not found', 404);
+ header('Content-Type: application/gzip');
+ header('Content-Disposition: attachment; filename="' . $file . '"');
+ header('Content-Length: ' . filesize($path));
+ header('X-Accel-Buffering: no');
+ ob_end_clean();
+ readfile($path);
+ exit;
+
default: bad('Unknown action');
}
}
@@ -475,6 +516,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
+
+
+
BACKUPS
+
+
+
+
+
+
+
BACKUP RUNNING...
+
+
+
+
+ Daily automatic backup runs at 2:00 AM. Files + all databases. Last 7 days retained. Stored on server — download anytime.
+
+
+
+
SITE HEALTH
@@ -728,6 +789,7 @@ function loadTab(tab) {
// Stop any existing network auto-refresh when leaving
if (_netAutoRefresh) { clearInterval(_netAutoRefresh); _netAutoRefresh = null; }
({
+ backups: loadBackups,
dashboard: loadDashboard,
agents: loadAgents,
network: ()=>{ loadNetwork(); _netAutoRefresh = setInterval(loadNetwork, 30000); },
@@ -1353,6 +1415,87 @@ async function loadVMs() {
${html}`;
}
+// ── BACKUPS ────────────────────────────────────────────────────────────────────
+let _backupPollTimer = null;
+
+function fmtSize(bytes) {
+ if (bytes >= 1073741824) return (bytes/1073741824).toFixed(1) + ' GB';
+ if (bytes >= 1048576) return (bytes/1048576).toFixed(1) + ' MB';
+ return (bytes/1024).toFixed(0) + ' KB';
+}
+
+async function loadBackups() {
+ const list = document.getElementById('backups-list');
+ list.innerHTML = '
SCANNING...
';
+ const data = await api('backups_list');
+
+ // Show/hide running status bar
+ const bar = document.getElementById('backup-status-bar');
+ if (data.running) {
+ bar.style.display = 'block';
+ document.getElementById('backup-status-msg').textContent = 'BACKUP IN PROGRESS...';
+ document.getElementById('backup-log-tail').textContent = data.last_log || '';
+ // Animate progress bar
+ let pct = parseInt(document.getElementById('backup-progress-bar').style.width) || 5;
+ pct = Math.min(pct + 8, 90);
+ document.getElementById('backup-progress-bar').style.width = pct + '%';
+ document.getElementById('backup-progress-bar').style.background = 'var(--yellow)';
+ if (!_backupPollTimer) _backupPollTimer = setInterval(loadBackups, 4000);
+ } else {
+ if (_backupPollTimer) { clearInterval(_backupPollTimer); _backupPollTimer = null; }
+ if (bar.style.display !== 'none') {
+ // Just finished
+ document.getElementById('backup-status-msg').textContent = '✓ BACKUP COMPLETE';
+ document.getElementById('backup-progress-bar').style.width = '100%';
+ document.getElementById('backup-progress-bar').style.background = 'var(--green)';
+ document.getElementById('backup-log-tail').textContent = data.last_log || '';
+ setTimeout(() => { bar.style.display = 'none'; }, 4000);
+ }
+ document.getElementById('backupRunBtn').disabled = false;
+ document.getElementById('backupRunBtn').textContent = '▶ RUN BACKUP NOW';
+ }
+
+ const files = data.files || [];
+ if (!files.length) {
+ list.innerHTML = '
NO BACKUPS YET — click RUN BACKUP NOW to create the first one
';
+ return;
+ }
+
+ list.innerHTML = `
+ | DATE / TIME | FILENAME | SIZE | DOWNLOAD |
+ ${files.map((f, i) => `
+ | ${f.date}${i===0?' ● LATEST':''} |
+ ${esc(f.file)} |
+ ${fmtSize(f.size)} |
+ ↓ DOWNLOAD |
+
`).join('')}
`;
+}
+
+async function triggerBackup() {
+ const btn = document.getElementById('backupRunBtn');
+ btn.disabled = true; btn.textContent = 'STARTING...';
+ const bar = document.getElementById('backup-status-bar');
+ bar.style.display = 'block';
+ document.getElementById('backup-status-msg').textContent = 'BACKUP STARTING...';
+ document.getElementById('backup-progress-bar').style.width = '5%';
+ document.getElementById('backup-progress-bar').style.background = 'var(--yellow)';
+ document.getElementById('backup-log-tail').textContent = '';
+
+ const fd = new FormData(); fd.append('action','backup_trigger');
+ try {
+ const r = await fetch(location.href, {method:'POST', body:fd});
+ const d = await r.json();
+ if (d.ok) {
+ toast('Backup started — polling for completion...', 'ok');
+ btn.textContent = 'RUNNING...';
+ if (!_backupPollTimer) _backupPollTimer = setInterval(loadBackups, 4000);
+ } else {
+ toast(d.message || 'Already running', 'ok');
+ btn.disabled = false; btn.textContent = '▶ RUN BACKUP NOW';
+ }
+ } catch(e) { toast('Failed to start backup', 'err'); btn.disabled = false; btn.textContent = '▶ RUN BACKUP NOW'; }
+}
+
// ── AUTO-LOGIN CHECK (PHP session) ───────────────────────────────────────────
document.getElementById('loginWrap').style.display='none';