Sync from main server - 2026-05-13 01:06:32
This commit is contained in:
414
platform/templates/pages/cloud.html
Normal file
414
platform/templates/pages/cloud.html
Normal 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 & 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 > /root/.r2-credentials << '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 %}
|
||||
Reference in New Issue
Block a user