Sync from main server - 2026-05-13 01:06:32

This commit is contained in:
root
2026-05-13 01:06:32 +02:00
parent 09bbe0403c
commit 6158b34613
8 changed files with 2159 additions and 129 deletions

View File

@@ -39,6 +39,9 @@
<a class="nav-item {% if active_page == 'backups' %}active{% endif %}" href="{{ url_for('backups_page') }}">
<i class="fas fa-database"></i><span>Backups</span>
</a>
<a class="nav-item {% if active_page == 'cloud' %}active{% endif %}" href="{{ url_for('cloud_page') }}">
<i class="fas fa-cloud"></i><span>Cloud Storage</span>
</a>
<div class="nav-section-label" style="margin-top:20px;">ADMIN</div>
<a class="nav-item {% if active_page == 'users' %}active{% endif %}" href="{{ url_for('users_page') }}">

View File

@@ -1,5 +1,276 @@
{% extends "base.html" %}
{% block content %}
{# ── Extra styles scoped to this page ── #}
<style>
/* ── BACKUP ITEMS ──────────────────────────────────────────────── */
.backup-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-radius: 10px;
background: var(--surface2);
border: 1px solid var(--border);
gap: 10px;
flex-wrap: wrap;
transition: border-color 0.15s, background 0.15s;
}
.backup-item:hover { border-color: var(--border2); background: var(--bg4); }
.backup-name {
font-family: var(--mono);
font-size: 12px;
color: var(--text2);
word-break: break-all;
}
.backup-actions { display: flex; gap: 5px; align-items: center; flex-shrink: 0; }
/* ── ENHANCED MODAL ─────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.72);
backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center;
padding: 20px;
animation: fadeInOverlay 0.18s ease;
}
@keyframes fadeInOverlay {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-box {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 18px;
width: 100%;
max-width: 620px;
max-height: 88vh;
overflow-y: auto;
box-shadow: 0 40px 100px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
animation: slideUpModal 0.22s cubic-bezier(0.34,1.56,0.64,1);
}
[data-theme="light"] .modal-box {
background: #fff;
border-color: #dde1ed;
box-shadow: 0 20px 60px rgba(0,0,0,0.14);
}
@keyframes slideUpModal {
from { opacity: 0; transform: translateY(18px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border);
position: sticky; top: 0;
background: var(--surface);
border-radius: 18px 18px 0 0;
z-index: 2;
}
[data-theme="light"] .modal-header { background: #fff; border-color: #dde1ed; }
.modal-title {
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
}
.modal-close {
background: var(--surface2);
border: 1px solid var(--border);
cursor: pointer;
color: var(--text3);
font-size: 14px;
width: 30px; height: 30px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, color 0.15s;
}
.modal-close:hover { background: rgba(239,68,68,0.12); color: #ef4444; border-color: rgba(239,68,68,0.25); }
.modal-body { padding: 20px 24px 24px; }
/* ── AUDIT STATUS BANNER ─────────────────────────────────────── */
.audit-status-banner {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
border-radius: 12px;
margin-bottom: 18px;
border: 1px solid transparent;
}
.audit-status-banner.healthy { background: rgba(34,197,94,0.08); border-color: rgba(34,197,94,0.2); }
.audit-status-banner.warning { background: rgba(245,158,11,0.08); border-color: rgba(245,158,11,0.2); }
.audit-status-banner.critical { background: rgba(239,68,68,0.08); border-color: rgba(239,68,68,0.2); }
.audit-status-icon {
width: 40px; height: 40px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.audit-status-banner.healthy .audit-status-icon { background: rgba(34,197,94,0.15); color: var(--green); }
.audit-status-banner.warning .audit-status-icon { background: rgba(245,158,11,0.15); color: var(--yellow); }
.audit-status-banner.critical .audit-status-icon { background: rgba(239,68,68,0.15); color: var(--red); }
.audit-status-info { flex: 1; min-width: 0; }
.audit-status-label {
font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
}
.audit-status-banner.healthy .audit-status-label { color: var(--green); }
.audit-status-banner.warning .audit-status-label { color: var(--yellow); }
.audit-status-banner.critical .audit-status-label { color: var(--red); }
.audit-status-file {
font-family: var(--mono); font-size: 11px; color: var(--text3); margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.audit-status-size {
font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--text2);
}
/* ── SCORE BAR ─────────────────────────────────────────────── */
.audit-score-row {
display: flex; align-items: center; gap: 10px; margin-bottom: 18px;
}
.audit-score-bar-wrap {
flex: 1; height: 6px; background: var(--border2); border-radius: 10px; overflow: hidden;
}
.audit-score-bar-fill {
height: 100%; border-radius: 10px;
background: linear-gradient(90deg, var(--accent), var(--cyan));
transition: width 0.8s cubic-bezier(0.34,1.2,0.64,1);
}
.audit-score-bar-fill.warn { background: linear-gradient(90deg, var(--yellow), #f97316); }
.audit-score-bar-fill.crit { background: linear-gradient(90deg, var(--red), #f97316); }
.audit-score-num {
font-family: var(--mono); font-size: 12px; font-weight: 700;
min-width: 36px; text-align: right;
}
/* ── AUDIT CHECKS LIST ───────────────────────────────────────── */
.audit-checks-label {
font-family: var(--mono); font-size: 9px; font-weight: 700;
letter-spacing: 0.12em; color: var(--text3);
text-transform: uppercase; margin-bottom: 8px;
}
.audit-check-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 9px 12px; border-radius: 8px; margin-bottom: 4px;
background: var(--surface2); border: 1px solid var(--border);
transition: background 0.12s;
}
.audit-check-item:hover { background: var(--bg4); }
.audit-check-icon {
width: 20px; height: 20px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 10px; flex-shrink: 0; margin-top: 1px;
}
.audit-check-icon.pass { background: rgba(34,197,94,0.15); color: var(--green); }
.audit-check-icon.warn { background: rgba(245,158,11,0.15); color: var(--yellow); }
.audit-check-icon.fail { background: rgba(239,68,68,0.15); color: var(--red); }
.audit-check-body { flex: 1; min-width: 0; }
.audit-check-name { font-size: 12px; font-weight: 600; color: var(--text); }
.audit-check-detail { font-size: 11px; color: var(--text3); font-family: var(--mono); margin-top: 2px; word-break: break-word; }
.audit-sha {
margin-top: 14px; padding: 10px 14px;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; font-family: var(--mono); font-size: 11px;
color: var(--text3); word-break: break-all;
}
.audit-sha span { color: var(--cyan); }
/* ── AUDIT summary ─────────────────────────────────────────── */
.audit-summary-box {
margin-top: 14px; padding: 12px 14px;
background: var(--surface2); border-radius: 10px;
border: 1px solid var(--border);
font-size: 12px; color: var(--text2); line-height: 1.6;
}
/* ── DETAILS MODAL specific ──────────────────────────────────── */
.details-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px;
}
.details-card {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 10px; padding: 12px 14px;
}
.details-card-label {
font-family: var(--mono); font-size: 9px; font-weight: 700;
letter-spacing: 0.12em; color: var(--text3); text-transform: uppercase; margin-bottom: 5px;
}
.details-card-value {
font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--text);
word-break: break-all; line-height: 1.4;
}
.details-card-value.accent { color: var(--accent2); }
.details-card-value.green { color: var(--green); }
.details-card-value.yellow { color: var(--yellow); }
.details-path-row {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 10px; padding: 12px 14px; margin-bottom: 10px;
}
.details-path-label {
font-family: var(--mono); font-size: 9px; font-weight: 700;
letter-spacing: 0.12em; color: var(--text3); text-transform: uppercase; margin-bottom: 4px;
}
.details-path-value {
font-family: var(--mono); font-size: 12px; color: var(--cyan); word-break: break-all;
}
.details-sha-row {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 10px; padding: 12px 14px; margin-bottom: 10px;
}
.details-badge-row {
display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px;
}
.details-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 20px; font-family: var(--mono);
font-size: 11px; font-weight: 600;
}
.details-badge.ok { background: rgba(34,197,94,0.1); color: var(--green); border: 1px solid rgba(34,197,94,0.2); }
.details-badge.warn { background: rgba(245,158,11,0.1); color: var(--yellow); border: 1px solid rgba(245,158,11,0.2); }
.details-badge.info { background: rgba(96,165,250,0.1); color: var(--accent2);border: 1px solid rgba(96,165,250,0.2); }
/* ── CLOUD AUDIT specifics ───────────────────────────────────── */
.r2-check-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-radius: 8px; margin-bottom: 4px;
background: var(--surface2); border: 1px solid var(--border);
font-family: var(--mono); font-size: 12px; color: var(--text2);
}
.r2-check-icon { font-size: 14px; flex-shrink: 0; }
/* ── R2 toast ─────────────────────────────────────────────── */
#r2-toast {
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 12px;
padding: 14px 18px;
font-size: 12px; font-family: var(--mono); color: var(--text2);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
min-width: 290px;
animation: slideUpModal 0.2s ease;
}
</style>
{# ── Manual Backup ── #}
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-shield-halved"></i> Manual Backup</div>
@@ -20,41 +291,73 @@
</div>
</div>
{# ── Available Backups ── #}
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-database"></i> Available Backups</div>
<button class="btn btn-ghost btn-sm" onclick="refreshBackupsList()"><i class="fas fa-sync-alt"></i> Refresh</button>
</div>
<div class="two-col">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;">
{# LOCAL #}
<div>
<div class="section-header"><div class="section-title">🖥️ MAIN SERVER</div><span class="card-meta">/root/backups/</span></div>
<div class="section-header">
<div class="section-title">🖥️ MAIN SERVER</div>
<span class="card-meta">/root/backups/</span>
</div>
<div class="backup-list" id="local-backup-list">
{% for b in backups %}
<div class="backup-item"><span class="backup-name">{{ b }}</span><div class="backup-actions"><button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('local','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button><button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b }}')">↩ Restore</button><button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('local','{{ b }}',this)"><i class="fas fa-trash"></i></button></div></div>
<div class="backup-item">
<span class="backup-name">{{ b }}</span>
<div class="backup-actions">
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('local','{{ b }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('local','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
<button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b }}')">↩ Restore</button>
<button class="btn btn-ghost btn-sm" onclick="quickUploadR2('{{ b }}',this)" title="Upload to R2"><i class="fas fa-cloud-upload-alt"></i></button>
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('local','{{ b }}',this)"><i class="fas fa-trash"></i></button>
</div>
</div>
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>{% endfor %}
</div>
</div>
{# VM #}
<div>
<div class="section-header"><div class="section-title">💾 VM SERVER</div><span class="card-meta">/backups/main-server/</span></div>
<div class="section-header">
<div class="section-title">💾 VM SERVER</div>
<span class="card-meta">/backups/main-server/</span>
</div>
<div class="backup-list" id="vm-backup-list">
{% for b in vm_backups %}
<div class="backup-item"><span class="backup-name">{{ b }}</span><div class="backup-actions"><button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('vm','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button><button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b }}')">↩ Restore</button><button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('vm','{{ b }}',this)"><i class="fas fa-trash"></i></button></div></div>
<div class="backup-item">
<span class="backup-name">{{ b }}</span>
<div class="backup-actions">
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('vm','{{ b }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('vm','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
<button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b }}')">↩ Restore</button>
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('vm','{{ b }}',this)"><i class="fas fa-trash"></i></button>
</div>
</div>
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No VM backups</div>{% endfor %}
</div>
</div>
</div>
</div>
<div id="audit-modal" class="modal-overlay" style="display:none;" onclick="closeAuditModal(event)">
<div class="modal-box" onclick="event.stopPropagation()">
<div class="modal-header">
<div class="modal-title"><i class="fas fa-shield-halved" style="color:var(--cyan);"></i> Backup Health Audit</div>
<button class="modal-close" onclick="closeAuditModal()" title="Close"></button>
{# CLOUD R2 #}
<div>
<div class="section-header">
<div class="section-title">☁️ CLOUD (R2)</div>
<span class="card-meta">navitrends-backups</span>
</div>
<div class="backup-list" id="cloud-backup-list">
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div>
</div>
</div>
<div id="audit-modal-content"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div></div>
</div>
</div>
{# ── Backup History ── #}
<div class="card" style="margin-top:0;">
<div class="card-header">
<div class="card-title"><i class="fas fa-clock-rotate-left"></i> Backup History</div>
@@ -62,4 +365,493 @@
</div>
<div id="backup-history-list"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></div>
</div>
{% endblock %}
{# ══════════════════════════════════════════════════════════════
MODALS
══════════════════════════════════════════════════════════════ #}
{# ── Local/VM Audit modal ── #}
<div id="audit-modal" class="modal-overlay" style="display:none;" onclick="closeAuditModal(event)">
<div class="modal-box" onclick="event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<i class="fas fa-shield-halved" style="color:var(--cyan);"></i>
Backup Health Audit
</div>
<button class="modal-close" onclick="closeAuditModal()" title="Close"></button>
</div>
<div class="modal-body" id="audit-modal-content">
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>
</div>
</div>
</div>
{# ── Cloud (R2) Audit modal ── #}
<div id="r2-audit-modal" class="modal-overlay" style="display:none;" onclick="closeR2AuditModal(event)">
<div class="modal-box" onclick="event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<i class="fas fa-shield-halved" style="color:var(--cyan);"></i>
Cloud Backup Audit — R2
</div>
<button class="modal-close" onclick="closeR2AuditModal()" title="Close"></button>
</div>
<div class="modal-body" id="r2-audit-content">
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>
</div>
</div>
</div>
{# ── Details modal ── #}
<div id="details-modal" class="modal-overlay" style="display:none;" onclick="closeDetailsModal(event)">
<div class="modal-box" onclick="event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<i class="fas fa-circle-info" style="color:var(--accent2);"></i>
Backup Details
</div>
<button class="modal-close" onclick="closeDetailsModal()" title="Close"></button>
</div>
<div class="modal-body" id="details-modal-content">
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading details…</div>
</div>
</div>
</div>
{# ── R2 upload toast ── #}
<div id="r2-toast" style="display:none;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<i class="fas fa-cloud-upload-alt" style="color:var(--accent2);font-size:16px;"></i>
<span id="r2-toast-msg" style="flex:1;font-size:12px;">Uploading to R2…</span>
</div>
<div style="height:5px;background:var(--border2);border-radius:10px;overflow:hidden;">
<div id="r2-toast-bar" style="height:100%;background:linear-gradient(90deg,var(--accent2),var(--cyan));width:0%;transition:width 0.4s ease;border-radius:10px;"></div>
</div>
</div>
<script>
// ════════════════════════════════════════════════════════════════
// HELPERS
// ════════════════════════════════════════════════════════════════
function _statusTier(status) {
if (status === 'healthy') return 'healthy';
if (status === 'warning') return 'warning';
return 'critical';
}
function _statusIcon(status) {
if (status === 'healthy') return 'fa-circle-check';
if (status === 'warning') return 'fa-triangle-exclamation';
return 'fa-circle-xmark';
}
function _checkIcon(s) {
if (s === 'pass') return '✓';
if (s === 'warn') return '!';
return '✗';
}
function _scoreClass(score) {
if (score >= 75) return '';
if (score >= 50) return 'warn';
return 'crit';
}
// ════════════════════════════════════════════════════════════════
// AUDIT — LOCAL / VM
// ════════════════════════════════════════════════════════════════
function renderAuditModal(report) {
const tier = report.health_tier || _statusTier(report.status || '');
const label = report.health_label || report.status || 'Unknown';
const score = typeof report.score === 'number' ? report.score : null;
const scoreStr = score !== null ? score + '%' : '—';
const scoreCls = score !== null ? _scoreClass(score) : 'crit';
let checksHtml = '';
(report.checks || []).forEach(c => {
checksHtml += `
<div class="audit-check-item">
<div class="audit-check-icon ${c.status}">${_checkIcon(c.status)}</div>
<div class="audit-check-body">
<div class="audit-check-name">${c.name}</div>
${c.detail ? `<div class="audit-check-detail">${c.detail}</div>` : ''}
</div>
</div>`;
});
return `
<div class="audit-status-banner ${tier}">
<div class="audit-status-icon">
<i class="fas ${_statusIcon(tier)}"></i>
</div>
<div class="audit-status-info">
<div class="audit-status-label">${label}</div>
<div class="audit-status-file">${report.backup_file || ''}</div>
</div>
${report.file_size_display ? `<div class="audit-status-size">${report.file_size_display}</div>` : ''}
</div>
${score !== null ? `
<div class="audit-score-row">
<div style="font-family:var(--mono);font-size:11px;color:var(--text3);">Health Score</div>
<div class="audit-score-bar-wrap">
<div class="audit-score-bar-fill ${scoreCls}" style="width:${score}%"></div>
</div>
<div class="audit-score-num" style="color:${score>=75?'var(--green)':score>=50?'var(--yellow)':'var(--red)'}">${scoreStr}</div>
</div>` : ''}
${report.checks && report.checks.length ? `
<div class="audit-checks-label">Checks</div>
${checksHtml}` : ''}
${report.summary ? `
<div class="audit-summary-box">
<i class="fas fa-info-circle" style="color:var(--accent2);margin-right:6px;"></i>${report.summary}
</div>` : ''}
${report.stored_hash || report.sha256 ? `
<div class="audit-sha">
<span style="color:var(--text3);">SHA256 </span>
<span>${report.stored_hash || report.sha256}</span>
</div>` : ''}
`;
}
async function auditBackup(source, filename, btn) {
const modal = document.getElementById('audit-modal');
const content = document.getElementById('audit-modal-content');
modal.style.display = 'flex';
content.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>';
try {
const res = await fetch('/api/backups/audit', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({source, backup_file: filename})
});
const report = await res.json();
content.innerHTML = renderAuditModal(report);
// animate score bar
setTimeout(() => {
const bar = content.querySelector('.audit-score-bar-fill');
if (bar) bar.style.width = (report.score || 0) + '%';
}, 80);
} catch(e) {
content.innerHTML = `<div style="color:var(--red);font-family:var(--mono);font-size:12px;padding:16px;">❌ Audit request failed: ${e.message}</div>`;
}
}
function closeAuditModal(event) {
if (!event || event.target === document.getElementById('audit-modal')) {
document.getElementById('audit-modal').style.display = 'none';
}
}
// ════════════════════════════════════════════════════════════════
// AUDIT — CLOUD R2
// ════════════════════════════════════════════════════════════════
async function auditCloudBackup(key, btn) {
const modal = document.getElementById('r2-audit-modal');
const content = document.getElementById('r2-audit-content');
modal.style.display = 'flex';
content.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>';
try {
const res = await fetch('/api/cloud/r2/audit', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({key})
});
const report = await res.json();
const tier = _statusTier(report.status);
const score = report.score || null;
const scoreCls = score !== null ? _scoreClass(score) : 'crit';
let checksHtml = '';
(report.checks || []).forEach(c => {
const icon = c.startsWith('✅') ? 'ok' : c.startsWith('⚠️') ? 'warn' : 'fail';
checksHtml += `<div class="r2-check-row">
<span class="r2-check-icon">${c.startsWith('✅') ? '✅' : c.startsWith('⚠️') ? '⚠️' : '❌'}</span>
<span>${c.replace(/^[✅⚠️❌]\s*/,'')}</span>
</div>`;
});
content.innerHTML = `
<div class="audit-status-banner ${tier}">
<div class="audit-status-icon"><i class="fas ${_statusIcon(tier)}"></i></div>
<div class="audit-status-info">
<div class="audit-status-label">${report.status || 'Unknown'}</div>
<div class="audit-status-file">${key}</div>
</div>
${report.size_human ? `<div class="audit-status-size">${report.size_human}</div>` : ''}
</div>
${score !== null ? `
<div class="audit-score-row">
<div style="font-family:var(--mono);font-size:11px;color:var(--text3);">Health Score</div>
<div class="audit-score-bar-wrap">
<div class="audit-score-bar-fill ${scoreCls}" style="width:0%"></div>
</div>
<div class="audit-score-num" style="color:${score>=75?'var(--green)':score>=50?'var(--yellow)':'var(--red)'}">${score}%</div>
</div>` : ''}
${checksHtml ? `<div class="audit-checks-label">Checks</div>${checksHtml}` : ''}
${report.warnings && report.warnings.length ? `
<div class="audit-checks-label" style="color:var(--yellow);margin-top:12px;">Warnings</div>
${report.warnings.map(w=>`<div class="r2-check-row" style="border-color:rgba(245,158,11,0.2);">
<span class="r2-check-icon">⚠️</span><span style="color:var(--yellow);">${w}</span>
</div>`).join('')}` : ''}
${report.errors && report.errors.length ? `
<div class="audit-checks-label" style="color:var(--red);margin-top:12px;">Errors</div>
${report.errors.map(e=>`<div class="r2-check-row" style="border-color:rgba(239,68,68,0.2);">
<span class="r2-check-icon">❌</span><span style="color:var(--red);">${e}</span>
</div>`).join('')}` : ''}
${report.stored_hash ? `<div class="audit-sha"><span style="color:var(--text3);">SHA256 </span><span>${report.stored_hash}</span></div>` : ''}
`;
setTimeout(() => {
const bar = content.querySelector('.audit-score-bar-fill');
if (bar) bar.style.width = (score || 0) + '%';
}, 80);
} catch(e) {
content.innerHTML = `<div style="color:var(--red);font-family:var(--mono);font-size:12px;padding:16px;">❌ Audit request failed: ${e.message}</div>`;
}
}
function closeR2AuditModal(event) {
if (!event || event.target === document.getElementById('r2-audit-modal')) {
document.getElementById('r2-audit-modal').style.display = 'none';
}
}
// ════════════════════════════════════════════════════════════════
// DETAILS MODAL
// ════════════════════════════════════════════════════════════════
async function showBackupDetails(source, filename, btn) {
const modal = document.getElementById('details-modal');
const content = document.getElementById('details-modal-content');
modal.style.display = 'flex';
content.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading details…</div>';
try {
const res = await fetch('/api/backups/details', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({source, backup_file: filename})
});
const d = await res.json();
// Parse date from filename: myapps-backup-YYYYMMDD_HHMMSS.tar.gz
const m = filename.match(/(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
let dateStr = d.created_at || '—';
if (!d.created_at && m) {
dateStr = `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]} UTC`;
}
const path = source === 'local'
? `/root/backups/${filename}`
: `/backups/main-server/${filename}`;
const hasSha = d.sha256 && d.sha256 !== 'none';
const ageDisplay = d.age_days !== undefined
? (d.age_days === 0 ? 'Today' : d.age_days === 1 ? '1 day ago' : `${d.age_days} days ago`)
: '—';
content.innerHTML = `
<div class="details-path-row">
<div class="details-path-label">File Path</div>
<div class="details-path-value">${d.path || path}</div>
</div>
<div class="details-grid">
<div class="details-card">
<div class="details-card-label">File Size</div>
<div class="details-card-value accent">${d.size_human || '—'}</div>
</div>
<div class="details-card">
<div class="details-card-label">Created</div>
<div class="details-card-value" style="font-size:11px;">${dateStr}</div>
</div>
<div class="details-card">
<div class="details-card-label">Age</div>
<div class="details-card-value ${d.age_days > 7 ? 'yellow' : 'green'}">${ageDisplay}</div>
</div>
<div class="details-card">
<div class="details-card-label">Source</div>
<div class="details-card-value">${source === 'local' ? '🖥️ Main Server' : '💾 VM Server'}</div>
</div>
</div>
<div class="details-sha-row">
<div class="details-path-label">SHA256 Checksum</div>
${hasSha
? `<div style="font-family:var(--mono);font-size:11px;color:var(--cyan);word-break:break-all;margin-top:4px;">${d.sha256}</div>`
: `<div style="font-family:var(--mono);font-size:11px;color:var(--text3);margin-top:4px;">No .sha256 sidecar — run a new backup to generate checksums</div>`}
</div>
<div class="details-badge-row">
${hasSha ? `<span class="details-badge ok"><i class="fas fa-check"></i> Checksum present</span>` : `<span class="details-badge warn"><i class="fas fa-triangle-exclamation"></i> No checksum</span>`}
${d.size_bytes && d.size_bytes > 50*1024*1024 ? `<span class="details-badge ok"><i class="fas fa-check"></i> Size OK</span>` : `<span class="details-badge warn"><i class="fas fa-triangle-exclamation"></i> Small file</span>`}
<span class="details-badge info"><i class="fas fa-file-zipper"></i> tar.gz</span>
${d.on_cloud ? `<span class="details-badge ok"><i class="fas fa-cloud"></i> Synced to R2</span>` : `<span class="details-badge info"><i class="fas fa-cloud"></i> Not in R2</span>`}
</div>
`;
} catch(e) {
content.innerHTML = `<div style="color:var(--red);font-family:var(--mono);font-size:12px;padding:16px;">❌ Failed to load details: ${e.message}</div>`;
}
}
function closeDetailsModal(event) {
if (!event || event.target === document.getElementById('details-modal')) {
document.getElementById('details-modal').style.display = 'none';
}
}
// ════════════════════════════════════════════════════════════════
// CLOUD BACKUP COLUMN
// ════════════════════════════════════════════════════════════════
async function loadCloudBackupsColumn() {
const list = document.getElementById('cloud-backup-list');
try {
const res = await fetch('/api/cloud/r2/backups');
const data = await res.json();
if (!data.backups || data.backups.length === 0) {
list.innerHTML = '<div class="empty-state"><i class="fas fa-cloud"></i> No cloud backups yet</div>';
return;
}
const archives = data.backups.filter(b => b.name.endsWith('.tar.gz'));
list.innerHTML = archives.map(b => `
<div class="backup-item">
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;">
<span class="backup-name" style="font-size:11px;">
<i class="fas fa-cloud" style="color:var(--accent2);font-size:10px;margin-right:4px;"></i>${b.name}
</span>
<span style="font-size:10px;color:var(--text3);font-family:var(--mono);">${b.size_human} · ${b.last_modified_str}</span>
</div>
<div class="backup-actions">
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditCloudBackup('${b.key}', this)" title="Audit R2 integrity">
<i class="fas fa-shield-check"></i> Audit
</button>
<button class="btn btn-ghost btn-sm" onclick="restoreFromCloud('${b.key}','${b.name}')">↩ Restore</button>
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteCloudBackup('${b.key}',this)"><i class="fas fa-trash"></i></button>
</div>
</div>
`).join('');
} catch(e) {
list.innerHTML = `<div class="empty-state" style="color:var(--red);">Failed to load R2 backups</div>`;
}
}
// ════════════════════════════════════════════════════════════════
// QUICK UPLOAD TO R2
// ════════════════════════════════════════════════════════════════
async function quickUploadR2(filename, btn) {
const toast = document.getElementById('r2-toast');
const toastMsg = document.getElementById('r2-toast-msg');
const toastBar = document.getElementById('r2-toast-bar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
toast.style.display = 'block';
toastMsg.textContent = `Uploading ${filename}`;
toastBar.style.width = '0%';
try {
const res = await fetch('/api/cloud/r2/upload', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({backup_file: filename, source: 'local'})
});
const data = await res.json();
if (!data.success) {
toastMsg.textContent = '❌ ' + data.message;
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i>';
setTimeout(() => toast.style.display = 'none', 4000);
return;
}
const jobId = data.job_id;
const poll = setInterval(async () => {
try {
const s = await fetch(`/api/cloud/r2/upload/status/${jobId}`);
const job = await s.json();
toastBar.style.width = job.progress + '%';
toastMsg.textContent = job.progress < 100
? `Uploading ${filename}${job.progress}%`
: `✅ Uploaded to R2!`;
if (job.status === 'done') {
clearInterval(poll);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i>';
loadCloudBackupsColumn();
setTimeout(() => toast.style.display = 'none', 3000);
} else if (job.status === 'error') {
clearInterval(poll);
toastMsg.textContent = '❌ Upload failed';
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i>';
setTimeout(() => toast.style.display = 'none', 4000);
}
} catch(e) { clearInterval(poll); }
}, 1200);
} catch(e) {
toastMsg.textContent = '❌ ' + e.message;
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i>';
setTimeout(() => toast.style.display = 'none', 4000);
}
}
// ════════════════════════════════════════════════════════════════
// DELETE / RESTORE
// ════════════════════════════════════════════════════════════════
function restoreFromCloud(key, filename) {
window.location = `/restore?source=cloud&file=${encodeURIComponent(filename)}&key=${encodeURIComponent(key)}`;
}
async function deleteCloudBackup(key, btn) {
if (!confirm(`Delete from Cloudflare R2?\n${key}`)) return;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const res = await fetch('/api/cloud/r2/delete', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({key})
});
const data = await res.json();
if (data.success) {
loadCloudBackupsColumn();
} else {
alert('Delete failed: ' + data.message);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-trash"></i>';
}
} catch(e) {
alert(e.message);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-trash"></i>';
}
}
// Init
document.addEventListener('DOMContentLoaded', loadCloudBackupsColumn);
</script>
{% endblock %}

View File

@@ -0,0 +1,414 @@
{% extends "base.html" %}
{% block content %}
{# ── Connection status banner ─────────────────────────────────────────────── #}
<div class="card" id="r2-status-card">
<div class="card-header">
<div class="card-title"><i class="fas fa-cloud"></i> Cloudflare R2 — Cloud Storage</div>
<div style="display:flex;align-items:center;gap:10px;">
<span id="r2-conn-badge" class="badge" style="background:rgba(59,130,246,0.12);color:var(--accent2);">
<i class="fas fa-spinner fa-spin"></i> Checking…
</span>
<button class="btn btn-ghost btn-sm" onclick="testR2Connection()">
<i class="fas fa-plug"></i> Test Connection
</button>
</div>
</div>
<div class="stat-row" id="r2-stats-row">
<div class="stat-card">
<div class="stat-number" id="r2-stat-objects"></div>
<div class="stat-label">Cloud Backups</div>
</div>
<div class="stat-card">
<div class="stat-number" id="r2-stat-size"></div>
<div class="stat-label">Total Cloud Size</div>
</div>
<div class="stat-card">
<div class="stat-number" style="font-size:13px;color:var(--text2);" id="r2-stat-bucket">{{ r2_bucket }}</div>
<div class="stat-label">Bucket Name</div>
</div>
<div class="stat-card">
<div class="stat-number" style="font-size:11px;color:var(--text3);">R2</div>
<div class="stat-label">Provider</div>
</div>
</div>
<div id="r2-conn-msg" style="color:var(--text3);font-size:12px;margin-top:4px;font-family:var(--mono);padding:0 4px;"></div>
</div>
{# ── Upload panel ─────────────────────────────────────────────────────────── #}
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-cloud-upload-alt"></i> Upload Backup to R2</div>
</div>
<p style="color:var(--text2);font-size:13px;margin-bottom:14px;">
Select a local or VM backup to push to Cloudflare R2 cold storage.
</p>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;max-width:640px;">
<div class="form-group" style="flex:1;min-width:260px;">
<label class="form-label">SOURCE BACKUP</label>
<select id="upload-backup-select" class="form-input">
{% if local_backups %}
<optgroup label="🖥️ Main Server">
{% for b in local_backups %}<option value="local::{{ b }}">{{ b }}</option>{% endfor %}
</optgroup>
{% endif %}
{% if vm_backups %}
<optgroup label="💾 VM Server">
{% for b in vm_backups %}<option value="vm::{{ b }}">{{ b }}</option>{% endfor %}
</optgroup>
{% endif %}
{% if not local_backups and not vm_backups %}
<option disabled>No backups found</option>
{% endif %}
</select>
</div>
<button class="btn btn-primary" onclick="uploadToR2()" id="r2-upload-btn"
{% if not r2_configured %}disabled title="R2 credentials not configured"{% endif %}>
<i class="fas fa-cloud-upload-alt"></i> Upload to R2
</button>
</div>
{% if not r2_configured %}
<div style="margin-top:14px;padding:12px 14px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.25);border-radius:8px;font-size:12px;color:var(--yellow);">
<i class="fas fa-triangle-exclamation"></i>
<strong>R2 credentials not configured.</strong>
Set <code>R2_ACCESS_KEY_ID</code> and <code>R2_SECRET_ACCESS_KEY</code> — see Setup Guide below.
</div>
{% endif %}
<div id="r2-upload-wrapper" style="display:none;margin-top:16px;">
<div class="card-header" style="margin-bottom:8px;">
<div class="card-title" style="font-size:13px;"><i class="fas fa-terminal"></i> Upload Log</div>
<span class="badge" id="r2-upload-badge" style="background:rgba(59,130,246,0.15);color:var(--accent2);">Running…</span>
</div>
<div id="r2-upload-log" class="log-console" style="max-height:200px;"></div>
<div style="margin-top:8px;">
<div style="height:6px;background:var(--border);border-radius:4px;overflow:hidden;">
<div id="r2-progress-bar" style="height:100%;background:linear-gradient(90deg,var(--accent2),var(--cyan));width:0%;transition:width 0.4s ease;border-radius:4px;"></div>
</div>
<div style="display:flex;justify-content:space-between;margin-top:4px;">
<span style="font-size:11px;color:var(--text3);font-family:var(--mono);" id="r2-upload-elapsed"></span>
<span style="font-size:11px;color:var(--accent2);font-family:var(--mono);" id="r2-progress-pct">0%</span>
</div>
</div>
</div>
</div>
{# ── Cloud backup list ─────────────────────────────────────────────────────── #}
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-database"></i> Cloud Backups (R2)</div>
<button class="btn btn-ghost btn-sm" onclick="loadR2Backups()"><i class="fas fa-sync-alt"></i> Refresh</button>
</div>
<div id="r2-backup-list">
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div>
</div>
</div>
{# ── Audit modal ── #}
<div id="r2-audit-modal" class="modal-overlay" style="display:none;" onclick="closeR2AuditModal(event)">
<div class="modal-box" onclick="event.stopPropagation()">
<div class="modal-header">
<div class="modal-title"><i class="fas fa-shield-halved" style="color:var(--cyan);"></i> Cloud Backup Audit — R2</div>
<button class="modal-close" onclick="closeR2AuditModal()" title="Close"></button>
</div>
<div id="r2-audit-content"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div></div>
</div>
</div>
{# ── Setup guide ───────────────────────────────────────────────────────────── #}
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-book"></i> R2 Setup Guide</div>
<button class="btn btn-ghost btn-sm" onclick="toggleSetupGuide()"><i class="fas fa-chevron-down" id="guide-chevron"></i></button>
</div>
<div id="setup-guide-body" style="display:none;">
<div class="log-console" style="max-height:none;font-size:12px;line-height:1.8;">
<span style="color:var(--cyan);"># ─── STEP 1: Create an R2 API Token ───────────────────────────────────────</span>
<span style="color:var(--text2);">1. Go to https://dash.cloudflare.com → R2 → "Manage R2 API Tokens"</span>
<span style="color:var(--text2);">2. Click "Create API token"</span>
<span style="color:var(--text2);">3. Set permissions: Object Read &amp; Write</span>
<span style="color:var(--text2);">4. Specify bucket (or leave "All buckets")</span>
<span style="color:var(--text2);">5. Copy Access Key ID and Secret Access Key</span>
<span style="color:var(--cyan);"># ─── STEP 2: Create credentials file on main server ───────────────────────</span>
<span style="color:var(--green);">cat &gt; /root/.r2-credentials &lt;&lt; 'EOF'</span>
<span style="color:var(--yellow);">export AWS_ACCESS_KEY_ID="your-access-key-id"</span>
<span style="color:var(--yellow);">export AWS_SECRET_ACCESS_KEY="your-secret-access-key"</span>
<span style="color:var(--yellow);">export R2_ACCOUNT_ID="35e00c230cc8066252a2d9890b69aea2"</span>
<span style="color:var(--yellow);">export R2_BUCKET_NAME="navitrends-backups"</span>
<span style="color:var(--green);">EOF</span>
<span style="color:var(--green);">chmod 600 /root/.r2-credentials</span>
<span style="color:var(--cyan);"># ─── STEP 3: Set same vars for the platform (Flask app) ───────────────────</span>
<span style="color:var(--text2);">Add to your systemd service or startup script:</span>
<span style="color:var(--green);">Environment="R2_ACCESS_KEY_ID=your-key"</span>
<span style="color:var(--green);">Environment="R2_SECRET_ACCESS_KEY=your-secret"</span>
<span style="color:var(--green);">Environment="R2_BUCKET_NAME=navitrends-backups"</span>
<span style="color:var(--cyan);"># ─── STEP 4: Install boto3 ────────────────────────────────────────────────</span>
<span style="color:var(--green);">pip install boto3 --break-system-packages</span>
<span style="color:var(--cyan);"># ─── STEP 5: (Optional) Install AWS CLI for shell-level uploads ───────────</span>
<span style="color:var(--green);">pip install awscli --break-system-packages</span>
<span style="color:var(--green);">aws configure set default.s3.multipart_threshold 64MB</span>
</div>
</div>
</div>
<script>
function toggleSetupGuide() {
const body = document.getElementById('setup-guide-body');
const icon = document.getElementById('guide-chevron');
const open = body.style.display === 'none';
body.style.display = open ? '' : 'none';
icon.className = open ? 'fas fa-chevron-up' : 'fas fa-chevron-down';
}
async function testR2Connection() {
const badge = document.getElementById('r2-conn-badge');
const msg = document.getElementById('r2-conn-msg');
badge.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing…';
badge.style.cssText = 'background:rgba(59,130,246,0.12);color:var(--accent2);';
try {
const res = await fetch('/api/cloud/r2/test');
const data = await res.json();
if (data.success) {
badge.innerHTML = '✅ Connected';
badge.style.cssText = 'background:rgba(34,197,94,0.12);color:var(--green);';
} else {
badge.innerHTML = '❌ Disconnected';
badge.style.cssText = 'background:rgba(239,68,68,0.12);color:#f87171;';
}
msg.textContent = data.message || '';
} catch(e) {
badge.innerHTML = '❌ Error';
msg.textContent = e.message;
}
}
async function loadR2Stats() {
try {
const res = await fetch('/api/cloud/r2/stats');
const data = await res.json();
document.getElementById('r2-stat-objects').textContent = data.count ?? '—';
document.getElementById('r2-stat-size').textContent = data.total_size_human ?? '—';
} catch(e) {}
}
async function loadR2Backups() {
const list = document.getElementById('r2-backup-list');
list.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div>';
try {
const res = await fetch('/api/cloud/r2/backups');
const data = await res.json();
if (!data.backups || data.backups.length === 0) {
list.innerHTML = '<div class="empty-state"><i class="fas fa-cloud"></i> No cloud backups yet</div>';
return;
}
list.innerHTML = data.backups.map(b => `
<div class="backup-item">
<div style="display:flex;flex-direction:column;gap:2px;">
<span class="backup-name">
<i class="fas fa-cloud" style="color:var(--accent2);font-size:11px;margin-right:6px;"></i>${b.name}
</span>
<span style="font-size:11px;color:var(--text3);font-family:var(--mono);">
${b.size_human} · ${b.last_modified_str}
</span>
</div>
<div class="backup-actions">
<button class="btn btn-ghost btn-sm" onclick="auditR2Backup('${b.key}', this)" title="Audit integrity">
<i class="fas fa-shield-check"></i> Audit
</button>
<button class="btn btn-ghost btn-sm" onclick="restoreFromR2('${b.key}','${b.name}')">
↩ Restore
</button>
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteR2Backup('${b.key}', this)">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`).join('');
} catch(e) {
list.innerHTML = `<div class="empty-state" style="color:#f87171;"><i class="fas fa-circle-exclamation"></i> ${e.message}</div>`;
}
}
// ── Audit ─────────────────────────────────────────────────────────────────────
async function auditR2Backup(key, btn) {
const modal = document.getElementById('r2-audit-modal');
const content = document.getElementById('r2-audit-content');
modal.style.display = '';
content.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>';
try {
const res = await fetch('/api/cloud/r2/audit', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({key})
});
const report = await res.json();
const statusColor = report.status === 'healthy' ? 'var(--green)'
: report.status === 'warning' ? 'var(--yellow)'
: '#f87171';
const statusIcon = report.status === 'healthy' ? 'fa-circle-check'
: report.status === 'warning' ? 'fa-triangle-exclamation'
: 'fa-circle-xmark';
content.innerHTML = `
<div style="margin-bottom:14px;padding:10px 14px;background:var(--bg2);border-radius:8px;display:flex;align-items:center;gap:10px;">
<i class="fas ${statusIcon}" style="color:${statusColor};font-size:18px;"></i>
<div>
<div style="font-weight:600;color:${statusColor};text-transform:uppercase;font-size:12px;">${report.status}</div>
<div style="font-size:11px;color:var(--text3);font-family:var(--mono);">${key}</div>
</div>
${report.size_human ? `<div style="margin-left:auto;font-size:12px;color:var(--text2);">${report.size_human}</div>` : ''}
</div>
${report.checks && report.checks.length ? `
<div style="margin-bottom:10px;">
<div style="font-size:11px;color:var(--text3);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em;">Checks</div>
${report.checks.map(c => `<div style="font-size:12px;padding:3px 0;font-family:var(--mono);">${c}</div>`).join('')}
</div>` : ''}
${report.warnings && report.warnings.length ? `
<div style="margin-bottom:10px;">
<div style="font-size:11px;color:var(--yellow);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em;">Warnings</div>
${report.warnings.map(w => `<div style="font-size:12px;padding:3px 0;font-family:var(--mono);color:var(--yellow);">${w}</div>`).join('')}
</div>` : ''}
${report.errors && report.errors.length ? `
<div style="margin-bottom:10px;">
<div style="font-size:11px;color:#f87171;margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em;">Errors</div>
${report.errors.map(e => `<div style="font-size:12px;padding:3px 0;font-family:var(--mono);color:#f87171;">${e}</div>`).join('')}
</div>` : ''}
${report.stored_hash ? `
<div style="font-size:11px;color:var(--text3);margin-top:8px;font-family:var(--mono);">
SHA256: ${report.stored_hash}
</div>` : ''}
`;
} catch(e) {
content.innerHTML = `<div style="color:#f87171;">❌ Audit request failed: ${e.message}</div>`;
}
}
function closeR2AuditModal(event) {
if (!event || event.target === document.getElementById('r2-audit-modal')) {
document.getElementById('r2-audit-modal').style.display = 'none';
}
}
function restoreFromR2(key, filename) {
window.location = `/restore?source=cloud&file=${encodeURIComponent(filename)}&key=${encodeURIComponent(key)}`;
}
async function deleteR2Backup(key, btn) {
if (!confirm(`Delete from R2?\n${key}`)) return;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const res = await fetch('/api/cloud/r2/delete', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({key})
});
const data = await res.json();
if (data.success) {
loadR2Backups();
loadR2Stats();
} else {
alert('Delete failed: ' + data.message);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-trash"></i>';
}
} catch(e) {
alert(e.message);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-trash"></i>';
}
}
async function uploadToR2() {
const sel = document.getElementById('upload-backup-select');
const val = sel.value;
if (!val) return;
const [source, file] = val.split('::');
const btn = document.getElementById('r2-upload-btn');
const wrapper = document.getElementById('r2-upload-wrapper');
const logEl = document.getElementById('r2-upload-log');
const badge = document.getElementById('r2-upload-badge');
const bar = document.getElementById('r2-progress-bar');
const pct = document.getElementById('r2-progress-pct');
const elapsed = document.getElementById('r2-upload-elapsed');
btn.disabled = true;
wrapper.style.display = '';
logEl.innerHTML = '';
badge.textContent = 'Starting…';
bar.style.width = '0%';
pct.textContent = '0%';
try {
const res = await fetch('/api/cloud/r2/upload', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({backup_file: file, source})
});
const data = await res.json();
if (!data.success) {
badge.textContent = '❌ Error';
logEl.innerHTML += `<div style="color:#f87171;">${data.message}</div>`;
btn.disabled = false;
return;
}
const jobId = data.job_id;
const poll = setInterval(async () => {
try {
const s = await fetch(`/api/cloud/r2/upload/status/${jobId}`);
const job = await s.json();
logEl.innerHTML = job.log.map(l =>
`<div>${l.replace(/✅/g,'<span style="color:var(--green)">✅</span>')
.replace(/❌/g,'<span style="color:#f87171;">❌</span>')
.replace(/⬆️/g,'<span style="color:var(--accent2)">⬆️</span>')}</div>`
).join('');
logEl.scrollTop = logEl.scrollHeight;
bar.style.width = job.progress + '%';
pct.textContent = job.progress + '%';
elapsed.textContent = `Elapsed: ${job.elapsed}s`;
if (job.status === 'done') {
clearInterval(poll);
badge.textContent = '✅ Uploaded';
badge.style.cssText = 'background:rgba(34,197,94,0.12);color:var(--green);';
btn.disabled = false;
loadR2Backups();
loadR2Stats();
} else if (job.status === 'error') {
clearInterval(poll);
badge.textContent = '❌ Failed';
badge.style.cssText = 'background:rgba(239,68,68,0.12);color:#f87171;';
btn.disabled = false;
}
} catch(e) { clearInterval(poll); }
}, 1200);
} catch(e) {
badge.textContent = '❌ Error';
logEl.innerHTML += `<div style="color:#f87171;">${e.message}</div>`;
btn.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', () => {
testR2Connection();
loadR2Stats();
loadR2Backups();
});
</script>
{% endblock %}

View File

@@ -46,6 +46,10 @@
<div class="stat-card"><div class="stat-number" id="stat-users">{{ users|length }}</div><div class="stat-label">Linux Users</div></div>
<div class="stat-card"><div class="stat-number" id="stat-local-bk">{{ backups|length }}</div><div class="stat-label">Local Backups</div></div>
<div class="stat-card"><div class="stat-number" id="stat-vm-bk">{{ vm_backups|length }}</div><div class="stat-label">VM Backups</div></div>
<div class="stat-card" style="cursor:pointer;" onclick="window.location='/cloud'">
<div class="stat-number" id="stat-cloud-bk" style="color:var(--accent2);"></div>
<div class="stat-label">☁ Cloud Backups</div>
</div>
</div>
</div>
@@ -106,16 +110,26 @@
</div>
</div>
{% if not running_on_main %}
<script>
(function () {
// ── Helper: build a container row identical to the Jinja template ──
// ── Load cloud backup count async ──────────────────────────────────────────
async function loadCloudCount() {
try {
const res = await fetch('/api/cloud/r2/stats');
const data = await res.json();
const el = document.getElementById('stat-cloud-bk');
if (el) el.textContent = data.count ?? '—';
} catch(e) {}
}
loadCloudCount();
{% if not running_on_main %}
// ── Helper: build a container row ──────────────────────────────────────────
function buildRow(c) {
const isUp = c.status && c.status.includes('Up');
const badge = isUp
? `<span class="badge badge-run">Running</span>`
: `<span class="badge badge-stop">Stopped</span>`;
return `
<tr data-ctr="${c.name}">
<td class="ct-name">${c.name}</td>
@@ -136,45 +150,31 @@
<td class="col-extra app-extra ct-image" style="display:none;">${c.image || ''}</td>
<td class="col-extra app-extra ct-ports" style="display:none;">${c.ports || '—'}</td>
<td><div class="action-btns">
<button class="ctr-action-btn restart" title="Restart" onclick="ctrAction('${c.name}','restart',this)">
<i class="fas fa-rotate-right"></i>
</button>
<button class="ctr-action-btn stop" title="Stop" onclick="ctrAction('${c.name}','stop',this)">
<i class="fas fa-stop"></i>
</button>
<button class="ctr-action-btn start" title="Start" onclick="ctrAction('${c.name}','start',this)">
<i class="fas fa-play"></i>
</button>
<button class="ctr-action-btn restart" title="Restart" onclick="ctrAction('${c.name}','restart',this)"><i class="fas fa-rotate-right"></i></button>
<button class="ctr-action-btn stop" title="Stop" onclick="ctrAction('${c.name}','stop',this)"><i class="fas fa-stop"></i></button>
<button class="ctr-action-btn start" title="Start" onclick="ctrAction('${c.name}','start',this)"><i class="fas fa-play"></i></button>
</div></td>
</tr>`;
}
// ── Populate metrics cards ──
function applySystem(s) {
if (!s) return;
const cpu = parseFloat(s.cpu_pct) || 0;
document.getElementById('m-cpu').innerHTML = `${cpu.toFixed(1)}<span>%</span>`;
document.getElementById('m-mem').textContent = s.memory || '—';
document.getElementById('m-disk').textContent = s.disk || '—';
document.getElementById('m-load').textContent = s.load || '—';
document.getElementById('g-cpu').style.width = `${Math.min(cpu, 100)}%`;
document.getElementById('g-mem').style.width = `${Math.min(parseFloat(s.mem_pct) || 0, 100)}%`;
document.getElementById('g-disk').style.width = `${Math.min(parseFloat(s.disk_pct) || 0, 100)}%`;
if (s.docker_v) {
const meta = document.getElementById('overview-meta');
if (meta) meta.textContent = `Docker ${s.docker_v} · {{ main_server }}`;
}
}
// ── Populate overview stat numbers ──
function applyStats(data) {
const set = (id, val) => {
const el = document.getElementById(id);
if (el && val !== undefined && val !== null) el.textContent = val;
};
const set = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined && val !== null) el.textContent = val; };
set('stat-total', data.containers ? data.containers.length : undefined);
set('stat-running', data.running_count);
set('stat-users', data.user_count);
@@ -182,52 +182,36 @@
set('stat-vm-bk', data.vm_backups);
}
// ── Populate the containers table ──
function applyContainers(containers) {
if (!containers || !containers.length) return;
const tbody = document.getElementById('app-containers-body');
if (!tbody) return;
tbody.innerHTML = containers.map(buildRow).join('');
// Re-apply column visibility in case "Show more" was already toggled
const extras = tbody.querySelectorAll('.app-extra');
const btn = document.getElementById('app-toggle-btn');
if (btn && btn.dataset.expanded === 'true') {
extras.forEach(el => el.style.display = '');
}
// Kick stats refresh if a global function exists (from base template)
if (typeof refreshContainerStats === 'function') {
refreshContainerStats();
}
if (btn && btn.dataset.expanded === 'true') extras.forEach(el => el.style.display = '');
if (typeof refreshContainerStats === 'function') refreshContainerStats();
}
// ── Main async loader ──
async function loadDashboardAsync() {
try {
const res = await fetch('/api/dashboard');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
applySystem(data.system);
applyStats(data);
applyContainers(data.containers);
} catch (err) {
console.error('[dashboard] async load failed:', err);
}
}
// Run immediately on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadDashboardAsync);
} else {
loadDashboardAsync();
}
{% endif %}
})();
</script>
{% endif %}
{% endblock %}

View File

@@ -10,24 +10,54 @@
<div class="form-section-title">STEP 1 — SELECT BACKUP SOURCE</div>
<div class="radio-group">
<label class="radio-card">
<input type="radio" name="backup_source" value="local" checked onchange="updateBackupList()">
<input type="radio" name="backup_source" value="local" checked onchange="nvUpdateSource()">
<div class="radio-body"><span class="radio-icon">🖥️</span><div><div class="radio-label">Main Server</div><div class="radio-desc"><code>/root/backups/</code></div></div></div>
</label>
<label class="radio-card">
<input type="radio" name="backup_source" value="vm" onchange="updateBackupList()">
<input type="radio" name="backup_source" value="vm" onchange="nvUpdateSource()">
<div class="radio-body"><span class="radio-icon">💾</span><div><div class="radio-label">VM Backup Server</div><div class="radio-desc"><code>/backups/main-server/</code></div></div></div>
</label>
<label class="radio-card">
<input type="radio" name="backup_source" value="cloud" onchange="nvUpdateSource()">
<div class="radio-body"><span class="radio-icon">☁️</span><div><div class="radio-label">Cloudflare R2</div><div class="radio-desc">cloud storage</div></div></div>
</label>
</div>
{# Separate selects — only one shown at a time #}
<div class="form-group" style="margin-top:14px; max-width:500px;">
<label class="form-label">BACKUP FILE</label>
<select id="backup-file-select" class="form-input">
<optgroup label="Main Server" id="local-options">
{% for b in backups %}<option value="{{ b }}" data-source="local">{{ b }}</option>{% else %}<option disabled>No local backups</option>{% endfor %}
</optgroup>
<optgroup label="VM Backups" id="vm-options" style="display:none;">
{% for b in vm_backups %}<option value="{{ b }}" data-source="vm">{{ b }}</option>{% else %}<option disabled>No VM backups</option>{% endfor %}
</optgroup>
{# Local select #}
<select id="select-local" class="form-input">
{% for b in backups %}
<option value="{{ b }}">{{ b }}</option>
{% else %}
<option disabled>No local backups</option>
{% endfor %}
</select>
{# VM select #}
<select id="select-vm" class="form-input" style="display:none;">
{% for b in vm_backups %}
<option value="{{ b }}">{{ b }}</option>
{% else %}
<option disabled>No VM backups</option>
{% endfor %}
</select>
{# Cloud select — populated via JS #}
<select id="select-cloud" class="form-input" style="display:none;">
<option disabled selected>Loading cloud backups…</option>
</select>
</div>
{# Cloud info notice #}
<div id="cloud-notice" style="display:none;margin-top:10px;padding:10px 14px;
background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.2);
border-radius:8px;font-size:12px;color:var(--accent2);">
<i class="fas fa-info-circle"></i>
The backup will be downloaded from R2 to the server first, then restored.
This may take a few minutes depending on the file size (typically 12 GB).
</div>
</div>
@@ -84,7 +114,7 @@
</div>
<div class="form-section">
<button class="btn btn-danger btn-lg" onclick="launchRestore()" id="restore-btn">
<button class="btn btn-danger btn-lg" onclick="nvLaunchRestore()" id="restore-btn">
<i class="fas fa-play"></i> Start Restore
</button>
<p style="color:var(--text3); font-size:12px; margin-top:8px;">⚠ Healthy running containers are skipped.</p>
@@ -100,7 +130,158 @@
<div style="color:var(--text3);font-size:11px;margin-top:6px;font-family:var(--mono);" id="restore-elapsed"></div>
</div>
</div>
<script>
window.restorePrefill = {{ restore_prefill|tojson }};
// ── Cloud backup data (key lookup) ────────────────────────────────────────────
let _cloudBackups = [];
async function loadCloudSelect() {
const sel = document.getElementById('select-cloud');
try {
const res = await fetch('/api/cloud/r2/backups');
const data = await res.json();
// Only .tar.gz — no .sha256
_cloudBackups = (data.backups || []).filter(b => b.name.endsWith('.tar.gz'));
if (_cloudBackups.length === 0) {
sel.innerHTML = '<option disabled>No cloud backups found</option>';
} else {
sel.innerHTML = _cloudBackups.map(b =>
`<option value="${b.name}" data-key="${b.key}">${b.name} (${b.size_human})</option>`
).join('');
}
} catch(e) {
sel.innerHTML = '<option disabled>Failed to load R2 backups</option>';
}
}
// ── Show/hide correct select based on source radio ────────────────────────────
function nvUpdateSource() {
const source = document.querySelector('input[name="backup_source"]:checked')?.value;
document.getElementById('select-local').style.display = source === 'local' ? '' : 'none';
document.getElementById('select-vm').style.display = source === 'vm' ? '' : 'none';
document.getElementById('select-cloud').style.display = source === 'cloud' ? '' : 'none';
document.getElementById('cloud-notice').style.display = source === 'cloud' ? '' : 'none';
}
// ── Get currently selected file + source ─────────────────────────────────────
function nvGetSelection() {
const source = document.querySelector('input[name="backup_source"]:checked')?.value || 'local';
let file = '', key = '';
if (source === 'local') {
file = document.getElementById('select-local').value;
} else if (source === 'vm') {
file = document.getElementById('select-vm').value;
} else {
const sel = document.getElementById('select-cloud');
file = sel.value;
const opt = sel.options[sel.selectedIndex];
key = opt?.dataset?.key || ('backups/' + file);
}
return { source, file, key };
}
// ── Launch restore ────────────────────────────────────────────────────────────
async function nvLaunchRestore() {
const { source, file, key } = nvGetSelection();
if (!file) { alert('Please select a backup file.'); return; }
const target = document.querySelector('input[name="restore_target"]:checked')?.value || 'local';
const authMethod = document.querySelector('input[name="auth_method"]:checked')?.value || 'key';
const body = {
backup_source: source,
backup_file: file,
cloud_key: key,
target,
remote_ip: document.getElementById('remote-ip')?.value || '',
remote_port: document.getElementById('remote-port')?.value || '22',
remote_user: document.getElementById('remote-user')?.value || 'root',
auth_method: authMethod,
ssh_key_path: document.getElementById('ssh-key-path')?.value || '',
ssh_password: document.getElementById('ssh-password')?.value || '',
};
const btn = document.getElementById('restore-btn');
const wrapper = document.getElementById('restore-log-wrapper');
const logEl = document.getElementById('restore-log');
const badge = document.getElementById('restore-status-badge');
const elapsed = document.getElementById('restore-elapsed');
btn.disabled = true;
wrapper.style.display = '';
logEl.innerHTML = source === 'cloud'
? '<div style="color:var(--accent2)">☁️ Downloading backup from R2, please wait…</div>'
: '<div style="color:var(--accent2)">🚀 Starting restore…</div>';
badge.textContent = 'Running…';
badge.style.cssText = 'background:rgba(59,130,246,0.15);color:var(--accent2);';
try {
const res = await fetch('/restore/start', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
const data = await res.json();
if (data.error) {
logEl.innerHTML += `<div style="color:#f87171;">❌ ${data.error}</div>`;
badge.textContent = '❌ Error';
badge.style.cssText = 'background:rgba(239,68,68,0.12);color:#f87171;';
btn.disabled = false;
return;
}
const jobId = data.job_id;
const poll = setInterval(async () => {
try {
const s = await fetch(`/restore/status/${jobId}`);
const job = await s.json();
logEl.innerHTML = job.log.map(l => `<div>${l}</div>`).join('');
logEl.scrollTop = logEl.scrollHeight;
elapsed.textContent = `Elapsed: ${job.elapsed}s`;
if (job.status === 'done') {
clearInterval(poll);
badge.textContent = '✅ Done';
badge.style.cssText = 'background:rgba(34,197,94,0.12);color:var(--green);';
btn.disabled = false;
} else if (job.status === 'error') {
clearInterval(poll);
badge.textContent = '❌ Failed';
badge.style.cssText = 'background:rgba(239,68,68,0.12);color:#f87171;';
btn.disabled = false;
}
} catch(e) { clearInterval(poll); }
}, 1500);
} catch(e) {
logEl.innerHTML += `<div style="color:#f87171;">❌ ${e.message}</div>`;
btn.disabled = false;
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadCloudSelect();
// Handle prefill from URL params (e.g. clicking Restore from backups page)
const pf = window.restorePrefill || {};
if (pf.source) {
const radio = document.querySelector(`input[name="backup_source"][value="${pf.source}"]`);
if (radio) { radio.checked = true; nvUpdateSource(); }
}
if (pf.file) {
// Pre-select file after a short delay to let cloud options load
setTimeout(() => {
const source = pf.source || 'local';
const selId = source === 'cloud' ? 'select-cloud' : source === 'vm' ? 'select-vm' : 'select-local';
const sel = document.getElementById(selId);
if (sel) {
for (let o of sel.options) {
if (o.value === pf.file) { o.selected = true; break; }
}
}
}, 600);
}
});
</script>
{% endblock %}
{% endblock %}