diff --git a/README.md b/README.md index 81a1438..de70c27 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/frappe_restic/public/scss/restic_backups.bundle.scss b/frappe_restic/public/scss/restic_backups.bundle.scss index b75c24f..7b9b8c8 100644 --- a/frappe_restic/public/scss/restic_backups.bundle.scss +++ b/frappe_restic/public/scss/restic_backups.bundle.scss @@ -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; diff --git a/frappe_restic/restic_backup/backup_control.py b/frappe_restic/restic_backup/backup_control.py index 2946b3f..bc818c5 100644 --- a/frappe_restic/restic_backup/backup_control.py +++ b/frappe_restic/restic_backup/backup_control.py @@ -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 @@ -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") diff --git a/frappe_restic/restic_backup/doctype/restic_backup_schedule_entry/restic_backup_schedule_entry.json b/frappe_restic/restic_backup/doctype/restic_backup_schedule_entry/restic_backup_schedule_entry.json index 72ad1a7..3f64904 100644 --- a/frappe_restic/restic_backup/doctype/restic_backup_schedule_entry/restic_backup_schedule_entry.json +++ b/frappe_restic/restic_backup/doctype/restic_backup_schedule_entry/restic_backup_schedule_entry.json @@ -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, diff --git a/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.js b/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.js index c40f3ea..2cbbd55 100644 --- a/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.js +++ b/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.js @@ -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 ``; + }).join(""); + return `
${__( + "Each row runs once at the selected time on every selected day." + )}
+ ${rows || `${frappe.utils.escape_html(variable.name)}
+ ${status}
+ ${ + 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." + ) + }
+${__( + "Set these in the deployment environment, not in Frappe, then restart or redeploy." + )}
+${__( + "Coolify: application Environment Variables. Docker Compose: the .env file supplied to Compose." + )}
+ ${status_rows} +RESTIC_OFFSITE_BACKUP_ENABLED (${__(
+ "all backup uploads"
+ )})
+ ${deployment_status}
+ ${__(
+ "RESTIC_REPOSITORY format: s3:https://s3.
${ - this.data.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." - ) - }
`, - }, - { - fieldtype: "HTML", - fieldname: "backup_schedule", - }, - { fieldtype: "Section Break", label: __("Off-site storage") }, - { - fieldtype: "HTML", - fieldname: "offsite_configuration", - options: this.offsiteConfigurationHtml(this.data.configuration), - }, - { fieldtype: "Section Break", label: __("Retention") }, - { - fieldtype: "Int", - fieldname: "local_backup_limit", - label: __("Local backup sets"), - reqd: true, - default: settings.local_backup_limit, - description: __("Complete backup sets kept on this server."), - }, - { fieldtype: "Column Break" }, - { - fieldtype: "Int", - fieldname: "remote_keep_days", - label: __("Off-site retention (days)"), - reqd: true, - default: settings.remote_keep_days, - description: __("The newest off-site snapshot is always retained."), - }, - { fieldtype: "Section Break", label: __("Email notifications") }, - { - fieldtype: "Check", - fieldname: "email_on_success", - label: __("Email successful runs"), - default: settings.email_on_success, - description: __( - "System Managers are always told about failures, in the notification bell and by email." - ), - }, - ], - primary_action_label: __("Save"), - primary_action: async (values) => { - const enabled = Boolean(Number(values.enabled)); - if (enabled && !this.validateSchedule(schedule)) return; - const primaryButton = dialog.get_primary_btn(); - primaryButton.prop("disabled", true); - try { - this.data.settings = await frappe.xcall( - "frappe_restic.restic_backup.page.restic_backup_control.restic_backup_control.save_settings", - { - values: { - ...values, - backup_schedule: enabled ? schedule : settings.schedule, - }, - } - ); - dialog.hide(); - this.render(); - frappe.show_alert({ - message: __("Backup settings saved"), - indicator: "green", - }); - } finally { - primaryButton.prop("disabled", false); - } - }, - }); - dialog.show(); - dialog.$wrapper.addClass("restic-backup-settings-dialog"); - dialog.fields_dict.enabled.$input.on("change.resticBackupSchedule", () => { - this.setScheduleEditorVisibility(dialog); - }); - this.renderScheduleEditor(dialog.fields_dict.backup_schedule.$wrapper, schedule, weekdays); - this.setScheduleEditorVisibility(dialog); - } - - setScheduleEditorVisibility(dialog) { - const enabled = Boolean(Number(dialog.get_value("enabled"))); - dialog.fields_dict.backup_schedule.$wrapper.toggle(enabled); - } - - renderScheduleEditor($wrapper, schedule, weekdays) { - const render = () => { - const rows = schedule - .map((entry, index) => { - const dayButtons = weekdays - .map((day) => { - const selected = entry.days.includes(day); - return ``; - }) - .join(""); - return `${__( - "Each row runs once at the selected time on every selected day." - )}
- ${rows || `${frappe.utils.escape_html(variable.name)}
- ${status}
- ${__( - "Set these in the deployment environment, not in Frappe, then restart or redeploy." - )}
-${__( - "Coolify: application Environment Variables. Docker Compose: the .env file supplied to Compose." - )}
- ${statusRows} -RESTIC_OFFSITE_BACKUP_ENABLED (${__(
- "all backup uploads"
- )})
- ${deploymentStatus}
- ${__(
- "RESTIC_REPOSITORY format: s3:https://s3.