diff --git a/app/assets/javascripts/admin/controllers/admin_backups_controller.js b/app/assets/javascripts/admin/controllers/admin_backups_controller.js new file mode 100644 index 00000000000..3af633dbc09 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_backups_controller.js @@ -0,0 +1 @@ +Discourse.AdminBackupsController = Ember.ObjectController.extend({}); diff --git a/app/assets/javascripts/admin/controllers/admin_backups_index_controller.js b/app/assets/javascripts/admin/controllers/admin_backups_index_controller.js new file mode 100644 index 00000000000..c716ad4f329 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_backups_index_controller.js @@ -0,0 +1,77 @@ +Discourse.AdminBackupsIndexController = Ember.ArrayController.extend({ + needs: ["adminBackups"], + status: Em.computed.alias("controllers.adminBackups"), + + rollbackDisabled: Em.computed.not("rollbackEnabled"), + + rollbackEnabled: function() { + return this.get("status.canRollback") && this.get("restoreEnabled"); + }.property("status.canRollback", "restoreEnabled"), + + restoreDisabled: Em.computed.not("restoreEnabled"), + + restoreEnabled: function() { + return Discourse.SiteSettings.allow_import && !this.get("status.isOperationRunning"); + }.property("status.isOperationRunning"), + + restoreTitle: function() { + if (!Discourse.SiteSettings.allow_import) { + return I18n.t("admin.backups.operations.restore.is_disabled"); + } else if (this.get("status.isOperationRunning")) { + return I18n.t("admin.backups.operation_already_running"); + } else { + return I18n.t("admin.backups.operations.restore.title"); + } + }.property("status.isOperationRunning"), + + destroyTitle: function() { + if (this.get("status.isOperationRunning")) { + return I18n.t("admin.backups.operation_already_running"); + } else { + return I18n.t("admin.backups.operations.destroy.title"); + } + }.property("status.isOperationRunning"), + + readOnlyModeTitle: function() { return this._readOnlyModeI18n("title"); }.property("Discourse.isReadOnly"), + readOnlyModeText: function() { return this._readOnlyModeI18n("text"); }.property("Discourse.isReadOnly"), + + _readOnlyModeI18n: function(value) { + var action = Discourse.get("isReadOnly") ? "disable" : "enable"; + return I18n.t("admin.backups.read_only." + action + "." + value); + }, + + actions: { + + /** + Toggle read-only mode + + @method toggleReadOnlyMode + **/ + toggleReadOnlyMode: function() { + var self = this; + if (!Discourse.get("isReadOnly")) { + bootbox.confirm( + I18n.t("admin.backups.read_only.enable.confirm"), + I18n.t("no_value"), + I18n.t("yes_value"), + function(confirmed) { + if (confirmed) { self._toggleReadOnlyMode(true); } + } + ); + } else { + this._toggleReadOnlyMode(false); + } + }, + + }, + + _toggleReadOnlyMode: function(enable) { + Discourse.ajax("/admin/backups/readonly", { + type: "PUT", + data: { enable: enable } + }).then(function() { + Discourse.set("isReadOnly", enable); + }); + }, + +}); diff --git a/app/assets/javascripts/admin/controllers/admin_backups_logs_controller.js b/app/assets/javascripts/admin/controllers/admin_backups_logs_controller.js new file mode 100644 index 00000000000..1a9f8e7ccf0 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_backups_logs_controller.js @@ -0,0 +1,4 @@ +Discourse.AdminBackupsLogsController = Ember.ArrayController.extend({ + needs: ["adminBackups"], + status: Em.computed.alias("controllers.adminBackups"), +}); diff --git a/app/assets/javascripts/admin/models/backup.js b/app/assets/javascripts/admin/models/backup.js new file mode 100644 index 00000000000..48cbe0e9d17 --- /dev/null +++ b/app/assets/javascripts/admin/models/backup.js @@ -0,0 +1,90 @@ +/** + Data model for representing a backup + + @class Backup + @extends Discourse.Model + @namespace Discourse + @module Discourse +**/ +Discourse.Backup = Discourse.Model.extend({ + + /** + Destroys the current backup + + @method destroy + @returns {Promise} a promise that resolves when the backup has been destroyed + **/ + destroy: function() { + return Discourse.ajax("/admin/backups/" + this.get("filename"), { type: "DELETE" }); + }, + + /** + Starts the restoration of the current backup + + @method restore + @returns {Promise} a promise that resolves when the backup has started being restored + **/ + restore: function() { + return Discourse.ajax("/admin/backups/" + this.get("filename") + "/restore", { type: "POST" }); + } + +}); + +Discourse.Backup.reopenClass({ + + /** + Finds a list of backups + + @method find + @returns {Promise} a promise that resolves to the array of {Discourse.Backup} backup + **/ + find: function() { + return PreloadStore.getAndRemove("backups", function() { + return Discourse.ajax("/admin/backups.json"); + }).then(function(backups) { + return backups.map(function (backup) { return Discourse.Backup.create(backup); }); + }); + }, + + /** + Starts a backup + + @method start + @returns {Promise} a promise that resolves when the backup has started + **/ + start: function() { + return Discourse.ajax("/admin/backups", { type: "POST" }).then(function(result) { + if (!result.success) { bootbox.alert(result.message); } + }); + }, + + /** + Cancels a backup + + @method cancel + @returns {Promise} a promise that resolves when the backup has been cancelled + **/ + cancel: function() { + return Discourse.ajax("/admin/backups/cancel.json").then(function(result) { + if (!result.success) { bootbox.alert(result.message); } + }); + }, + + /** + Rollbacks the database to the previous working state + + @method rollback + @returns {Promise} a promise that resolves when the rollback is done + **/ + rollback: function() { + return Discourse.ajax("/admin/backups/rollback.json").then(function(result) { + if (!result.success) { + bootbox.alert(result.message); + } else { + // redirect to homepage (session might be lost) + window.location.pathname = Discourse.getURL("/"); + } + }); + }, + +}); diff --git a/app/assets/javascripts/admin/routes/admin_backups_index_route.js b/app/assets/javascripts/admin/routes/admin_backups_index_route.js new file mode 100644 index 00000000000..8ba5e6539fa --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_backups_index_route.js @@ -0,0 +1,7 @@ +Discourse.AdminBackupsIndexRoute = Discourse.Route.extend({ + + model: function() { + return Discourse.Backup.find(); + } + +}); diff --git a/app/assets/javascripts/admin/routes/admin_backups_route.js b/app/assets/javascripts/admin/routes/admin_backups_route.js new file mode 100644 index 00000000000..f77cf10b49c --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_backups_route.js @@ -0,0 +1,150 @@ +Discourse.AdminBackupsRoute = Discourse.Route.extend({ + + LOG_CHANNEL: "/admin/backups/logs", + + activate: function() { + Discourse.MessageBus.subscribe(this.LOG_CHANNEL, this._processLogMessage.bind(this)); + }, + + _processLogMessage: function(log) { + if (log.message === "[STARTED]") { + this.controllerFor("adminBackups").set("isOperationRunning", true); + this.controllerFor("adminBackupsLogs").clear(); + } else if (log.message === "[FAILED]") { + this.controllerFor("adminBackups").set("isOperationRunning", false); + bootbox.alert(I18n.t("admin.backups.operations.failed", { operation: log.operation })); + } else if (log.message === "[SUCCESS]") { + this.controllerFor("adminBackups").set("isOperationRunning", false); + if (log.operation === "restore") { + // redirect to homepage when the restore is done (session might be lost) + window.location.pathname = Discourse.getURL("/"); + } + } else { + this.controllerFor("adminBackupsLogs").pushObject(Em.Object.create(log)); + } + }, + + model: function() { + return PreloadStore.getAndRemove("operations_status", function() { + return Discourse.ajax("/admin/backups/status.json"); + }).then(function (status) { + return Em.Object.create({ + isOperationRunning: status.is_operation_running, + canRollback: status.can_rollback, + }); + }); + }, + + deactivate: function() { + Discourse.MessageBus.unsubscribe(this.LOG_CHANNEL); + }, + + actions: { + /** + Starts a backup and redirect the user to the logs tab + + @method startBackup + **/ + startBackup: function() { + var self = this; + bootbox.confirm( + I18n.t("admin.backups.operations.backup.confirm"), + I18n.t("no_value"), + I18n.t("yes_value"), + function(confirmed) { + if (confirmed) { + Discourse.Backup.start().then(function() { + self.controllerFor("adminBackupsLogs").clear(); + self.controllerFor("adminBackups").set("isOperationRunning", true); + self.transitionTo("admin.backups.logs"); + }); + } + } + ); + }, + + /** + Destroys a backup + + @method destroyBackup + @param {Discourse.Backup} the backup to destroy + **/ + destroyBackup: function(backup) { + var self = this; + bootbox.confirm( + I18n.t("admin.backups.operations.destroy.confirm"), + I18n.t("no_value"), + I18n.t("yes_value"), + function(confirmed) { + if (confirmed) { + backup.destroy().then(function() { + self.controllerFor("adminBackupsIndex").removeObject(backup); + }); + } + } + ); + }, + + /** + Start a restore and redirect the user to the logs tab + + @method startRestore + @param {Discourse.Backup} the backup to restore + **/ + startRestore: function(backup) { + var self = this; + bootbox.confirm( + I18n.t("admin.backups.operations.restore.confirm"), + I18n.t("no_value"), + I18n.t("yes_value"), + function(confirmed) { + if (confirmed) { + backup.restore().then(function() { + self.controllerFor("adminBackupsLogs").clear(); + self.controllerFor("adminBackups").set("isOperationRunning", true); + self.transitionTo("admin.backups.logs"); + }); + } + } + ); + }, + + /** + Cancels the current operation + + @method cancelOperation + **/ + cancelOperation: function() { + var self = this; + bootbox.confirm( + I18n.t("admin.backups.operations.cancel.confirm"), + I18n.t("no_value"), + I18n.t("yes_value"), + function(confirmed) { + if (confirmed) { + Discourse.Backup.cancel().then(function() { + self.controllerFor("adminBackups").set("isOperationRunning", false); + }); + } + } + ); + }, + + /** + Rollback to previous working state + + @method rollback + **/ + rollback: function() { + bootbox.confirm( + I18n.t("admin.backups.operations.rollback.confirm"), + I18n.t("no_value"), + I18n.t("yes_value"), + function(confirmed) { + if (confirmed) { Discourse.Backup.rollback(); } + } + ); + }, + } + +}); diff --git a/app/assets/javascripts/admin/templates/admin.js.handlebars b/app/assets/javascripts/admin/templates/admin.js.handlebars index acb69cde5f2..1cbe5c3a4b4 100644 --- a/app/assets/javascripts/admin/templates/admin.js.handlebars +++ b/app/assets/javascripts/admin/templates/admin.js.handlebars @@ -18,6 +18,7 @@ {{#if currentUser.admin}}
{{i18n admin.backups.columns.filename}} | +{{i18n admin.backups.columns.size}} | ++ {{#if status.canRollback}} + + {{/if}} + + | +
---|---|---|
{{backup.filename}} | +{{humanSize backup.size}} | ++ {{i18n admin.backups.operations.download.text}} + + + | +
{{i18n admin.backups.none}} | ++ | + |
"); + buffer.push(formattedLogs); + buffer.push(""); + } else { + buffer.push("
" + I18n.t("admin.backups.logs.none") + "
"); + } + // add a loading indicator + if (this.get("controller.status.isOperationRunning")) { + buffer.push(""); + } + }, + + _forceScrollToBottom: function() { + var $div = this.$()[0]; + $div.scrollTop = $div.scrollHeight; + }.on("didInsertElement") + +}); diff --git a/app/assets/javascripts/admin/views/admin_backups_view.js b/app/assets/javascripts/admin/views/admin_backups_view.js new file mode 100644 index 00000000000..d1b049f9ac2 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_backups_view.js @@ -0,0 +1,23 @@ +Discourse.AdminBackupsView = Discourse.View.extend({ + classNames: ["admin-backups"], + + _hijackDownloads: function() { + this.$().on("mouseup.admin-backups", "a.download", function (e) { + var $link = $(e.currentTarget); + + if (!$link.data("href")) { + $link.addClass("no-href"); + $link.data("href", $link.attr("href")); + $link.attr("href", null); + $link.data("auto-route", true); + } + + Discourse.URL.redirectTo($link.data("href")); + }); + }.on("didInsertElement"), + + _removeBindings: function() { + this.$().off("mouseup.admin-backups"); + }.on("willDestroyElement") + +});