Add scheduled and manual backups
Backups are .tar.gz archives of a consistent database snapshot (VACUUM INTO) plus the images folder. They run on a cron schedule, daily at 3:00 by default, and the newest N scheduled ones are kept (7 by default). Manual backups are never pruned. Settings → Backups offers schedule presets or a custom cron expression, retention, "Back up now", and download/delete for each backup. A small built-in parser handles standard 5-field cron syntax with names, ranges, steps and @daily-style shorthands. Backups go to CANON_BACKUP_DIR (default data/backups). Compose mounts a separate canon-backups volume at /backups, overridable with CANON_BACKUP_PATH. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
72b49d709a
commit
f3185ba7bd
18 files changed
+940
-10
No files matched your search
@@ -777,3 +777,11 @@ input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* ---------- settings: backups ---------- */
|
||||
.backup-status { font-size: 14px; }
|
||||
.backup-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border); }
|
||||
.backup-list li { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||||
.backup-list .backup-when { flex: 1; min-width: 0; font-size: 14px; font-weight: 550; }
|
||||
.backup-list .backup-meta { display: block; color: var(--muted); font-size: 12.5px; font-weight: 400; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.backup-list .backup-actions { display: flex; gap: 2px; flex: none; }
|
||||
@@ -43,5 +43,6 @@
|
||||
<symbol id="alert" viewBox="0 0 24 24"><path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3ZM12 9v4M12 17h.01"/></symbol>
|
||||
<symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol>
|
||||
<symbol id="crown" viewBox="0 0 24 24"><path d="M11.6 3.3a.5.5 0 0 1 .8 0l2.9 5.5a1 1 0 0 0 1.5.3l4.4-3.7a.5.5 0 0 1 .8.5l-2.8 10.4a1 1 0 0 1-1 .7H5.8a1 1 0 0 1-1-.7L2 5.9a.5.5 0 0 1 .8-.5l4.4 3.7a1 1 0 0 0 1.5-.3zM5 21h14"/></symbol>
|
||||
<symbol id="archive" viewBox="0 0 24 24"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4"/></symbol>
|
||||
<symbol id="refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15-6.7L21 8M21 3v5h-5M21 12a9 9 0 0 1-15 6.7L3 16M8 16H3v5"/></symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -1,5 +1,5 @@
|
||||
// Settings: profile, appearance, integrations, account.
|
||||
import { h, icon, api, toast, busy } from "./common.js";
|
||||
// Settings: profile, appearance, integrations, backups, account.
|
||||
import { h, icon, api, toast, busy, confirmDialog } from "./common.js";
|
||||
|
||||
const root = document.getElementById("settings");
|
||||
let settings;
|
||||
@@ -318,6 +318,127 @@ function integrationsSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- backups ----------
|
||||
|
||||
const SCHEDULES = [
|
||||
["off", "Off"],
|
||||
["0 * * * *", "Every hour"],
|
||||
["0 */6 * * *", "Every 6 hours"],
|
||||
["0 3 * * *", "Every day at 3:00"],
|
||||
["0 3 * * 0", "Every Sunday at 3:00"],
|
||||
["0 3 1 * *", "The 1st of each month at 3:00"],
|
||||
];
|
||||
|
||||
const when = (iso) => new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
|
||||
function formatSize(bytes) {
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
|
||||
return `${bytes.toFixed(i && bytes < 10 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function backupsSection() {
|
||||
let data;
|
||||
const preset = h("select", {}, SCHEDULES.map(([v, label]) => h("option", { value: v }, label)), h("option", { value: "custom" }, "Custom (cron)…"));
|
||||
const cron = h("input", { spellcheck: false, placeholder: "30 2 * * mon-fri", style: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" } });
|
||||
const cronField = field("Cron expression", cron);
|
||||
const keep = h("input", { type: "number", min: 1, max: 365, style: { maxWidth: "120px" } });
|
||||
const status = h("p", { class: "backup-status", "aria-live": "polite" });
|
||||
const list = h("ul", { class: "backup-list" });
|
||||
const empty = h("p", { class: "muted" }, "No backups yet.");
|
||||
const save = h("button", { type: "submit", class: "btn btn--primary" }, "Save schedule");
|
||||
const now = h("button", { type: "button", class: "btn" }, icon("archive"), "Back up now");
|
||||
|
||||
const showCustom = () => { cronField.hidden = preset.value !== "custom"; };
|
||||
preset.addEventListener("change", () => {
|
||||
if (preset.value === "custom" && !cron.value) cron.value = data.cron === "off" ? "0 3 * * *" : data.cron;
|
||||
showCustom();
|
||||
});
|
||||
|
||||
const render = (d) => {
|
||||
data = d;
|
||||
const known = SCHEDULES.some(([v]) => v === d.cron);
|
||||
preset.value = known ? d.cron : "custom";
|
||||
cron.value = known ? "" : d.cron;
|
||||
keep.value = d.keep;
|
||||
showCustom();
|
||||
const parts = [d.next ? `Next backup: ${when(d.next)}.` : "Scheduled backups are off."];
|
||||
if (d.status.lastRun) parts.push(d.status.lastError ? `The last backup failed: ${d.status.lastError}` : `Last backup: ${when(d.status.lastRun)}.`);
|
||||
status.textContent = parts.join(" ");
|
||||
status.className = "backup-status" + (d.status.lastError ? " alert alert--error" : "");
|
||||
list.replaceChildren(...d.backups.map(backupRow));
|
||||
empty.hidden = d.backups.length > 0;
|
||||
};
|
||||
const reload = async () => render(await api("GET", "/api/backups"));
|
||||
|
||||
const backupRow = (b) => h("li", {},
|
||||
h("span", { class: "backup-when" }, when(b.created), " ", b.manual ? h("span", { class: "badge badge--muted" }, "Manual") : null,
|
||||
h("span", { class: "backup-meta" }, `${formatSize(b.size)} · ${b.name}`)),
|
||||
h("span", { class: "backup-actions" },
|
||||
h("a", { class: "icon-btn", href: `/admin/backups/${encodeURIComponent(b.name)}`, download: b.name, title: "Download", "aria-label": `Download ${b.name}` }, icon("download")),
|
||||
h("button", {
|
||||
type: "button", class: "icon-btn icon-btn--danger", title: "Delete", "aria-label": `Delete ${b.name}`,
|
||||
onclick: async () => {
|
||||
if (!await confirmDialog({ title: "Delete this backup?", message: `${b.name} will be removed from the server.`, confirm: "Delete", danger: true })) return;
|
||||
try {
|
||||
await api("DELETE", `/api/backups/${encodeURIComponent(b.name)}`);
|
||||
toast("Backup deleted");
|
||||
await reload();
|
||||
} catch (err) { toast(err.message, { error: true }); }
|
||||
},
|
||||
}, icon("trash")),
|
||||
),
|
||||
);
|
||||
|
||||
now.addEventListener("click", async () => {
|
||||
busy(now, true);
|
||||
try {
|
||||
await api("POST", "/api/backups");
|
||||
toast("Backup created");
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
} finally {
|
||||
busy(now, false);
|
||||
reload().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
const form = h("form", { class: "stack" },
|
||||
h("div", { class: "row" },
|
||||
field("Schedule", preset),
|
||||
field("Scheduled backups to keep", keep, "Older ones are deleted. Manual backups are kept until you delete them."),
|
||||
),
|
||||
cronField,
|
||||
status,
|
||||
h("div", { class: "settings-actions" }, now, save),
|
||||
);
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
busy(save, true);
|
||||
try {
|
||||
render(await api("PUT", "/api/backups/schedule", {
|
||||
cron: preset.value === "custom" ? cron.value : preset.value,
|
||||
keep: Number(keep.value),
|
||||
}));
|
||||
toast("Backup schedule saved");
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
} finally {
|
||||
busy(save, false);
|
||||
}
|
||||
});
|
||||
|
||||
const hint = h("span", { class: "field-hint" });
|
||||
const el = section("backups", "Backups",
|
||||
"Each backup is a .tar.gz holding your database and every image, so it's all you need to restore.",
|
||||
form, h("div", { class: "field" }, h("span", { class: "field-label" }, "Saved backups"), list, empty, hint));
|
||||
reload().then(() => {
|
||||
hint.textContent = `Stored in ${data.dir} on the server. Schedules use the server's time zone, ${data.timezone}.`;
|
||||
}).catch((err) => status.replaceChildren(err.message));
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---------- account ----------
|
||||
|
||||
function accountSection() {
|
||||
@@ -358,7 +479,7 @@ async function init() {
|
||||
root.replaceChildren(h("p", { class: "alert alert--error" }, icon("alert"), err.message));
|
||||
return;
|
||||
}
|
||||
root.replaceChildren(profileSection(), appearanceSection(), integrationsSection(), accountSection());
|
||||
root.replaceChildren(profileSection(), appearanceSection(), integrationsSection(), backupsSection(), accountSection());
|
||||
root.removeAttribute("aria-busy");
|
||||
if (location.hash) document.querySelector(location.hash)?.scrollIntoView();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<a href="#profile">{{icon "user"}}Profile</a>
|
||||
<a href="#appearance">{{icon "palette"}}Appearance</a>
|
||||
<a href="#integrations">{{icon "plug"}}Integrations</a>
|
||||
<a href="#backups">{{icon "archive"}}Backups</a>
|
||||
<a href="#account">{{icon "lock"}}Account</a>
|
||||
</nav>
|
||||
<div class="settings-body" id="settings" aria-busy="true">
|
||||
|
||||
Reference in new issue
Block a user