Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ ERPNext is not required.
- **Encrypted off-site snapshots:** Restic encrypts and deduplicates; credentials stay in
environment variables and are never shown in the desk.
- **Schedules:** pick weekdays and times in the site's timezone, or run a backup by hand.
- **Backup Control page:** backup history, off-site snapshot state, settings and actions in
- **Backup Control page:** backup history, off-site snapshot state and actions in
one place, restricted to System Managers.
- **Retention:** local backup sets and off-site snapshots are pruned automatically; the
repository is checked weekly.
Expand Down Expand Up @@ -63,11 +63,11 @@ server and the background workers):
Use a separate repository path per environment. The repository is initialised on the
first upload.

Then open **Restic Backup Settings**:
Then open **Restic Backup → Backup Settings**:

- **Enable Scheduled Backups** and add schedule rows.
- **Local Backup Sets** (default 4) are kept on the server after a successful upload.
- **Remote Retention (Days)** (default 14) keeps snapshots in that window, and always the newest.
- **Off-site Retention (Days)** (default 14) keeps snapshots in that window, and always the newest.
- **Email on Success** also emails System Managers about successful runs. They are always
told about failures, in the notification bell and by email.

Expand Down
8 changes: 2 additions & 6 deletions frappe_restic/public/scss/restic_backups.bundle.scss
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
// Frappe themes its own .frappe-card, but Bootstrap's .card stays white in dark mode.
.restic-backup-control,
.restic-backup-settings-dialog {
.restic-backup-settings {
.card {
border-color: var(--border-color);
background-color: var(--card-bg);
}
}

.restic-backup-settings-dialog {
.modal-dialog {
max-width: 880px;
}

.restic-backup-settings {
.restic-backup-schedule-editor {
display: grid;
gap: 0.75rem;
Expand Down
9 changes: 5 additions & 4 deletions frappe_restic/restic_backup/backup_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from frappe.utils import cint, get_time, now_datetime
from frappe.utils.background_jobs import enqueue
from frappe.utils.backups import BackupGenerator, get_backup_path
from frappe.utils.user import get_users_with_role
from redis.exceptions import LockError

from frappe_restic.config import namespace
Expand Down Expand Up @@ -475,10 +474,12 @@ def _send_alert(subject: str, message: str, *, succeeded: bool = False, run_name
if succeeded and not _settings_int("email_on_success", 0):
return "Not requested"
try:
# Frappe's own role lookups leave out Administrator, the only manager on a small site.
names = frappe.get_all(
"Has Role", filters={"role": SYSTEM_MANAGER_ROLE, "parenttype": "User"}, pluck="parent"
)
managers = frappe.get_all(
"User",
filters={"name": ["in", get_users_with_role(SYSTEM_MANAGER_ROLE)]},
fields=["name", "email"],
"User", filters={"name": ["in", names], "enabled": 1}, fields=["name", "email"]
)
except Exception: # notification failure must not replace the operation outcome
frappe.logger("restic_backup").exception("Could not find the System Managers to notify")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
],
"fields": [
{
"description": "Comma-separated weekdays. Backup Control presents these as selectable day pills.",
"description": "Comma-separated weekdays. Backup Settings presents these as selectable day pills.",
"fieldname": "days",
"fieldtype": "Data",
"in_list_view": 1,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,197 @@
const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];

frappe.ui.form.on("Restic Backup Settings", {
refresh(frm) {
frm.page.set_title(__("Backup Settings"));
frm.add_custom_button(__("Back to Backup Control"), () => {
frappe.set_route("restic-backup-control");
});
bind_schedule_editor(frm);
render_schedule_editor(frm);
render_storage_configuration(frm);
},

validate(frm) {
const problem = schedule_problem(frm.doc.backup_schedule || []);
if (problem) {
frappe.msgprint(problem);
frappe.validated = false;
}
},
});

function selected_days(row) {
return (row.days || "")
.split(",")
.map((day) => day.trim())
.filter(Boolean);
}

// A time loaded from the database has no leading zero ("9:00:00"), which a time input rejects.
function clock_time(value) {
if (!value) return "";
return String(value)
.split(":")
.slice(0, 2)
.map((part) => part.padStart(2, "0"))
.join(":");
}

// The schedule is stored in the hidden table; this editor only reads and writes its rows.
function render_schedule_editor(frm) {
const rows = (frm.doc.backup_schedule || [])
.map((row, index) => {
const days = selected_days(row);
const day_buttons = WEEKDAYS.map((day) => {
const selected = days.includes(day);
return `<button type="button"
class="btn btn-sm ${selected ? "btn-primary" : "btn-default"} restic-backup-day"
data-index="${index}"
data-day="${day}"
aria-pressed="${selected}">${__(day.slice(0, 3))}</button>`;
}).join("");
return `<div class="restic-backup-schedule-row">
<div class="restic-backup-schedule-days">
<div class="restic-backup-field-label">${__("Days")}</div>
<div class="restic-backup-day-list">${day_buttons}</div>
</div>
<label class="restic-backup-schedule-time">
<span class="restic-backup-field-label">${__("Time")}</span>
<input type="time" class="form-control" data-index="${index}"
value="${frappe.utils.escape_html(clock_time(row.backup_time))}">
</label>
<button type="button" class="btn btn-link text-danger restic-backup-remove"
data-index="${index}" aria-label="${__("Remove schedule")}">${__("Remove")}</button>
</div>`;
})
.join("");

frm.fields_dict.schedule_editor.$wrapper.addClass("restic-backup-settings")
.html(`<div class="restic-backup-schedule-editor">
<p class="text-muted small mb-0">${__(
"Each row runs once at the selected time on every selected day."
)}</p>
${rows || `<div class="text-muted restic-backup-empty">${__("No schedule rows yet.")}</div>`}
<button type="button" class="btn btn-default btn-sm restic-backup-add">
${__("Add schedule")}
</button>
</div>`);
}

function bind_schedule_editor(frm) {
const $wrapper = frm.fields_dict.schedule_editor.$wrapper;
const row_at = (element) => frm.doc.backup_schedule[Number(element.dataset.index)];

$wrapper.off(".resticBackupSchedule");
$wrapper.on("click.resticBackupSchedule", ".restic-backup-day", (event) => {
const row = row_at(event.currentTarget);
const day = event.currentTarget.dataset.day;
const days = selected_days(row);
row.days = WEEKDAYS.filter((value) =>
value === day ? !days.includes(day) : days.includes(value)
).join(",");
frm.dirty();
render_schedule_editor(frm);
});
$wrapper.on("change.resticBackupSchedule", 'input[type="time"]', (event) => {
row_at(event.currentTarget).backup_time = event.currentTarget.value;
frm.dirty();
});
$wrapper.on("click.resticBackupSchedule", ".restic-backup-remove", (event) => {
const row = row_at(event.currentTarget);
frappe.model.clear_doc(row.doctype, row.name);
frm.dirty();
render_schedule_editor(frm);
});
$wrapper.on("click.resticBackupSchedule", ".restic-backup-add", () => {
frm.add_child("backup_schedule", { days: "", backup_time: "" });
frm.dirty();
render_schedule_editor(frm);
});
}

function schedule_problem(rows) {
if (!rows.length) {
return __("Add at least one backup schedule.");
}
const scheduled_slots = new Map();
for (const [index, row] of rows.entries()) {
const days = selected_days(row);
const time = clock_time(row.backup_time);
if (!days.length || !time) {
return __("Choose at least one day and a time in schedule row {0}.", [index + 1]);
}
for (const day of days) {
const slot = `${day}|${time}`;
if (scheduled_slots.has(slot)) {
return __("Schedule rows {0} and {1} both include {2} at {3}.", [
scheduled_slots.get(slot),
index + 1,
__(day),
time,
]);
}
scheduled_slots.set(slot, index + 1);
}
}
return null;
}

async function render_storage_configuration(frm) {
const configuration = await frappe.xcall(
"frappe_restic.restic_backup.page.restic_backup_control.restic_backup_control.get_configuration"
);
frm.fields_dict.offsite_configuration.$wrapper
.addClass("restic-backup-settings")
.html(storage_configuration_html(configuration));
}

function storage_configuration_html(configuration) {
const status_rows = configuration.required_variables
.map((variable) => {
const indicator = variable.configured ? "green" : "red";
const status = variable.configured ? __("Configured") : __("Missing");
return `<div class="restic-backup-env-row">
<code>${frappe.utils.escape_html(variable.name)}</code>
<span class="indicator-pill ${indicator}">${status}</span>
</div>`;
})
.join("");
const deployment_status = configuration.offsite_enabled
? `<span class="indicator-pill green">${__("Enabled")}</span>`
: `<span class="indicator-pill orange">${__("Disabled")}</span>`;
const storage_status = configuration.restic_ready
? `<span class="indicator-pill green">${__("Configured")}</span>`
: `<span class="indicator-pill red">${__("Credentials missing")}</span>`;
return `<p class="text-muted">${
configuration.offsite_enabled
? __("Off-site uploads are enabled by deployment configuration.")
: __(
"Off-site uploads are disabled by deployment configuration. Scheduled and manual backups stay local."
)
}</p>
<details class="card restic-backup-offsite">
<summary class="restic-backup-offsite-summary">
<strong>${__("Storage configuration")}</strong>
${storage_status}
</summary>
<div class="card-body border-top">
<p class="mb-1">${__(
"Set these in the deployment environment, not in Frappe, then restart or redeploy."
)}</p>
<p class="text-muted small mb-3">${__(
"Coolify: application Environment Variables. Docker Compose: the .env file supplied to Compose."
)}</p>
${status_rows}
<div class="restic-backup-env-row pt-3">
<span><code>RESTIC_OFFSITE_BACKUP_ENABLED</code> <span class="text-muted">(${__(
"all backup uploads"
)})</span></span>
${deployment_status}
</div>
<p class="text-muted small mt-3 mb-0">${__(
"RESTIC_REPOSITORY format: s3:https://s3.<region>.backblazeb2.com/<bucket>/restic. Use a bucket-restricted Backblaze application key."
)}</p>
</div>
</details>`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
"field_order": [
"enabled",
"schedule_section",
"schedule_editor",
"backup_schedule",
"storage_section",
"offsite_configuration",
"retention_section",
"local_backup_limit",
"retention_column",
Expand All @@ -27,12 +30,25 @@
"fieldtype": "Section Break",
"label": "Schedule"
},
{
"fieldname": "schedule_editor",
"fieldtype": "HTML"
},
{
"fieldname": "backup_schedule",
"fieldtype": "Table",
"hidden": 1,
"label": "Schedule",
"options": "Restic Backup Schedule Entry",
"reqd": 1
"options": "Restic Backup Schedule Entry"
},
{
"fieldname": "storage_section",
"fieldtype": "Section Break",
"label": "Off-site Storage"
},
{
"fieldname": "offsite_configuration",
"fieldtype": "HTML"
},
{
"fieldname": "retention_section",
Expand All @@ -41,7 +57,7 @@
},
{
"default": "4",
"description": "Complete default-path backup sets retained locally after successful off-site upload.",
"description": "Complete backup sets kept on this server.",
"fieldname": "local_backup_limit",
"fieldtype": "Int",
"label": "Local Backup Sets",
Expand All @@ -53,10 +69,10 @@
},
{
"default": "14",
"description": "Restic keeps snapshots within this window and always keeps the newest snapshot.",
"description": "The newest off-site snapshot is always retained.",
"fieldname": "remote_keep_days",
"fieldtype": "Int",
"label": "Remote Retention (Days)",
"label": "Off-site Retention (Days)",
"reqd": 1
},
{
Expand All @@ -74,7 +90,7 @@
],
"issingle": 1,
"links": [],
"modified": "2026-10-07 19:00:00.000000",
"modified": "2026-10-07 20:00:00.000000",
"modified_by": "Administrator",
"module": "Restic Backup",
"name": "Restic Backup Settings",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def validate(self) -> None:
if not 1 <= cint(self.local_backup_limit) <= 30:
frappe.throw(_("Local Backup Sets must be between 1 and 30."))
if not 1 <= cint(self.remote_keep_days) <= 3650:
frappe.throw(_("Remote Retention must be between 1 and 3650 days."))
frappe.throw(_("Off-site Retention must be between 1 and 3650 days."))

frappe.db.set_single_value("System Settings", "backup_limit", cint(self.local_backup_limit))

Expand Down
Loading
Loading