Sync from main server - 2026-05-13 01:06:32
This commit is contained in:
@@ -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 %}
|
||||
Reference in New Issue
Block a user