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:
cdnutterandClaude Opus 5.5 committed 2026-09-30 14:30:40 -07:00
1 parent 72b49d709a
commit f3185ba7bd
18 files changed
+940 -10

No files matched your search

+8
View File
@@ -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; }
+1
View File
@@ -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

+124 -3
View File
@@ -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();
}
+1
View File
@@ -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">