1110 lines
52 KiB
HTML
1110 lines
52 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
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>
|