Files
docker-backup/webapp/templates/index.html
2026-04-10 15:36:35 -07:00

1110 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Docker Backup</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"/>
<style>
body { background:#0d1117; color:#c9d1d9; }
.sidebar { width:220px; min-height:100vh; background:#161b22; border-right:1px solid #30363d; flex-shrink:0; }
.sidebar .nav-link { color:#8b949e; border-radius:6px; padding:8px 14px; }
.sidebar .nav-link:hover, .sidebar .nav-link.active { background:#21262d; color:#f0f6fc; }
.sidebar .nav-link i { width:20px; text-align:center; margin-right:8px; }
.main { flex:1; padding:24px; min-width:0; overflow-x:hidden; }
.card { background:#161b22; border:1px solid #30363d; border-radius:8px; }
.card-header { background:#21262d; border-bottom:1px solid #30363d; font-weight:600; }
.table { --bs-table-bg:transparent; }
.table th { color:#8b949e; font-weight:500; border-color:#30363d; font-size:.78rem; text-transform:uppercase; letter-spacing:.05em; }
.table td { border-color:#30363d; vertical-align:middle; }
.badge-running { background:#1f6feb22; color:#58a6ff; border:1px solid #1f6feb55; }
.badge-exited { background:#21262d; color:#8b949e; border:1px solid #30363d; }
.badge-error { background:#da363622; color:#f85149; border:1px solid #da363655; }
.badge-paused { background:#9e6a0322; color:#d29922; border:1px solid #9e6a0355; }
.badge-done { background:#23863622; color:#3fb950; border:1px solid #23863655; }
.host-pill { background:#0d419d22; color:#79c0ff; border:1px solid #1f6feb44;
font-size:.72rem; padding:2px 8px; border-radius:20px; white-space:nowrap; }
.btn-xs { padding:2px 8px; font-size:.75rem; }
.log-box { background:#0d1117; border:1px solid #30363d; border-radius:6px;
font-family:monospace; font-size:.78rem; overflow-y:auto; padding:10px;
color:#e6edf3; white-space:pre-wrap; }
.modal-content { background:#161b22; border:1px solid #30363d; }
.modal-header, .modal-footer { border-color:#30363d; }
.form-control, .form-select, .input-group-text {
background:#0d1117; border-color:#30363d; color:#c9d1d9; }
.form-control:focus, .form-select:focus {
background:#0d1117; border-color:#58a6ff; color:#c9d1d9; box-shadow:none; }
.form-control:read-only { color:#8b949e; }
.form-check-input { background-color:#21262d; border-color:#30363d; }
.section { display:none; }
.section.active { display:block; }
.empty-state { text-align:center; padding:48px; color:#484f58; }
.empty-state i { font-size:3rem; display:block; margin-bottom:12px; }
.stat-card { background:#21262d; border:1px solid #30363d; border-radius:8px; padding:16px 20px; }
.stat-card .num { font-size:1.8rem; font-weight:700; color:#f0f6fc; }
.stat-card .lbl { font-size:.78rem; color:#8b949e; margin-top:2px; }
.host-card { background:#21262d; border:1px solid #30363d; border-radius:8px;
padding:12px 16px; display:flex; align-items:center; gap:12px; }
.dot { width:9px; height:9px; border-radius:50%; flex-shrink:0; }
.dot.ok { background:#3fb950; }
.dot.err { background:#f85149; }
.spin { display:inline-block; width:8px; height:8px; background:#58a6ff;
border-radius:50%; animation:pulse 1s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
.cron-label { font-size:.72rem; color:#8b949e; }
/* Login overlay */
#login-overlay { position:fixed; inset:0; background:#0d1117; z-index:9999;
display:flex; align-items:center; justify-content:center; }
#login-overlay.d-none { display:none !important; }
.login-box { background:#161b22; border:1px solid #30363d; border-radius:12px;
padding:32px; width:360px; }
#login-error { display:none; }
</style>
</head>
<body>
<!-- Login Overlay -->
<div id="login-overlay" class="d-none">
<div class="login-box">
<div class="d-flex align-items-center gap-2 mb-4">
<i class="bi bi-box-seam-fill text-primary fs-4"></i>
<span class="fw-bold fs-5 text-light">Docker Backup</span>
</div>
<p class="text-muted small mb-4">Sign in to continue</p>
<div class="mb-3">
<label class="form-label text-muted small">Username</label>
<input type="text" class="form-control" id="login-user" autocomplete="username"/>
</div>
<div class="mb-4">
<label class="form-label text-muted small">Password</label>
<input type="password" class="form-control" id="login-pass" autocomplete="current-password"/>
</div>
<div id="login-error" class="alert alert-danger py-2 small mb-3"></div>
<button class="btn btn-primary w-100" onclick="doLogin()">Sign in</button>
</div>
</div>
<div class="d-flex" id="app">
<!-- Sidebar -->
<nav class="sidebar d-flex flex-column p-3 gap-1">
<div class="d-flex align-items-center gap-2 mb-4 px-2">
<i class="bi bi-box-seam-fill text-primary fs-5"></i>
<span class="fw-bold text-light">Docker Backup</span>
</div>
<a href="#" class="nav-link active" data-section="dashboard"><i class="bi bi-grid-1x2"></i>Dashboard</a>
<a href="#" class="nav-link" data-section="containers"><i class="bi bi-box"></i>Containers</a>
<a href="#" class="nav-link" data-section="backups"><i class="bi bi-archive"></i>Backups</a>
<a href="#" class="nav-link" data-section="schedules">
<i class="bi bi-calendar-check"></i>Schedules
<span id="sched-badge" class="badge bg-primary ms-1 d-none">0</span>
</a>
<a href="#" class="nav-link" data-section="settings"><i class="bi bi-gear"></i>Settings</a>
<div class="mt-auto d-flex flex-column gap-1">
<a href="#" class="nav-link" data-section="jobs">
<i class="bi bi-list-task"></i>Jobs
<span id="jobs-badge" class="badge bg-warning text-dark ms-1 d-none">0</span>
</a>
<a href="#" class="nav-link text-muted" id="logout-btn" style="display:none!important">
<i class="bi bi-box-arrow-right"></i>Sign out
</a>
</div>
</nav>
<!-- Main -->
<main class="main">
<!-- Dashboard -->
<div id="section-dashboard" class="section active">
<h5 class="mb-4 text-light">Dashboard</h5>
<div class="row g-3 mb-4">
<div class="col-6 col-md-3"><div class="stat-card"><div class="num" id="stat-hosts"></div><div class="lbl"><i class="bi bi-hdd-network me-1"></i>Hosts</div></div></div>
<div class="col-6 col-md-3"><div class="stat-card"><div class="num" id="stat-running"></div><div class="lbl"><i class="bi bi-play-circle me-1"></i>Running</div></div></div>
<div class="col-6 col-md-3"><div class="stat-card"><div class="num" id="stat-backups"></div><div class="lbl"><i class="bi bi-archive me-1"></i>Backups</div></div></div>
<div class="col-6 col-md-3"><div class="stat-card"><div class="num" id="stat-size"></div><div class="lbl"><i class="bi bi-hdd me-1"></i>Total Size</div></div></div>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="card h-100">
<div class="card-header px-3 py-2">Hosts</div>
<div class="card-body p-3" id="dash-hosts"></div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card h-100">
<div class="card-header px-3 py-2">Recent Backups</div>
<div class="card-body p-0" id="dash-recent"></div>
</div>
</div>
</div>
</div>
<!-- Containers -->
<div id="section-containers" class="section">
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
<h5 class="mb-0 text-light">Containers</h5>
<div class="d-flex gap-2 align-items-center flex-wrap">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="show-all-toggle">
<label class="form-check-label text-muted small" for="show-all-toggle">Show stopped</label>
</div>
<button class="btn btn-outline-secondary btn-sm" onclick="loadContainers()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<div id="containers-wrap"></div>
</div>
<!-- Backups -->
<div id="section-backups" class="section">
<div class="d-flex align-items-center justify-content-between mb-4">
<h5 class="mb-0 text-light">Backups</h5>
<button class="btn btn-outline-secondary btn-sm" onclick="loadBackups()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead><tr>
<th class="ps-3">Container</th><th>Image</th><th>Date</th>
<th>Size</th><th>Volumes</th><th class="pe-3"></th>
</tr></thead>
<tbody id="backups-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- Schedules -->
<div id="section-schedules" class="section">
<div class="d-flex align-items-center justify-content-between mb-4">
<h5 class="mb-0 text-light">Schedules</h5>
<button class="btn btn-primary btn-sm" onclick="openScheduleModal()">
<i class="bi bi-plus-lg me-1"></i>Add Schedule
</button>
</div>
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead><tr>
<th class="ps-3">Container</th><th>Host</th><th>Schedule</th>
<th>Pre-hook</th><th>Retention</th><th>Last Run</th><th>Next Run</th><th class="pe-3"></th>
</tr></thead>
<tbody id="schedules-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- Settings -->
<div id="section-settings" class="section">
<h5 class="mb-4 text-light">Settings</h5>
<div class="row g-4">
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header px-3 py-2">Docker Hosts</div>
<div class="card-body p-3">
<div id="settings-hosts" class="d-flex flex-column gap-2 mb-3"></div>
<div class="input-group">
<input type="text" class="form-control" id="new-host-input"
placeholder="tcp://host:2375 or ssh://user@host"/>
<button class="btn btn-primary" onclick="addHost()"><i class="bi bi-plus-lg"></i></button>
</div>
<div class="form-text text-muted mt-1">Use <code>local</code> for local socket.</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header px-3 py-2">Backup Storage</div>
<div class="card-body p-3">
<label class="form-label text-muted small">Backup Directory</label>
<div class="input-group">
<input type="text" class="form-control" id="backup-dir-input"/>
<button class="btn btn-primary" onclick="saveBackupDir()">Save</button>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header px-3 py-2">Authentication</div>
<div class="card-body p-3">
<div id="auth-status-msg" class="small text-muted mb-3"></div>
<div class="mb-2">
<label class="form-label text-muted small">Username</label>
<input type="text" class="form-control" id="auth-user"/>
</div>
<div class="mb-3">
<label class="form-label text-muted small">Password <span class="text-muted">(min 6 chars)</span></label>
<input type="password" class="form-control" id="auth-pass"/>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-sm" onclick="saveAuth()">
<i class="bi bi-shield-check me-1"></i>Set Password
</button>
<button class="btn btn-outline-danger btn-sm" id="auth-disable-btn"
onclick="disableAuth()" style="display:none">
<i class="bi bi-shield-x me-1"></i>Disable Auth
</button>
</div>
<div id="auth-msg" class="small mt-2"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Jobs -->
<div id="section-jobs" class="section">
<div class="d-flex align-items-center justify-content-between mb-4">
<h5 class="mb-0 text-light">Jobs</h5>
<button class="btn btn-outline-secondary btn-sm" onclick="loadJobs()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div id="jobs-list"></div>
</div>
</main>
</div>
<!-- Backup Modal -->
<div class="modal fade" id="backupModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-archive me-2"></i>Backup Container</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label text-muted small">Container</label>
<input type="text" class="form-control" id="bk-container" readonly/>
</div>
<div class="mb-3">
<label class="form-label text-muted small">Host</label>
<input type="text" class="form-control" id="bk-host" readonly/>
</div>
<div class="mb-3">
<label class="form-label text-muted small">Pre-backup Hook <span class="text-muted">(runs inside container)</span></label>
<input type="text" class="form-control font-monospace" id="bk-prehook"
placeholder="e.g. pg_dump -U postgres mydb > /tmp/dump.sql"/>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label text-muted small">Keep last N backups <span class="text-muted">(0=off)</span></label>
<input type="number" class="form-control" id="bk-keep-count" value="0" min="0"/>
</div>
<div class="col">
<label class="form-label text-muted small">Delete after N days <span class="text-muted">(0=off)</span></label>
<input type="number" class="form-control" id="bk-keep-days" value="0" min="0"/>
</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="bk-save-image"/>
<label class="form-check-label small" for="bk-save-image">
Include image in backup <span class="text-muted">(large, but works without registry)</span>
</label>
</div>
<div id="bk-log-wrap" class="d-none">
<div id="bk-log" class="log-box" style="height:180px;"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button class="btn btn-primary" id="bk-btn" onclick="startBackup()">
<i class="bi bi-archive me-1"></i>Start Backup
</button>
</div>
</div>
</div>
</div>
<!-- Bulk Backup Modal -->
<div class="modal fade" id="bulkModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-boxes me-2"></i>Bulk Backup — <span id="bulk-host-title"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small">Backs up every running container on this host as a separate backup file.</p>
<div id="bulk-container-list" class="mb-3 d-flex flex-column gap-1"></div>
<div class="mb-3">
<label class="form-label text-muted small">Pre-backup Hook <span class="text-muted">(runs inside each container)</span></label>
<input type="text" class="form-control font-monospace" id="bulk-prehook" placeholder="optional"/>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="bulk-save-image"/>
<label class="form-check-label small" for="bulk-save-image">Include images</label>
</div>
<div id="bulk-log-wrap" class="mt-3 d-none">
<div id="bulk-log" class="log-box" style="height:160px;"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button class="btn btn-primary" id="bulk-btn" onclick="startBulkBackup()">
<i class="bi bi-boxes me-1"></i>Backup All
</button>
</div>
</div>
</div>
</div>
<!-- Restore Modal -->
<div class="modal fade" id="restoreModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-arrow-counterclockwise me-2"></i>Restore Container</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label text-muted small">Backup File</label>
<input type="text" class="form-control" id="rs-file" readonly/>
</div>
<div class="mb-3">
<label class="form-label text-muted small">Target Host</label>
<select class="form-select" id="rs-host"></select>
</div>
<div class="mb-3">
<label class="form-label text-muted small">Container Name <span class="text-muted">(blank = original)</span></label>
<input type="text" class="form-control" id="rs-name"/>
</div>
<div class="d-flex gap-4 mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="rs-start"/>
<label class="form-check-label small" for="rs-start">Start after restore</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="rs-load-image"/>
<label class="form-check-label small" for="rs-load-image">Load image from backup</label>
</div>
</div>
<div id="rs-log-wrap" class="d-none">
<div id="rs-log" class="log-box" style="height:180px;"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button class="btn btn-success" id="rs-btn" onclick="startRestore()">
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
</button>
</div>
</div>
</div>
</div>
<!-- Schedule Modal -->
<div class="modal fade" id="scheduleModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="sched-modal-title"><i class="bi bi-calendar-check me-2"></i>Schedule Backup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3 mb-3">
<div class="col">
<label class="form-label text-muted small">Host</label>
<select class="form-select" id="sm-host" onchange="loadHostContainers()"></select>
</div>
<div class="col">
<label class="form-label text-muted small">Container</label>
<input type="text" class="form-control" id="sm-container" list="sm-containers-list" placeholder="container name"/>
<datalist id="sm-containers-list"></datalist>
</div>
</div>
<div class="mb-3">
<label class="form-label text-muted small">Schedule</label>
<div class="d-flex gap-2">
<select class="form-select" id="sm-cron-preset" onchange="cronPresetChange()" style="width:140px;flex-shrink:0">
<option value="hourly">Hourly</option>
<option value="daily" selected>Daily (2am)</option>
<option value="weekly">Weekly (Sun)</option>
<option value="monthly">Monthly</option>
<option value="custom">Custom…</option>
</select>
<input type="text" class="form-control font-monospace d-none" id="sm-cron-custom"
placeholder="0 2 * * *" title="minute hour day month day_of_week"/>
</div>
<div class="form-text text-muted" id="sm-cron-hint">Runs at 02:00 UTC every day</div>
</div>
<div class="mb-3">
<label class="form-label text-muted small">Pre-backup Hook <span class="text-muted">(runs inside container)</span></label>
<input type="text" class="form-control font-monospace" id="sm-prehook"
placeholder="e.g. pg_dump -U postgres mydb > /tmp/dump.sql"/>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label text-muted small">Keep last N</label>
<input type="number" class="form-control" id="sm-keep-count" value="7" min="0"/>
</div>
<div class="col">
<label class="form-label text-muted small">Delete after N days</label>
<input type="number" class="form-control" id="sm-keep-days" value="30" min="0"/>
</div>
</div>
<div class="d-flex gap-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="sm-save-image"/>
<label class="form-check-label small" for="sm-save-image">Include image</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="sm-enabled" checked/>
<label class="form-check-label small" for="sm-enabled">Enabled</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" onclick="saveSchedule()">
<i class="bi bi-check-lg me-1"></i>Save Schedule
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// ── Auth ──────────────────────────────────────────────────────────────────────
async function checkAuth() {
const r = await fetch('/api/auth/status');
const s = await r.json();
if (s.enabled && !s.authenticated) {
showLogin();
} else {
hideLogin();
if (s.enabled) {
document.getElementById('logout-btn').style.display = '';
}
}
}
function showLogin() {
document.getElementById('login-overlay').classList.remove('d-none');
setTimeout(() => document.getElementById('login-user').focus(), 100);
}
function hideLogin() {
document.getElementById('login-overlay').classList.add('d-none');
}
async function doLogin() {
const user = document.getElementById('login-user').value;
const pass = document.getElementById('login-pass').value;
const err = document.getElementById('login-error');
const res = await fetch('/api/auth/login', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({username: user, password: pass})
});
const data = await res.json();
if (data.ok) {
hideLogin();
document.getElementById('logout-btn').style.display = '';
loadDashboard();
} else {
err.textContent = data.error || 'Invalid credentials';
err.style.display = 'block';
}
}
document.getElementById('login-pass').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
document.getElementById('logout-btn').addEventListener('click', async e => {
e.preventDefault();
await fetch('/api/auth/logout', {method:'POST'});
document.getElementById('logout-btn').style.display = 'none';
showLogin();
});
// ── API helper ────────────────────────────────────────────────────────────────
async function api(url, opts={}) {
const r = await fetch(url, { headers:{'Content-Type':'application/json'}, ...opts });
if (r.status === 401) { showLogin(); throw new Error('Unauthorized'); }
return r.json();
}
// ── Navigation ────────────────────────────────────────────────────────────────
document.querySelectorAll('.nav-link[data-section]').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
link.classList.add('active');
const sec = link.dataset.section;
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.getElementById('section-' + sec).classList.add('active');
({ dashboard: loadDashboard, containers: loadContainers, backups: loadBackups,
schedules: loadSchedules, settings: loadSettings, jobs: loadJobs })[sec]?.();
});
});
// ── Helpers ───────────────────────────────────────────────────────────────────
const esc = s => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const fmtDate = iso => iso ? new Date(iso).toLocaleString() : '';
const fmtSize = mb => mb >= 1024 ? (mb/1024).toFixed(1)+' GB' : mb+' MB';
const statusBadge = s => {
const cls = {running:'badge-running',exited:'badge-exited',error:'badge-error',paused:'badge-paused'}[s]||'badge-exited';
return `<span class="badge ${cls}">${s}</span>`;
};
const cronHints = {
hourly:'Every hour at :00', daily:'Every day at 02:00 UTC',
weekly:'Every Sunday at 02:00 UTC', monthly:'1st of each month at 02:00 UTC'
};
// ── Dashboard ─────────────────────────────────────────────────────────────────
async function loadDashboard() {
const [hosts, containers, backups] = await Promise.all([
api('/api/hosts'), api('/api/containers?all=true'), api('/api/backups')
]);
document.getElementById('stat-hosts').textContent = hosts.length;
document.getElementById('stat-running').textContent = containers.filter(c=>c.status==='running').length;
document.getElementById('stat-backups').textContent = backups.length;
document.getElementById('stat-size').textContent = fmtSize(backups.reduce((a,b)=>a+b.size_mb,0));
const hl = document.getElementById('dash-hosts');
hl.innerHTML = hosts.length ? hosts.map(h=>`
<div class="host-card mb-2">
<div class="dot ${h.ok?'ok':'err'}"></div>
<div class="flex-grow-1">
<div class="fw-semibold text-light small">${esc(h.host)}</div>
${h.ok ? `<div class="text-muted" style="font-size:.72rem">${esc(h.name)} · Docker ${esc(h.version)} · ${h.containers} containers</div>`
: `<div class="text-danger" style="font-size:.72rem">${esc(h.error)}</div>`}
</div>
</div>`).join('') : '<div class="empty-state"><i class="bi bi-hdd-network"></i>No hosts</div>';
const rb = document.getElementById('dash-recent');
rb.innerHTML = backups.length ? `<table class="table table-hover mb-0"><tbody>${
backups.slice(0,6).map(b=>`<tr>
<td class="ps-3 text-light small fw-semibold">${esc(b.container)}</td>
<td class="text-muted small">${fmtDate(b.mtime)}</td>
<td class="text-muted small pe-3">${fmtSize(b.size_mb)}</td>
</tr>`).join('')
}</tbody></table>` : '<div class="empty-state"><i class="bi bi-archive"></i>No backups yet</div>';
}
// ── Containers ────────────────────────────────────────────────────────────────
async function loadContainers() {
const all = document.getElementById('show-all-toggle').checked;
const wrap = document.getElementById('containers-wrap');
wrap.innerHTML = '<div class="text-muted text-center py-4">Loading…</div>';
const data = await api('/api/containers?all=' + all);
// Group by host
const byHost = {};
for (const c of data) {
(byHost[c.host] = byHost[c.host] || []).push(c);
}
if (!Object.keys(byHost).length) {
wrap.innerHTML = '<div class="empty-state"><i class="bi bi-box"></i>No containers</div>';
return;
}
wrap.innerHTML = Object.entries(byHost).map(([host, ctrs]) => `
<div class="card mb-3">
<div class="card-header px-3 py-2 d-flex align-items-center justify-content-between">
<span><span class="host-pill me-2">${esc(host)}</span>${ctrs.length} container${ctrs.length!==1?'s':''}</span>
<button class="btn btn-outline-primary btn-xs" onclick="openBulkModal('${esc(host)}')">
<i class="bi bi-boxes me-1"></i>Backup All
</button>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead><tr>
<th class="ps-3">Name</th><th>Image</th><th>Status</th><th>Ports</th><th class="pe-3"></th>
</tr></thead>
<tbody>
${ctrs.map(c => {
if (!c.id) return `<tr><td colspan="5" class="ps-3 text-danger small">${esc(c.error)}</td></tr>`;
const ports = Object.entries(c.ports||{}).map(([k,v])=>v?`${v}${k}`:k).join(', ') || '';
return `<tr>
<td class="ps-3 fw-semibold text-light">${esc(c.name)}</td>
<td class="text-muted small">${esc(c.image)}</td>
<td>${statusBadge(c.status)}</td>
<td class="text-muted small">${esc(ports)}</td>
<td class="pe-3 text-end">
<button class="btn btn-outline-secondary btn-xs me-1"
onclick="openScheduleModal('${esc(c.name)}','${esc(host)}')">
<i class="bi bi-calendar-check"></i>
</button>
<button class="btn btn-outline-primary btn-xs"
onclick="openBackupModal('${esc(c.name)}','${esc(host)}')">
<i class="bi bi-archive"></i> Backup
</button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
</div>`).join('');
}
document.getElementById('show-all-toggle').addEventListener('change', loadContainers);
// ── Backups ───────────────────────────────────────────────────────────────────
async function loadBackups() {
const tbody = document.getElementById('backups-tbody');
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">Loading…</td></tr>';
const data = await api('/api/backups');
if (!data.length) {
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><i class="bi bi-archive"></i>No backups</div></td></tr>';
return;
}
tbody.innerHTML = data.map(b=>`
<tr>
<td class="ps-3">
<div class="fw-semibold text-light">${esc(b.container)}</div>
<div class="text-muted" style="font-size:.7rem">${esc(b.file)}</div>
</td>
<td class="text-muted small">${esc(b.image)}</td>
<td class="text-muted small">${fmtDate(b.mtime)}</td>
<td class="text-muted small">${fmtSize(b.size_mb)}</td>
<td class="text-muted small">
${b.volumes.map(v=>`<span class="badge bg-secondary me-1">${esc(v)}</span>`).join('')||''}
${b.has_image?'<span class="badge" style="background:#1f6feb33;color:#79c0ff">+image</span>':''}
</td>
<td class="pe-3 text-end">
<button class="btn btn-outline-success btn-xs me-1"
onclick="openRestoreModal('${esc(b.path)}','${esc(b.container)}')">
<i class="bi bi-arrow-counterclockwise"></i> Restore
</button>
<button class="btn btn-outline-danger btn-xs" onclick="deleteBackup('${esc(b.file)}',this)">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`).join('');
}
async function deleteBackup(file, btn) {
if (!confirm(`Delete backup: ${file}?`)) return;
btn.disabled = true;
await api('/api/backups/' + encodeURIComponent(file), {method:'DELETE'});
loadBackups();
}
// ── Schedules ─────────────────────────────────────────────────────────────────
async function loadSchedules() {
const tbody = document.getElementById('schedules-tbody');
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">Loading…</td></tr>';
const data = await api('/api/schedules');
const badge = document.getElementById('sched-badge');
badge.textContent = data.length;
data.length ? badge.classList.remove('d-none') : badge.classList.add('d-none');
if (!data.length) {
tbody.innerHTML = '<tr><td colspan="8"><div class="empty-state"><i class="bi bi-calendar-check"></i>No schedules yet</div></td></tr>';
return;
}
tbody.innerHTML = data.map(s => {
const lastStatus = s.last_status === 'done'
? '<span class="badge badge-done">OK</span>'
: s.last_status === 'error'
? '<span class="badge badge-error">Failed</span>' : '';
const retention = [
s.retention_count ? `Last ${s.retention_count}` : '',
s.retention_days ? `${s.retention_days}d` : ''
].filter(Boolean).join(' / ') || '';
const cronDisplay = Object.keys(cronHints).includes(s.cron.toLowerCase())
? s.cron : `<code>${esc(s.cron)}</code>`;
return `
<tr class="${s.enabled?'':'opacity-50'}">
<td class="ps-3 fw-semibold text-light">${esc(s.container)}</td>
<td><span class="host-pill">${esc(s.host)}</span></td>
<td>
<div>${cronDisplay}</div>
<div class="cron-label">${esc(cronHints[s.cron.toLowerCase()]||'')}</div>
</td>
<td class="text-muted small">${s.pre_hook ? `<code>${esc(s.pre_hook.substring(0,40))}${s.pre_hook.length>40?'…':''}</code>` : ''}</td>
<td class="text-muted small">${esc(retention)}</td>
<td class="text-muted small">${s.last_run ? fmtDate(s.last_run) : ''} ${lastStatus}</td>
<td class="text-muted small">${fmtDate(s.next_run)}</td>
<td class="pe-3 text-end">
<button class="btn btn-outline-secondary btn-xs me-1" title="Run now"
onclick="runScheduleNow('${s.id}')"><i class="bi bi-play-fill"></i></button>
<button class="btn btn-outline-warning btn-xs me-1" title="${s.enabled?'Disable':'Enable'}"
onclick="toggleSchedule('${s.id}',${!s.enabled})">
<i class="bi bi-${s.enabled?'pause':'play'}-fill"></i></button>
<button class="btn btn-outline-secondary btn-xs me-1" title="Edit"
onclick="editSchedule(${JSON.stringify(esc(JSON.stringify(s)))})">
<i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-xs" title="Delete"
onclick="deleteSchedule('${s.id}',this)"><i class="bi bi-trash"></i></button>
</td>
</tr>`;
}).join('');
}
async function runScheduleNow(id) {
const res = await api(`/api/schedules/${id}/run`, {method:'POST'});
if (res.job_id) {
showToast(`Job started: ${res.job_id}`);
navigate('jobs');
}
}
async function toggleSchedule(id, enabled) {
await api(`/api/schedules/${id}`, {method:'PUT', body: JSON.stringify({enabled})});
loadSchedules();
}
async function deleteSchedule(id, btn) {
if (!confirm('Delete this schedule?')) return;
btn.disabled = true;
await api(`/api/schedules/${id}`, {method:'DELETE'});
loadSchedules();
}
// schedule modal state
let editingScheduleId = null;
function openScheduleModal(container='', host='') {
editingScheduleId = null;
document.getElementById('sched-modal-title').innerHTML = '<i class="bi bi-calendar-check me-2"></i>Add Schedule';
document.getElementById('sm-container').value = container;
document.getElementById('sm-prehook').value = '';
document.getElementById('sm-keep-count').value = 7;
document.getElementById('sm-keep-days').value = 30;
document.getElementById('sm-save-image').checked = false;
document.getElementById('sm-enabled').checked = true;
document.getElementById('sm-cron-preset').value = 'daily';
document.getElementById('sm-cron-custom').classList.add('d-none');
document.getElementById('sm-cron-hint').textContent = cronHints.daily;
// Populate host dropdown
api('/api/hosts').then(hosts => {
const sel = document.getElementById('sm-host');
sel.innerHTML = hosts.map(h=>`<option value="${esc(h.host)}" ${h.host===host?'selected':''}>${esc(h.host)}</option>`).join('');
if (host) loadHostContainers();
});
new bootstrap.Modal(document.getElementById('scheduleModal')).show();
}
function editSchedule(escapedJson) {
const s = JSON.parse(unescapeHtml(escapedJson));
editingScheduleId = s.id;
document.getElementById('sched-modal-title').innerHTML = '<i class="bi bi-pencil me-2"></i>Edit Schedule';
document.getElementById('sm-container').value = s.container;
document.getElementById('sm-prehook').value = s.pre_hook || '';
document.getElementById('sm-keep-count').value = s.retention_count || 0;
document.getElementById('sm-keep-days').value = s.retention_days || 0;
document.getElementById('sm-save-image').checked = s.save_image || false;
document.getElementById('sm-enabled').checked = s.enabled !== false;
const presets = ['hourly','daily','weekly','monthly'];
const preset = presets.includes(s.cron.toLowerCase()) ? s.cron.toLowerCase() : 'custom';
document.getElementById('sm-cron-preset').value = preset;
document.getElementById('sm-cron-custom').value = s.cron;
document.getElementById('sm-cron-custom').classList.toggle('d-none', preset !== 'custom');
document.getElementById('sm-cron-hint').textContent = cronHints[preset] || s.cron;
api('/api/hosts').then(hosts => {
const sel = document.getElementById('sm-host');
sel.innerHTML = hosts.map(h=>`<option value="${esc(h.host)}" ${h.host===s.host?'selected':''}>${esc(h.host)}</option>`).join('');
loadHostContainers();
});
new bootstrap.Modal(document.getElementById('scheduleModal')).show();
}
function unescapeHtml(s) {
const t = document.createElement('textarea'); t.innerHTML = s; return t.value;
}
async function loadHostContainers() {
const host = document.getElementById('sm-host').value;
const dl = document.getElementById('sm-containers-list');
try {
const ctrs = await api(`/api/containers?host=${encodeURIComponent(host)}&all=true`);
dl.innerHTML = ctrs.filter(c=>c.name).map(c=>`<option value="${esc(c.name)}">`).join('');
} catch(e) {}
}
function cronPresetChange() {
const val = document.getElementById('sm-cron-preset').value;
const custom = document.getElementById('sm-cron-custom');
custom.classList.toggle('d-none', val !== 'custom');
document.getElementById('sm-cron-hint').textContent = cronHints[val] || '';
}
async function saveSchedule() {
const preset = document.getElementById('sm-cron-preset').value;
const cron = preset === 'custom'
? document.getElementById('sm-cron-custom').value.trim()
: preset;
const body = {
host: document.getElementById('sm-host').value,
container: document.getElementById('sm-container').value.trim(),
cron,
pre_hook: document.getElementById('sm-prehook').value.trim(),
save_image: document.getElementById('sm-save-image').checked,
retention_count: parseInt(document.getElementById('sm-keep-count').value)||null,
retention_days: parseInt(document.getElementById('sm-keep-days').value)||null,
enabled: document.getElementById('sm-enabled').checked,
};
if (!body.container) { alert('Container name is required'); return; }
try {
if (editingScheduleId) {
await api(`/api/schedules/${editingScheduleId}`, {method:'PUT', body:JSON.stringify(body)});
} else {
await api('/api/schedules', {method:'POST', body:JSON.stringify(body)});
}
bootstrap.Modal.getInstance(document.getElementById('scheduleModal')).hide();
loadSchedules();
} catch(e) { alert('Error saving schedule: ' + e.message); }
}
// ── Settings ──────────────────────────────────────────────────────────────────
async function loadSettings() {
const [cfg, hosts, authStatus] = await Promise.all([
api('/api/config'), api('/api/hosts'), fetch('/api/auth/status').then(r=>r.json())
]);
document.getElementById('backup-dir-input').value = cfg.backup_dir || '';
const el = document.getElementById('settings-hosts');
el.innerHTML = hosts.map(h=>`
<div class="d-flex align-items-center gap-2 p-2 rounded" style="background:#21262d">
<div class="dot ${h.ok?'ok':'err'}"></div>
<div class="flex-grow-1 small">
<span class="text-light">${esc(h.host)}</span>
${h.ok?`<span class="text-muted ms-2 small">${esc(h.name)} · ${esc(h.version)}</span>`
:`<span class="text-danger ms-2 small">${esc(h.error||'')}</span>`}
</div>
${h.host!=='local'?`<button class="btn btn-outline-danger btn-xs" onclick="removeHost('${esc(h.host)}')">
<i class="bi bi-x"></i></button>`:''}
</div>`).join('') || '<div class="text-muted small">No hosts</div>';
const authMsg = document.getElementById('auth-status-msg');
authMsg.textContent = authStatus.enabled
? `Auth enabled — logged in as ${authStatus.username}`
: 'Auth is disabled — the UI is publicly accessible.';
document.getElementById('auth-disable-btn').style.display = authStatus.enabled ? '' : 'none';
}
async function addHost() {
const input = document.getElementById('new-host-input');
const host = input.value.trim();
if (!host) return;
await api('/api/hosts', {method:'POST', body:JSON.stringify({host})});
input.value = '';
loadSettings();
}
async function removeHost(host) {
if (!confirm(`Remove host: ${host}?`)) return;
await api('/api/hosts/'+encodeURIComponent(host), {method:'DELETE'});
loadSettings();
}
async function saveBackupDir() {
const dir = document.getElementById('backup-dir-input').value.trim();
if (!dir) return;
await api('/api/config', {method:'POST', body:JSON.stringify({backup_dir:dir})});
showToast('Backup directory saved');
}
async function saveAuth() {
const user = document.getElementById('auth-user').value.trim();
const pass = document.getElementById('auth-pass').value;
const msg = document.getElementById('auth-msg');
const res = await api('/api/auth/setup', {method:'POST', body:JSON.stringify({username:user, password:pass})});
if (res.ok) {
msg.innerHTML = '<span class="text-success">✓ Authentication enabled</span>';
document.getElementById('logout-btn').style.display = '';
loadSettings();
} else {
msg.innerHTML = `<span class="text-danger">${esc(res.error)}</span>`;
}
}
async function disableAuth() {
if (!confirm('Disable authentication? The UI will be publicly accessible.')) return;
const res = await api('/api/auth/disable', {method:'POST'});
if (res.ok) { loadSettings(); document.getElementById('logout-btn').style.display='none'; }
}
document.getElementById('new-host-input').addEventListener('keydown', e => {
if (e.key === 'Enter') addHost();
});
// ── Jobs ──────────────────────────────────────────────────────────────────────
async function loadJobs() {
const data = await api('/api/jobs');
const el = document.getElementById('jobs-list');
if (!data.length) {
el.innerHTML = '<div class="empty-state"><i class="bi bi-list-task"></i>No jobs yet</div>';
return;
}
el.innerHTML = data.slice().reverse().map(j=>{
const icon = j.status==='running' ? '<span class="spin"></span>'
: j.status==='done' ? '<i class="bi bi-check-circle-fill text-success"></i>'
: '<i class="bi bi-x-circle-fill text-danger"></i>';
const logs = j.logs.filter(l=>l!=='[DONE]').join('\n');
return `<div class="card">
<div class="card-header px-3 py-2 d-flex align-items-center gap-2">
${icon}
<span class="flex-grow-1 small">${esc(j.label)}</span>
<span class="text-muted" style="font-size:.72rem">${fmtDate(j.created)}</span>
</div>
<pre class="mb-0 px-3 py-2" style="font-size:.75rem;color:#8b949e;max-height:160px;overflow-y:auto;background:transparent;white-space:pre-wrap">${esc(logs)}</pre>
</div>`;
}).join('');
const running = data.filter(j=>j.status==='running').length;
const badge = document.getElementById('jobs-badge');
running ? (badge.textContent=running, badge.classList.remove('d-none')) : badge.classList.add('d-none');
}
// Auto-refresh jobs badge
setInterval(async ()=>{
try {
const data = await api('/api/jobs');
const running = data.filter(j=>j.status==='running').length;
const b = document.getElementById('jobs-badge');
running ? (b.textContent=running, b.classList.remove('d-none')) : b.classList.add('d-none');
} catch(e){}
}, 3000);
// ── Backup Modal ──────────────────────────────────────────────────────────────
function openBackupModal(container, host) {
document.getElementById('bk-container').value = container;
document.getElementById('bk-host').value = host;
document.getElementById('bk-prehook').value = '';
document.getElementById('bk-keep-count').value = 0;
document.getElementById('bk-keep-days').value = 0;
document.getElementById('bk-save-image').checked = false;
document.getElementById('bk-log-wrap').classList.add('d-none');
document.getElementById('bk-log').textContent = '';
document.getElementById('bk-btn').disabled = false;
new bootstrap.Modal(document.getElementById('backupModal')).show();
}
async function startBackup() {
const btn = document.getElementById('bk-btn');
btn.disabled = true;
const logWrap = document.getElementById('bk-log-wrap');
const logEl = document.getElementById('bk-log');
logWrap.classList.remove('d-none');
logEl.textContent = '';
const res = await api('/api/backup', {method:'POST', body:JSON.stringify({
container: document.getElementById('bk-container').value,
host: document.getElementById('bk-host').value,
pre_hook: document.getElementById('bk-prehook').value.trim(),
save_image: document.getElementById('bk-save-image').checked,
retention_count: parseInt(document.getElementById('bk-keep-count').value)||null,
retention_days: parseInt(document.getElementById('bk-keep-days').value)||null,
})});
if (res.error) { logEl.textContent='Error: '+res.error; return; }
streamJob(res.job_id, logEl, () => loadDashboard());
}
// ── Bulk Backup Modal ─────────────────────────────────────────────────────────
let _bulkHost = '';
async function openBulkModal(host) {
_bulkHost = host;
document.getElementById('bulk-host-title').textContent = host;
document.getElementById('bulk-prehook').value = '';
document.getElementById('bulk-save-image').checked = false;
document.getElementById('bulk-log-wrap').classList.add('d-none');
document.getElementById('bulk-log').textContent = '';
document.getElementById('bulk-btn').disabled = false;
const listEl = document.getElementById('bulk-container-list');
listEl.innerHTML = '<div class="text-muted small">Loading containers…</div>';
const ctrs = await api(`/api/containers?host=${encodeURIComponent(host)}`);
const running = ctrs.filter(c=>c.status==='running');
listEl.innerHTML = running.length
? running.map(c=>`<div class="d-flex align-items-center gap-2 text-light small">
<i class="bi bi-box text-muted"></i>${esc(c.name)}
<span class="text-muted ms-auto">${esc(c.image)}</span>
</div>`).join('')
: '<div class="text-warning small">No running containers</div>';
new bootstrap.Modal(document.getElementById('bulkModal')).show();
}
async function startBulkBackup() {
const btn = document.getElementById('bulk-btn');
const logEl = document.getElementById('bulk-log');
const logWrap= document.getElementById('bulk-log-wrap');
btn.disabled = true;
logWrap.classList.remove('d-none');
logEl.textContent = '';
const res = await api('/api/bulk-backup', {method:'POST', body:JSON.stringify({
host: _bulkHost,
pre_hook: document.getElementById('bulk-prehook').value.trim(),
save_image: document.getElementById('bulk-save-image').checked,
})});
if (res.error) { logEl.textContent='Error: '+res.error; return; }
logEl.textContent = `Started ${res.jobs.length} backup job(s):\n`;
res.jobs.forEach(j => { logEl.textContent += ` ${j.container} → job ${j.job_id}\n`; });
logEl.textContent += '\nMonitor progress in Jobs →';
}
// ── Restore Modal ─────────────────────────────────────────────────────────────
async function openRestoreModal(path, containerName) {
document.getElementById('rs-file').value = path;
document.getElementById('rs-name').placeholder = containerName + ' (original)';
document.getElementById('rs-name').value = '';
document.getElementById('rs-start').checked = false;
document.getElementById('rs-load-image').checked = false;
document.getElementById('rs-log-wrap').classList.add('d-none');
document.getElementById('rs-log').textContent = '';
document.getElementById('rs-btn').disabled = false;
const hosts = await api('/api/hosts');
const sel = document.getElementById('rs-host');
sel.innerHTML = hosts.map(h=>`<option value="${esc(h.host)}">${esc(h.host)}${h.ok?' ✓':' ✗'}</option>`).join('');
new bootstrap.Modal(document.getElementById('restoreModal')).show();
}
async function startRestore() {
const btn = document.getElementById('rs-btn');
const logEl = document.getElementById('rs-log');
btn.disabled = true;
document.getElementById('rs-log-wrap').classList.remove('d-none');
logEl.textContent = '';
const res = await api('/api/restore', {method:'POST', body:JSON.stringify({
backup_path: document.getElementById('rs-file').value,
host: document.getElementById('rs-host').value,
new_name: document.getElementById('rs-name').value.trim()||null,
start: document.getElementById('rs-start').checked,
load_image: document.getElementById('rs-load-image').checked,
})});
if (res.error) { logEl.textContent='Error: '+res.error; return; }
streamJob(res.job_id, logEl, () => loadContainers());
}
// ── SSE Log Stream ────────────────────────────────────────────────────────────
function streamJob(jobId, logEl, onDone) {
const es = new EventSource('/api/jobs/'+jobId+'/stream');
es.onmessage = e => {
if (e.data==='[DONE]') { es.close(); onDone?.(); return; }
logEl.textContent += e.data+'\n';
logEl.scrollTop = logEl.scrollHeight;
};
es.onerror = () => es.close();
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function navigate(section) {
document.querySelector(`.nav-link[data-section="${section}"]`).click();
}
function showToast(msg) {
const t = document.createElement('div');
t.className = 'position-fixed bottom-0 end-0 m-3 alert alert-success py-2 px-3 small';
t.style.zIndex = 9000;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(()=>t.remove(), 3000);
}
// ── Init ──────────────────────────────────────────────────────────────────────
checkAuth().then(loadDashboard);
</script>
</body>
</html>