/api/backups returned plain filename strings while pages/backups.html's server-rendered dots came from get_local_backups_with_status()/ get_vm_backups_with_status(). platform.js's DOMContentLoaded handler always calls refreshBackupsList() (backups.html has #backup-history-list, which triggers it unconditionally), which re-fetches /api/backups and rebuilds #local-backup-list/#vm-backup-list via renderBackupList() — a version with zero knowledge of status, wiping out the dots seconds after initial paint. Switch /api/backups to the *_with_status() variants and have renderBackupList() render the same status-dot markup as the Jinja template, so the dot is sourced from persisted backup metadata on every render path, not just the first one.
761 lines
31 KiB
JavaScript
761 lines
31 KiB
JavaScript
const extraState = { app: false, all: false };
|
|
let selectedUser = null;
|
|
let manualBackupJobId = null;
|
|
let manualBackupPoll = null;
|
|
let currentJobId = null;
|
|
let pollInterval = null;
|
|
let allContainersCache = [];
|
|
let allContainersStats = {};
|
|
|
|
function el(id) { return document.getElementById(id); }
|
|
|
|
function escapeHtml(s) {
|
|
if (s == null) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function auditScoreBlockTierClass(tier) {
|
|
const t = String(tier || 'fair');
|
|
if (t === 'excellent' || t === 'good') return 'audit-tier-good';
|
|
if (t === 'fair') return 'audit-tier-fair';
|
|
if (t === 'poor') return 'audit-tier-poor';
|
|
return 'audit-tier-critical';
|
|
}
|
|
|
|
function renderAuditModalContent(d, filename) {
|
|
if (d.error) {
|
|
return `<div class="empty-state" style="color:var(--red)">${escapeHtml(d.error)}</div>
|
|
<div class="audit-footer"><button type="button" class="btn btn-ghost btn-sm" onclick="closeAuditModal()">Close</button></div>`;
|
|
}
|
|
|
|
const tierCls = auditScoreBlockTierClass(d.health_tier);
|
|
const score = Math.max(0, Math.min(100, Number(d.score) || 0));
|
|
const fileSize = d.file_size_display
|
|
? escapeHtml(d.file_size_display)
|
|
: (d.file_size_bytes != null ? escapeHtml(String(d.file_size_bytes)) + ' B' : '—');
|
|
const fname = escapeHtml(d.backup_file || filename);
|
|
const summary = escapeHtml(d.summary || '');
|
|
const healthLabel = escapeHtml(d.health_label || '');
|
|
const badgeSafe = !!d.ok;
|
|
const badgeText = badgeSafe ? '✓ Safe to restore' : '✗ Review before restore';
|
|
const badgeClass = badgeSafe ? 'audit-health-badge audit-health-badge-safe' : 'audit-health-badge audit-health-badge-unsafe';
|
|
|
|
const checksHtml = (d.checks || []).map((c) => {
|
|
const st = c.status === 'fail' ? 'fail' : c.status === 'warn' ? 'warn' : 'pass';
|
|
const icon = st === 'pass' ? '✓' : st === 'warn' ? '⚠' : '✗';
|
|
const iconCls = `audit-check-icon audit-check-icon-${st}`;
|
|
const detail = escapeHtml(c.detail || '');
|
|
const moreLines = Array.isArray(c.more) ? c.more : [];
|
|
const moreBlock = moreLines.length
|
|
? `<ul class="audit-check-more">${moreLines.map((line) => `<li>${escapeHtml(line)}</li>`).join('')}</ul>`
|
|
: '';
|
|
|
|
return `<details class="audit-check-row status-${st}">
|
|
<summary class="audit-check-summary">
|
|
<span class="${iconCls}">${icon}</span>
|
|
<span class="audit-check-name">${escapeHtml(c.name)}</span>
|
|
<span class="audit-check-pill audit-check-pill-${st}">${st}</span>
|
|
<span class="audit-check-chevron" aria-hidden="true"></span>
|
|
</summary>
|
|
<div class="audit-check-body">
|
|
<p class="audit-check-detail">${detail}</p>
|
|
${moreBlock}
|
|
</div>
|
|
</details>`;
|
|
}).join('');
|
|
|
|
return `<div class="audit-file-label">File</div>
|
|
<code class="audit-filename">${fname}</code>
|
|
<div class="audit-file-meta"><span class="audit-file-meta-label">Size</span><span class="audit-file-meta-value">${fileSize}</span></div>
|
|
<div class="audit-score-block ${tierCls}">
|
|
<div class="audit-score-numcol">
|
|
<div class="audit-score-number">${score}</div>
|
|
<div class="audit-score-label">Score</div>
|
|
</div>
|
|
<div class="audit-score-info">
|
|
<div class="audit-health-row">
|
|
<span class="audit-health-label">${healthLabel}</span>
|
|
<span class="${badgeClass}">${badgeText}</span>
|
|
</div>
|
|
<p class="audit-summary">${summary}</p>
|
|
<div class="audit-progress" role="progressbar" aria-valuenow="${score}" aria-valuemin="0" aria-valuemax="100"><div class="audit-progress-fill ${tierCls}" style="width:${score}%"></div></div>
|
|
</div>
|
|
</div>
|
|
<p class="audit-checks-hint">Expand a row to see exactly what was verified (size, checksum, archive test, paths, …).</p>
|
|
<div class="audit-checks">${checksHtml}</div>
|
|
<div class="audit-footer">
|
|
<button type="button" class="btn btn-ghost btn-sm" onclick="closeAuditModal()">Close</button>
|
|
<button type="button" class="btn btn-primary btn-sm" onclick="quickRestoreFromAuditModal()"><i class="fas fa-rotate-left"></i> Restore this backup</button>
|
|
</div>`;
|
|
}
|
|
|
|
function quickRestoreFromAuditModal() {
|
|
const modal = el('audit-modal');
|
|
if (!modal) return;
|
|
const s = modal.dataset.auditSource || '';
|
|
const f = modal.dataset.auditFile || '';
|
|
modal.style.display = 'none';
|
|
if (s && f) quickRestore(s, f);
|
|
}
|
|
|
|
function setText(id, value) {
|
|
const node = el(id);
|
|
if (node) node.textContent = value;
|
|
}
|
|
|
|
function toggleExtraColumns(prefix) {
|
|
extraState[prefix] = !extraState[prefix];
|
|
const show = extraState[prefix];
|
|
document.querySelectorAll(`.${prefix}-extra`).forEach((node) => {
|
|
node.style.display = show ? "" : "none";
|
|
});
|
|
const btn = el(`${prefix}-toggle-btn`);
|
|
if (btn) {
|
|
btn.innerHTML = show
|
|
? '<i class="fas fa-eye-slash"></i> Show less'
|
|
: '<i class="fas fa-eye"></i> Show more';
|
|
}
|
|
}
|
|
|
|
/** Sidebar counts (present in base.html on every page). */
|
|
async function refreshSidebarNavBadges() {
|
|
const setBadges = (containerCount, userCount) => {
|
|
const c = el('nav-badge-containers');
|
|
const u = el('nav-badge-users');
|
|
if (c) c.textContent = containerCount != null ? containerCount : '—';
|
|
if (u) u.textContent = userCount != null ? userCount : '—';
|
|
};
|
|
try {
|
|
const r = await fetch('/api/nav-summary', { credentials: 'same-origin' });
|
|
const d = r.ok ? await r.json() : null;
|
|
const cc = d && Number.isFinite(Number(d.container_count)) ? Number(d.container_count) : null;
|
|
const uc = d && Number.isFinite(Number(d.user_count)) ? Number(d.user_count) : null;
|
|
setBadges(cc, uc);
|
|
} catch (_) {
|
|
setBadges(null, null);
|
|
}
|
|
}
|
|
|
|
async function checkServerStatus() {
|
|
try {
|
|
const r = await fetch('/server/status');
|
|
const d = await r.json();
|
|
const dot = el('pulse-dot');
|
|
const text = el('server-status-text');
|
|
if (!dot || !text) return;
|
|
if (d.status === 'online') {
|
|
dot.className = 'pulse-dot online';
|
|
text.textContent = 'Online';
|
|
} else {
|
|
dot.className = 'pulse-dot offline';
|
|
text.textContent = 'Offline';
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
async function refreshSystemMetrics() {
|
|
try {
|
|
const r = await fetch('/api/system');
|
|
const d = await r.json();
|
|
if (el('m-cpu')) el('m-cpu').innerHTML = d.cpu_pct + '<span>%</span>';
|
|
setText('m-mem', d.memory);
|
|
setText('m-disk', d.disk);
|
|
setText('m-load', d.load);
|
|
if (el('g-cpu')) el('g-cpu').style.width = Math.min(parseFloat(d.cpu_pct) || 0, 100) + '%';
|
|
if (el('g-mem')) el('g-mem').style.width = Math.min(parseFloat(d.mem_pct) || 0, 100) + '%';
|
|
if (el('g-disk')) el('g-disk').style.width = Math.min(parseFloat(d.disk_pct) || 0, 100) + '%';
|
|
setText('uptime-chip', d.uptime);
|
|
if (el('settings-uptime')) el('settings-uptime').value = d.uptime;
|
|
if (d.hostname && el('this-server-desc')) el('this-server-desc').textContent = d.hostname;
|
|
} catch (_) {}
|
|
}
|
|
|
|
async function refreshContainerStats() {
|
|
try {
|
|
const r = await fetch('/api/stats');
|
|
const stats = await r.json();
|
|
document.querySelectorAll('[data-stat]').forEach((node) => {
|
|
const name = node.dataset.ctr;
|
|
const stat = node.dataset.stat;
|
|
const s = stats[name];
|
|
if (!s) return;
|
|
if (stat === 'cpu') node.textContent = s.cpu || '—';
|
|
if (stat === 'net') node.textContent = s.net || '—';
|
|
if (stat === 'block') node.textContent = s.block || '—';
|
|
if (stat === 'mem_pct') node.textContent = s.mem_pct || '—';
|
|
if (stat === 'mem_bar') {
|
|
const pct = parseFloat(s.mem_pct) || 0;
|
|
node.style.width = Math.min(pct, 100) + '%';
|
|
node.className = 'stat-bar-fill' + (pct > 85 ? ' crit' : pct > 65 ? ' warn' : '');
|
|
}
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
|
|
function statusBadgeHTML(status) {
|
|
if (status === 'running') return '<span class="badge badge-run">Running</span>';
|
|
if (status === 'stopped') return '<span class="badge badge-stop">Stopped</span>';
|
|
return '<span class="badge" style="background:rgba(107,114,128,0.15);color:var(--text2)">Unknown</span>';
|
|
}
|
|
|
|
function updateContainerStatusBadge(name, status) {
|
|
document.querySelectorAll(`.ctr-status-cell[data-ctr="${name}"]`).forEach((cell) => {
|
|
cell.innerHTML = statusBadgeHTML(status);
|
|
});
|
|
document.querySelectorAll(`#all-containers-body tr[data-ctr="${name}"] .ctr-status-cell`).forEach((cell) => {
|
|
cell.innerHTML = statusBadgeHTML(status);
|
|
});
|
|
}
|
|
|
|
async function ctrAction(name, action, btn) {
|
|
const origHTML = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
|
try {
|
|
const r = await fetch('/api/container/action', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, action })
|
|
});
|
|
const d = await r.json();
|
|
if (d.new_status) updateContainerStatusBadge(name, d.new_status);
|
|
btn.innerHTML = d.success ? '<i class="fas fa-check"></i>' : '<i class="fas fa-times"></i>';
|
|
setTimeout(() => {
|
|
btn.innerHTML = origHTML;
|
|
btn.disabled = false;
|
|
setTimeout(() => {
|
|
refreshContainerStats();
|
|
if (el('all-containers-body')) loadAllContainers();
|
|
}, 1500);
|
|
}, 1200);
|
|
} catch (_) {
|
|
btn.innerHTML = origHTML;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function buildActionBtns(name) {
|
|
return `<div class="action-btns">
|
|
<button class="ctr-action-btn restart" title="Restart" onclick="ctrAction('${name}','restart',this)"><i class="fas fa-rotate-right"></i></button>
|
|
<button class="ctr-action-btn stop" title="Stop" onclick="ctrAction('${name}','stop',this)"><i class="fas fa-stop"></i></button>
|
|
<button class="ctr-action-btn start" title="Start" onclick="ctrAction('${name}','start',this)"><i class="fas fa-play"></i></button>
|
|
</div>`;
|
|
}
|
|
|
|
function parseCpuPct(cpuStr) {
|
|
if (!cpuStr || cpuStr === '—') return 0;
|
|
return parseFloat(String(cpuStr).replace('%', '')) || 0;
|
|
}
|
|
|
|
function parseCreatedTs(createdAt) {
|
|
if (!createdAt) return 0;
|
|
const t = Date.parse(createdAt);
|
|
return Number.isNaN(t) ? 0 : t;
|
|
}
|
|
|
|
function parseUptimeSeconds(uptimeText) {
|
|
if (!uptimeText || !uptimeText.startsWith('Up')) return 0;
|
|
const text = uptimeText.replace(/^Up\s+/i, '').toLowerCase();
|
|
let sec = 0;
|
|
const dayM = text.match(/(\d+)\s*day/);
|
|
const hrM = text.match(/(\d+)\s*hour/);
|
|
const minM = text.match(/(\d+)\s*minute/);
|
|
const secM = text.match(/(\d+)\s*second/);
|
|
if (dayM) sec += parseInt(dayM[1], 10) * 86400;
|
|
if (hrM) sec += parseInt(hrM[1], 10) * 3600;
|
|
if (minM) sec += parseInt(minM[1], 10) * 60;
|
|
if (secM) sec += parseInt(secM[1], 10);
|
|
return sec;
|
|
}
|
|
|
|
function filterAndSortContainers(containers, stats, query, sortBy) {
|
|
let list = [...containers];
|
|
const q = (query || '').trim().toLowerCase();
|
|
if (q) {
|
|
list = list.filter((c) =>
|
|
(c.name || '').toLowerCase().includes(q) ||
|
|
(c.image || '').toLowerCase().includes(q) ||
|
|
(c.owner || '').toLowerCase().includes(q) ||
|
|
(c.ports || '').toLowerCase().includes(q) ||
|
|
(c.uptime || '').toLowerCase().includes(q)
|
|
);
|
|
}
|
|
list.sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'cpu_desc':
|
|
return parseCpuPct((stats[b.name] || {}).cpu) - parseCpuPct((stats[a.name] || {}).cpu);
|
|
case 'cpu_asc':
|
|
return parseCpuPct((stats[a.name] || {}).cpu) - parseCpuPct((stats[b.name] || {}).cpu);
|
|
case 'created_desc':
|
|
return parseCreatedTs(b.created_at) - parseCreatedTs(a.created_at);
|
|
case 'created_asc':
|
|
return parseCreatedTs(a.created_at) - parseCreatedTs(b.created_at);
|
|
case 'uptime_desc':
|
|
return parseUptimeSeconds(b.uptime) - parseUptimeSeconds(a.uptime);
|
|
case 'name':
|
|
default:
|
|
return (a.name || '').localeCompare(b.name || '');
|
|
}
|
|
});
|
|
return list;
|
|
}
|
|
|
|
function renderAllContainersRows(containers, stats) {
|
|
const body = el('all-containers-body');
|
|
if (!body) return;
|
|
const filterMeta = el('ctr-filter-meta');
|
|
const total = allContainersCache.length;
|
|
if (filterMeta) {
|
|
filterMeta.textContent = containers.length === total
|
|
? `${total} shown`
|
|
: `${containers.length} of ${total} shown`;
|
|
}
|
|
if (!containers.length) {
|
|
body.innerHTML = `<tr><td colspan="11"><div class="empty-state"><i class="fas fa-inbox"></i>${total ? 'No matches' : 'No containers'}</div></td></tr>`;
|
|
return;
|
|
}
|
|
const showExtra = extraState.all;
|
|
const ed = showExtra ? '' : 'display:none;';
|
|
body.innerHTML = containers.map((c) => {
|
|
const up = c.running || (c.status && c.status.includes('Up'));
|
|
const s = stats[c.name] || {};
|
|
const pct = parseFloat(s.mem_pct) || 0;
|
|
const cls = pct > 85 ? 'crit' : pct > 65 ? 'warn' : '';
|
|
const uptimeCls = up ? 'ct-uptime' : 'ct-uptime stopped';
|
|
const uptimeText = c.uptime || (up ? c.status : c.status || '—');
|
|
return `<tr data-ctr="${escapeHtml(c.name)}">
|
|
<td class="ct-name">${escapeHtml(c.name)}</td>
|
|
<td class="ct-owner">${escapeHtml(c.owner || 'root')}</td>
|
|
<td class="ctr-status-cell" data-ctr="${escapeHtml(c.name)}">${up ? '<span class="badge badge-run">Running</span>' : '<span class="badge badge-stop">Stopped</span>'}</td>
|
|
<td><span class="${uptimeCls}">${escapeHtml(uptimeText)}</span></td>
|
|
<td><span class="stat-pct">${escapeHtml(s.cpu || '—')}</span></td>
|
|
<td><div class="stat-bar-wrap"><div class="stat-bar-bg"><div class="stat-bar-fill ${cls}" style="width:${Math.min(pct, 100)}%"></div></div><span class="stat-pct">${escapeHtml(s.mem_pct || '—')}</span></div></td>
|
|
<td><span class="stat-pct" style="color:var(--cyan)">${escapeHtml(s.net || '—')}</span></td>
|
|
<td class="col-extra all-extra" style="${ed}"><span class="stat-pct" style="color:var(--yellow)">${escapeHtml(s.block || '—')}</span></td>
|
|
<td class="col-extra all-extra ct-image" style="${ed}">${escapeHtml(c.image)}</td>
|
|
<td class="col-extra all-extra ct-ports" style="${ed}">${escapeHtml(c.ports || '—')}</td>
|
|
<td>${buildActionBtns(c.name)}</td></tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
function applyContainersFilter() {
|
|
const query = el('ctr-search')?.value || '';
|
|
const sortBy = el('ctr-sort')?.value || 'name';
|
|
const filtered = filterAndSortContainers(allContainersCache, allContainersStats, query, sortBy);
|
|
renderAllContainersRows(filtered, allContainersStats);
|
|
}
|
|
|
|
async function loadAllContainers() {
|
|
const body = el('all-containers-body');
|
|
if (!body) return;
|
|
const meta = el('all-ctr-meta');
|
|
body.innerHTML = '<tr><td colspan="11"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div></td></tr>';
|
|
try {
|
|
const [ctrRes, statRes] = await Promise.all([fetch('/api/containers/all'), fetch('/api/stats')]);
|
|
const { containers, running } = await ctrRes.json();
|
|
allContainersCache = containers || [];
|
|
allContainersStats = await statRes.json();
|
|
if (meta) meta.textContent = `${allContainersCache.length} total · ${running} running`;
|
|
if (el('nav-badge-containers')) el('nav-badge-containers').textContent = allContainersCache.length;
|
|
applyContainersFilter();
|
|
} catch (e) {
|
|
body.innerHTML = `<tr><td colspan="11"><div class="empty-state">${escapeHtml(String(e))}</div></td></tr>`;
|
|
}
|
|
}
|
|
|
|
window.applyContainersFilter = applyContainersFilter;
|
|
|
|
async function refreshBackupsList() {
|
|
try {
|
|
const r = await fetch('/api/backups');
|
|
const d = await r.json();
|
|
renderBackupList(d.local, 'local-backup-list', 'local');
|
|
renderBackupList(d.vm, 'vm-backup-list', 'vm');
|
|
if (el('stat-local-bk')) el('stat-local-bk').textContent = d.local.length;
|
|
if (el('stat-vm-bk')) el('stat-vm-bk').textContent = d.vm.length;
|
|
if (el('local-options')) el('local-options').innerHTML = d.local.length ? d.local.map((b) => `<option value="${b.name}" data-source="local">${b.name}</option>`).join('') : '<option disabled>No local backups</option>';
|
|
if (el('vm-options')) el('vm-options').innerHTML = d.vm.length ? d.vm.map((b) => `<option value="${b.name}" data-source="vm">${b.name}</option>`).join('') : '<option disabled>No VM backups</option>';
|
|
} catch (_) {}
|
|
}
|
|
|
|
function jsStr(s) {
|
|
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
}
|
|
|
|
function renderBackupList(items, id, source) {
|
|
const node = el(id);
|
|
if (!node) return;
|
|
if (!items || !items.length) {
|
|
node.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>';
|
|
return;
|
|
}
|
|
node.innerHTML = items.map((b) => {
|
|
const name = b.name;
|
|
const safe = jsStr(name);
|
|
const status = b.status || 'unknown';
|
|
const titleParts = [status];
|
|
if (b.apps && b.apps.length) titleParts.push(`apps: ${b.apps.join(', ')}`);
|
|
if (b.size_human) titleParts.push(b.size_human);
|
|
const title = escapeHtml(titleParts.join(' · '));
|
|
const uploadBtn = source === 'local' && typeof quickUploadR2 === 'function'
|
|
? `<button class="btn btn-ghost btn-sm" onclick="quickUploadR2('${safe}',this)" title="Upload to R2"><i class="fas fa-cloud-upload-alt"></i></button>`
|
|
: '';
|
|
const detailsBtn = typeof showBackupDetails === 'function'
|
|
? `<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('${source}','${safe}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>`
|
|
: '';
|
|
return `
|
|
<div class="backup-item" id="bk-item-${source}-${name.replace(/[^a-z0-9]/gi, '_')}">
|
|
<div class="backup-item-main">
|
|
<span class="status-dot status-dot-${status}" title="${title}"></span>
|
|
<span class="backup-name">${escapeHtml(name)}</span>
|
|
</div>
|
|
<div class="backup-actions">
|
|
${detailsBtn}
|
|
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('${source}','${safe}',this)"><i class="fas fa-shield-check"></i> Audit</button>
|
|
<button class="btn btn-ghost btn-sm" onclick="quickRestore('${source}','${safe}')">↩ Restore</button>
|
|
${uploadBtn}
|
|
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('${source}','${safe}',this)"><i class="fas fa-trash"></i></button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function auditBackup(source, filename, btn) {
|
|
const modal = el('audit-modal');
|
|
const content = el('audit-modal-content');
|
|
if (!modal || !content) return;
|
|
modal.dataset.auditSource = source;
|
|
modal.dataset.auditFile = filename;
|
|
modal.style.display = 'flex';
|
|
content.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Running audit…</div>';
|
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; }
|
|
try {
|
|
const r = await fetch('/api/backups/audit', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ backup_file: filename, source })
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) {
|
|
content.innerHTML = renderAuditModalContent({ error: d.error || d.message || `HTTP ${r.status}` }, filename);
|
|
return;
|
|
}
|
|
content.innerHTML = renderAuditModalContent(d, filename);
|
|
} catch (e) {
|
|
content.innerHTML = `<div class="empty-state" style="color:var(--red)">Audit failed: ${escapeHtml(e)}</div>
|
|
<div class="audit-footer"><button type="button" class="btn btn-ghost btn-sm" onclick="closeAuditModal()">Close</button></div>`;
|
|
} finally {
|
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-shield-check"></i> Audit'; }
|
|
}
|
|
}
|
|
|
|
function closeAuditModal(e) {
|
|
const modal = el('audit-modal');
|
|
if (!modal) return;
|
|
if (!e || e.target === modal) modal.style.display = 'none';
|
|
}
|
|
|
|
async function deleteBackup(source, filename, btn) {
|
|
if (!confirm(`Delete backup:\n${filename}\n\nThis cannot be undone.`)) return;
|
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; }
|
|
try {
|
|
const r = await fetch('/api/backups/delete', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ backup_file: filename, source })
|
|
});
|
|
const d = await r.json();
|
|
if (!d.success) alert(`Delete failed: ${d.message}`);
|
|
refreshBackupsList();
|
|
} catch (e) {
|
|
alert(`Error: ${e}`);
|
|
} finally {
|
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-trash"></i>'; }
|
|
}
|
|
}
|
|
|
|
async function runManualBackup() {
|
|
const btn = el('manual-backup-btn');
|
|
if (!btn) return;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Starting…';
|
|
const wrapper = el('manual-backup-wrapper');
|
|
const logEl = el('manual-backup-log');
|
|
if (wrapper) wrapper.style.display = '';
|
|
if (logEl) logEl.innerHTML = '';
|
|
try {
|
|
const r = await fetch('/api/backups/run', { method: 'POST' });
|
|
const d = await r.json();
|
|
if (!d.success) throw new Error(d.message || 'Failed');
|
|
manualBackupJobId = d.job_id;
|
|
pollManualBackup();
|
|
} catch (e) {
|
|
if (logEl) logEl.textContent = `❌ ${e}`;
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-play"></i> Run Backup Now';
|
|
}
|
|
}
|
|
|
|
function pollManualBackup() {
|
|
if (manualBackupPoll) clearInterval(manualBackupPoll);
|
|
let lastLine = 0;
|
|
manualBackupPoll = setInterval(async () => {
|
|
if (!manualBackupJobId) return;
|
|
try {
|
|
const r = await fetch(`/api/backups/run/status/${manualBackupJobId}`);
|
|
const d = await r.json();
|
|
d.log.slice(lastLine).forEach(appendBackupLog);
|
|
lastLine = d.log.length;
|
|
if (el('manual-backup-elapsed')) el('manual-backup-elapsed').textContent = `⏱ ${d.elapsed}s`;
|
|
if (d.status !== 'running') {
|
|
clearInterval(manualBackupPoll);
|
|
if (el('manual-backup-btn')) {
|
|
el('manual-backup-btn').disabled = false;
|
|
el('manual-backup-btn').innerHTML = '<i class="fas fa-play"></i> Run Backup Now';
|
|
}
|
|
if (d.status === 'done') setTimeout(refreshBackupsList, 1200);
|
|
}
|
|
} catch (_) {}
|
|
}, 1500);
|
|
}
|
|
|
|
function appendBackupLog(line) {
|
|
const logEl = el('manual-backup-log');
|
|
if (!logEl) return;
|
|
const div = document.createElement('div');
|
|
div.className = 'log-line';
|
|
div.textContent = line;
|
|
logEl.appendChild(div);
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
|
|
async function loadBackupLog() {
|
|
const node = el('backup-history-list');
|
|
if (!node) return;
|
|
node.innerHTML = '<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div>';
|
|
try {
|
|
const r = await fetch('/api/backups/log?limit=15');
|
|
const d = await r.json();
|
|
if (!d.entries || !d.entries.length) {
|
|
node.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i>No backup history yet</div>';
|
|
return;
|
|
}
|
|
node.innerHTML = d.entries.map((e) => `
|
|
<div class="backup-item">
|
|
<div class="backup-item-main">
|
|
<span class="backup-name">${escapeHtml(e.name)}</span>
|
|
<span class="backup-meta">${escapeHtml(e.status)}</span>
|
|
</div>
|
|
</div>`).join('');
|
|
} catch (e) {
|
|
node.innerHTML = `<div class="empty-state">Error loading log: ${e}</div>`;
|
|
}
|
|
}
|
|
|
|
const userColors = ['#3b82f6', '#a78bfa', '#22c55e', '#f59e0b', '#ef4444', '#22d3ee'];
|
|
async function loadUsers() {
|
|
const grid = el('users-grid');
|
|
if (!grid) return;
|
|
try {
|
|
const r = await fetch('/api/users');
|
|
const users = await r.json();
|
|
if (el('nav-badge-users')) el('nav-badge-users').textContent = users.length;
|
|
if (el('stat-users')) el('stat-users').textContent = users.length;
|
|
if (!users.length) {
|
|
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><i class="fas fa-user-slash"></i>No users</div>';
|
|
return;
|
|
}
|
|
grid.innerHTML = users.map((u, i) => `<div class="user-card" data-user="${escapeHtml(u.name)}" onclick="loadUserContainers('${jsStr(u.name)}')"><div class="user-card-top"><div class="user-avatar" style="background:${userColors[i % userColors.length]}">${escapeHtml(u.name[0].toUpperCase())}</div><div><div class="user-name">${escapeHtml(u.name)}</div><div class="user-uid">uid ${u.uid}</div></div></div><div class="user-tags">${u.has_docker ? '<span class="user-tag docker"><i class="fab fa-docker"></i> docker</span>' : ''}${u.linger ? '<span class="user-tag linger">linger</span>' : ''}${u.has_vdisk ? '<span class="user-tag vdisk"><i class="fas fa-hard-drive"></i> vdisk</span>' : ''}</div><div class="user-stats"><div class="user-stat"><span class="user-stat-label">Disk</span><strong>${escapeHtml(u.disk_used)}</strong></div><div class="user-stat"><span class="user-stat-label">Containers</span><strong>${u.container_count}</strong></div></div></div>`).join('');
|
|
} catch (_) {}
|
|
}
|
|
|
|
async function loadUserContainers(username) {
|
|
selectedUser = username;
|
|
document.querySelectorAll('.user-card').forEach((card) => {
|
|
card.classList.toggle('selected', card.dataset.user === username);
|
|
});
|
|
if (el('user-detail-panel')) {
|
|
el('user-detail-panel').style.display = '';
|
|
el('user-detail-panel').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
setText('user-detail-title', `${username}'s Containers`);
|
|
const body = el('user-containers-body');
|
|
if (!body) return;
|
|
body.innerHTML = '<tr><td colspan="4"><div class="empty-state"><i class="fas fa-spinner fa-spin"></i></div></td></tr>';
|
|
try {
|
|
const r = await fetch(`/api/users/${username}/containers`);
|
|
const ctrs = await r.json();
|
|
if (!ctrs.length) {
|
|
body.innerHTML = '<tr><td colspan="4"><div class="empty-state"><i class="fas fa-inbox"></i>No containers</div></td></tr>';
|
|
return;
|
|
}
|
|
body.innerHTML = ctrs.map((c) => `<tr><td class="ct-name">${c.name}</td><td>${c.status.includes('Up') ? '<span class="badge badge-run">Running</span>' : '<span class="badge badge-stop">Stopped</span>'}</td><td class="ct-image">${c.image}</td><td class="ct-ports">${c.ports || '—'}</td></tr>`).join('');
|
|
} catch (_) {}
|
|
}
|
|
|
|
function showAlert(id, type, msg) {
|
|
const node = el(id);
|
|
if (!node) return;
|
|
node.className = `alert alert-${type} show`;
|
|
node.textContent = msg;
|
|
}
|
|
|
|
async function createUser() {
|
|
const username = el('new-username')?.value.trim();
|
|
const password = el('new-password')?.value.trim();
|
|
const quota = el('new-quota')?.value.trim();
|
|
const docker = !!el('new-docker')?.checked;
|
|
if (!username) return showAlert('create-user-result', 'error', 'Username required');
|
|
const btn = event.target;
|
|
btn.disabled = true;
|
|
try {
|
|
const r = await fetch('/api/users/create', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password: password || null, setup_docker: docker, disk_quota_mb: quota ? parseInt(quota, 10) : null })
|
|
});
|
|
const d = await r.json();
|
|
showAlert('create-user-result', d.success ? 'success' : 'error', d.message || '');
|
|
if (d.success) loadUsers();
|
|
} catch (e) {
|
|
showAlert('create-user-result', 'error', `${e}`);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function deleteUser() {
|
|
if (!selectedUser || !confirm(`Delete user "${selectedUser}"?`)) return;
|
|
const removeHome = confirm(`Also delete /home/${selectedUser}?`);
|
|
try {
|
|
const r = await fetch('/api/users/delete', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: selectedUser, remove_home: removeHome })
|
|
});
|
|
const d = await r.json();
|
|
showAlert('user-action-result', d.success ? 'success' : 'error', d.message || '');
|
|
if (d.success) {
|
|
if (el('user-detail-panel')) el('user-detail-panel').style.display = 'none';
|
|
loadUsers();
|
|
}
|
|
} catch (e) {
|
|
showAlert('user-action-result', 'error', `${e}`);
|
|
}
|
|
}
|
|
|
|
function updateBackupList() {
|
|
const src = document.querySelector('input[name="backup_source"]:checked')?.value;
|
|
if (!src) return;
|
|
if (el('local-options')) el('local-options').style.display = src === 'local' ? '' : 'none';
|
|
if (el('vm-options')) el('vm-options').style.display = src === 'vm' ? '' : 'none';
|
|
}
|
|
function toggleRemoteFields() {
|
|
const v = document.querySelector('input[name="restore_target"]:checked')?.value;
|
|
if (el('remote-fields')) el('remote-fields').style.display = v === 'remote' ? '' : 'none';
|
|
}
|
|
function toggleAuthFields() {
|
|
const m = document.querySelector('input[name="auth_method"]:checked')?.value;
|
|
if (el('key-field')) el('key-field').style.display = m === 'key' ? '' : 'none';
|
|
if (el('password-field')) el('password-field').style.display = m === 'password' ? '' : 'none';
|
|
}
|
|
|
|
function quickRestore(source, filename) {
|
|
window.location.href = `/restore?source=${encodeURIComponent(source)}&file=${encodeURIComponent(filename)}`;
|
|
}
|
|
|
|
function appendLog(line) {
|
|
const logEl = el('restore-log');
|
|
if (!logEl) return;
|
|
const div = document.createElement('div');
|
|
div.className = 'log-line';
|
|
div.textContent = line;
|
|
logEl.appendChild(div);
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
|
|
async function launchRestore() {
|
|
const src = document.querySelector('input[name="backup_source"]:checked')?.value;
|
|
const file = el('backup-file-select')?.value;
|
|
const target = document.querySelector('input[name="restore_target"]:checked')?.value;
|
|
if (!file) return alert('Select a backup file.');
|
|
const payload = { backup_source: src, backup_file: file, target };
|
|
if (target === 'remote') {
|
|
payload.remote_ip = el('remote-ip')?.value.trim();
|
|
payload.remote_port = el('remote-port')?.value.trim() || '22';
|
|
payload.remote_user = el('remote-user')?.value.trim() || 'root';
|
|
payload.auth_method = document.querySelector('input[name="auth_method"]:checked')?.value;
|
|
if (payload.auth_method === 'key') payload.ssh_key_path = el('ssh-key-path')?.value.trim();
|
|
else payload.ssh_password = el('ssh-password')?.value || '';
|
|
if (!payload.remote_ip) return alert('Enter target IP.');
|
|
}
|
|
if (!confirm(`Restore "${file}" now?`)) return;
|
|
if (el('restore-log-wrapper')) el('restore-log-wrapper').style.display = '';
|
|
if (el('restore-log')) el('restore-log').innerHTML = '';
|
|
try {
|
|
const res = await fetch('/restore/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
|
const data = await res.json();
|
|
if (data.error) return appendLog(`❌ ${data.error}`);
|
|
currentJobId = data.job_id;
|
|
pollRestore();
|
|
} catch (e) {
|
|
appendLog(`❌ ${e}`);
|
|
}
|
|
}
|
|
|
|
function pollRestore() {
|
|
if (pollInterval) clearInterval(pollInterval);
|
|
let lastLine = 0;
|
|
pollInterval = setInterval(async () => {
|
|
if (!currentJobId) return;
|
|
try {
|
|
const r = await fetch(`/restore/status/${currentJobId}`);
|
|
const d = await r.json();
|
|
d.log.slice(lastLine).forEach(appendLog);
|
|
lastLine = d.log.length;
|
|
if (el('restore-elapsed')) el('restore-elapsed').textContent = `⏱ ${d.elapsed}s`;
|
|
if (d.status !== 'running') clearInterval(pollInterval);
|
|
} catch (_) {}
|
|
}, 1500);
|
|
}
|
|
|
|
function refreshAll() {
|
|
const btn = document.querySelector('.icon-btn');
|
|
if (btn) btn.classList.add('spinning');
|
|
const extras = [];
|
|
if (typeof window.loadSites === 'function') extras.push(window.loadSites(true));
|
|
Promise.all([
|
|
checkServerStatus(),
|
|
refreshSystemMetrics(),
|
|
refreshContainerStats(),
|
|
refreshSidebarNavBadges(),
|
|
...extras,
|
|
]).finally(() => { if (btn) btn.classList.remove('spinning'); });
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
checkServerStatus();
|
|
refreshSystemMetrics();
|
|
refreshContainerStats();
|
|
refreshSidebarNavBadges();
|
|
if (el('all-containers-body')) loadAllContainers();
|
|
if (el('backup-history-list')) { refreshBackupsList(); loadBackupLog(); }
|
|
if (el('users-grid')) loadUsers();
|
|
if (window.restorePrefill && el('backup-file-select')) {
|
|
const { source, file } = window.restorePrefill;
|
|
if (source) {
|
|
const sourceRadio = document.querySelector(`input[name="backup_source"][value="${source}"]`);
|
|
if (sourceRadio) sourceRadio.checked = true;
|
|
updateBackupList();
|
|
}
|
|
if (file) {
|
|
const sel = el('backup-file-select');
|
|
for (const opt of sel.options) {
|
|
if (opt.value === file) { opt.selected = true; break; }
|
|
}
|
|
}
|
|
}
|
|
setInterval(() => { refreshSystemMetrics(); refreshContainerStats(); refreshSidebarNavBadges(); }, 15000);
|
|
setInterval(checkServerStatus, 30000);
|
|
});
|