backup & restore client-side code

This commit is contained in:
Régis Hanol 2014-02-12 20:35:46 -08:00
parent 310a439f3d
commit babcc3fc50
11 changed files with 451 additions and 0 deletions

View File

@ -0,0 +1 @@
Discourse.AdminBackupsController = Ember.ObjectController.extend({});

View File

@ -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);
});
},
});

View File

@ -0,0 +1,4 @@
Discourse.AdminBackupsLogsController = Ember.ArrayController.extend({
needs: ["adminBackups"],
status: Em.computed.alias("controllers.adminBackups"),
});

View File

@ -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("/");
}
});
},
});

View File

@ -0,0 +1,7 @@
Discourse.AdminBackupsIndexRoute = Discourse.Route.extend({
model: function() {
return Discourse.Backup.find();
}
});

View File

@ -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(); }
}
);
},
}
});

View File

@ -18,6 +18,7 @@
{{#if currentUser.admin}}
<li>{{#link-to 'admin.customize'}}{{i18n admin.customize.title}}{{/link-to}}</li>
<li>{{#link-to 'admin.api'}}{{i18n admin.api.title}}{{/link-to}}</li>
<li>{{#link-to 'admin.backups'}}{{i18n admin.backups.title}}{{/link-to}}</li>
{{/if}}
</ul>

View File

@ -0,0 +1,19 @@
<div class="admin-controls">
<div class="span15">
<ul class="nav nav-pills">
<li>{{#link-to "admin.backups.index"}}{{i18n admin.backups.menu.backups}}{{/link-to}}</li>
<li>{{#link-to "admin.backups.logs"}}{{i18n admin.backups.menu.logs}}{{/link-to}}</li>
</ul>
</div>
<div class="pull-right">
{{#if isOperationRunning}}
<button {{action cancelOperation}} class="btn btn-danger" title="{{i18n admin.backups.operations.cancel.title}}"><i class="fa fa-times"></i>{{i18n admin.backups.operations.cancel.text}}</button>
{{else}}
<button {{action startBackup}} class="btn btn-primary" title="{{i18n admin.backups.operations.backup.title}}"><i class="fa fa-rocket"></i>{{i18n admin.backups.operations.backup.text}}</button>
{{/if}}
</div>
</div>
<div class="admin-container">
{{outlet}}
</div>

View File

@ -0,0 +1,29 @@
<table>
<tr>
<th width="40%">{{i18n admin.backups.columns.filename}}</th>
<th width="30%">{{i18n admin.backups.columns.size}}</th>
<th>
{{#if status.canRollback}}
<button {{action rollback}} class="btn btn-rollback" title="{{i18n admin.backups.operations.rollback.title}}" {{bind-attr disabled=rollbackDisabled}}><i class="fa fa-ambulance fa-flip-horizontal"></i>{{i18n admin.backups.operations.rollback.text}}</button>
{{/if}}
<button {{action toggleReadOnlyMode}} class="btn" {{bind-attr title=readOnlyModeTitle}}><i class="fa fa-eye"></i>{{readOnlyModeText}}</button>
</th>
</tr>
{{#each backup in model}}
<tr>
<td>{{backup.filename}}</td>
<td>{{humanSize backup.size}}</td>
<td>
<a {{bind-attr href="backup.link"}} class="btn download" title="{{i18n admin.backups.operations.download.title}}"><i class="fa fa-download"></i>{{i18n admin.backups.operations.download.text}}</a>
<button {{action destroyBackup backup}} class="btn btn-danger" {{bind-attr disabled="status.isOperationRunning" title="destroyTitle"}}><i class="fa fa-trash-o"></i>{{i18n admin.backups.operations.destroy.text}}</button>
<button {{action startRestore backup}} class="btn" {{bind-attr disabled="restoreDisabled" title="restoreTitle"}}><i class="fa fa-undo"></i>{{i18n admin.backups.operations.restore.text}}</button>
</td>
</tr>
{{else}}
<tr>
<td>{{i18n admin.backups.none}}</td>
<td></td>
<td></td>
</tr>
{{/each}}
</table>

View File

@ -0,0 +1,50 @@
Discourse.AdminBackupsLogsView = Discourse.View.extend({
classNames: ["admin-backups-logs"],
_initialize: function() { this._reset(); }.on("init"),
_reset: function() {
this.setProperties({ formattedLogs: "", index: 0 });
},
_updateFormattedLogs: function() {
var logs = this.get("controller.model");
if (logs.length === 0) {
this._reset(); // reset the cached logs whenever the model is reset
} else {
// do the log formatting only once for HELLish performance
var formattedLogs = this.get("formattedLogs");
for (var i = this.get("index"), length = logs.length; i < length; i++) {
var date = moment(logs[i].get("timestamp")).format("YYYY-MM-DD HH:mm:ss"),
message = Handlebars.Utils.escapeExpression(logs[i].get("message"));
formattedLogs += "[" + date + "] " + message + "\n";
}
// update the formatted logs & cache index
this.setProperties({ formattedLogs: formattedLogs, index: logs.length });
// force rerender
this.rerender();
}
}.observes("controller.model.@each"),
render: function(buffer) {
var formattedLogs = this.get("formattedLogs");
if (formattedLogs && formattedLogs.length > 0) {
buffer.push("<pre>");
buffer.push(formattedLogs);
buffer.push("</pre>");
} else {
buffer.push("<p>" + I18n.t("admin.backups.logs.none") + "</p>");
}
// add a loading indicator
if (this.get("controller.status.isOperationRunning")) {
buffer.push("<i class='fa fa-spinner fa-spin'></i>");
}
},
_forceScrollToBottom: function() {
var $div = this.$()[0];
$div.scrollTop = $div.scrollHeight;
}.on("didInsertElement")
});

View File

@ -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")
});