diff --git a/auslib/admin/views/forms.py b/auslib/admin/views/forms.py index 24293175c5..2226b72663 100644 --- a/auslib/admin/views/forms.py +++ b/auslib/admin/views/forms.py @@ -352,7 +352,7 @@ class EditScheduledChangeNewReleaseForm(ScheduledChangeTimeForm): Release.""" name = StringField('Name', validators=[Optional()]) product = StringField('Product', validators=[Optional()]) - data = JSONStringField({}, 'Data', validators=[Optional()], widget=FileInput()) + data = JSONStringField(None, 'Data', validators=[Optional()], widget=FileInput()) sc_data_version = IntegerField('sc_data_version', validators=[InputRequired()], widget=HiddenInput()) @@ -361,6 +361,6 @@ class EditScheduledChangeExistingReleaseForm(ScheduledChangeTimeForm): a Release. Name cannot be changed because it is a PK field, and product cannot be changed because it almost never makes sense to (and can be done by deleting/recreating instead).""" - data = JSONStringField({}, 'Data', validators=[Optional()], widget=FileInput()) + data = JSONStringField(None, 'Data', validators=[Optional()], widget=FileInput()) data_version = IntegerField('data_version', widget=HiddenInput()) sc_data_version = IntegerField('sc_data_version', validators=[InputRequired()], widget=HiddenInput()) diff --git a/auslib/admin/views/releases.py b/auslib/admin/views/releases.py index 011312dcee..c61c745fc1 100644 --- a/auslib/admin/views/releases.py +++ b/auslib/admin/views/releases.py @@ -555,12 +555,18 @@ def __init__(self): @requirelogin def _post(self, sc_id, transaction, changed_by): - if request.json and request.json.get("data_version"): + change_type = request.json.get("change_type") + + if change_type == "update": form = EditScheduledChangeExistingReleaseForm() - else: + elif change_type == "insert": form = EditScheduledChangeNewReleaseForm() - - form.data.data = createBlob(form.data.data) + elif change_type == "delete": + form = EditScheduledChangeExistingReleaseForm() + else: + return Response(status=400, response="Invalid or missing change_type") + if form.data.data: + form.data.data = createBlob(form.data.data) return super(ReleaseScheduledChangeView, self)._post(sc_id, form, transaction, changed_by) @requirelogin diff --git a/auslib/test/admin/views/test_permissions.py b/auslib/test/admin/views/test_permissions.py index 6ed4ea6385..414dc17af5 100644 --- a/auslib/test/admin/views/test_permissions.py +++ b/auslib/test/admin/views/test_permissions.py @@ -178,6 +178,7 @@ def setUp(self): base_permission="rule", base_username="janet", base_options={"products": ["foo"]}, ) dbo.permissions.scheduled_changes.signoffs.t.insert().execute(sc_id=1, username="bill", role="releng") + dbo.permissions.scheduled_changes.signoffs.history.t.insert().execute(change_id=1, changed_by="bill", timestamp=30, sc_id=1, username="bill") dbo.permissions.scheduled_changes.signoffs.history.t.insert().execute(change_id=2, changed_by="bill", timestamp=31, sc_id=1, username="bill", role="releng") @@ -224,7 +225,6 @@ def setUp(self): dbo.permissions.scheduled_changes.conditions.history.t.insert().execute( change_id=7, changed_by="bill", timestamp=100, sc_id=3, when=30000000, data_version=2 ) - dbo.permissions.scheduled_changes.t.insert().execute( sc_id=4, scheduled_by="bill", change_type="delete", data_version=1, base_permission="scheduled_change", base_username="mary", complete=False, base_data_version=1, diff --git a/auslib/test/admin/views/test_releases.py b/auslib/test/admin/views/test_releases.py index 19ff9ffa9d..0343f184b1 100644 --- a/auslib/test/admin/views/test_releases.py +++ b/auslib/test/admin/views/test_releases.py @@ -1084,7 +1084,6 @@ def setUp(self): dbo.releases.scheduled_changes.conditions.history.t.insert().execute( change_id=7, changed_by="bill", timestamp=25, sc_id=3, when=10000000, data_version=2 ) - dbo.releases.scheduled_changes.t.insert().execute( sc_id=4, complete=False, scheduled_by="bill", change_type="delete", data_version=1, base_name="ab", base_data_version=1, ) @@ -1219,7 +1218,7 @@ def testAddScheduledChangeNewRelease(self): def testUpdateScheduledChangeExistingRelease(self): data = { "data": '{"name": "c", "hashFunction": "sha512", "extv": "3.0", "schema_version": 1}', "name": "c", - "data_version": 1, "sc_data_version": 1, "when": 78900000000, + "data_version": 1, "sc_data_version": 1, "when": 78900000000, "change_type": "update", } ret = self._post("/scheduled_changes/releases/2", data=data) self.assertEquals(ret.status_code, 200, ret.data) @@ -1239,11 +1238,20 @@ def testUpdateScheduledChangeExistingRelease(self): cond_expected = {"sc_id": 2, "data_version": 2, "when": 78900000000} self.assertEquals(dict(cond[0]), cond_expected) + @mock.patch("time.time", mock.MagicMock(return_value=300)) + def testUpdateScheduledChangeExistingDeleteRelease(self): + data = { + "name": "c", + "data_version": 1, "sc_data_version": 1, "when": 78900000000, "change_type": "delete" + } + ret = self._post("/scheduled_changes/releases/4", data=data) + self.assertEquals(ret.status_code, 200, ret.data) + @mock.patch("time.time", mock.MagicMock(return_value=300)) def testUpdateScheduledChangeNewRelease(self): data = { "data": '{"name": "m", "hashFunction": "sha512", "appv": "4.0", "schema_version": 1}', "name": "m", "product": "m", - "sc_data_version": 1, + "sc_data_version": 1, "change_type": "insert", } ret = self._post("/scheduled_changes/releases/1", data=data) self.assertEquals(ret.status_code, 200, ret.data) @@ -1267,7 +1275,8 @@ def testUpdateScheduledChangeNewRelease(self): def testUpdateScheduledChangeNewReleaseChangeName(self): data = { "data": '{"name": "mm", "hashFunction": "sha512", "appv": "4.0", "schema_version": 1}', "name": "mm", "product": "mm", - "sc_data_version": 1, + "sc_data_version": 1, "change_type": "insert", + } ret = self._post("/scheduled_changes/releases/1", data=data) self.assertEquals(ret.status_code, 200, ret.data) diff --git a/ui/app/js/controllers/release_scheduled_change_delete_controller.js b/ui/app/js/controllers/release_scheduled_change_delete_controller.js new file mode 100644 index 0000000000..505f99ff61 --- /dev/null +++ b/ui/app/js/controllers/release_scheduled_change_delete_controller.js @@ -0,0 +1,37 @@ +angular.module("app").controller("DeleteReleaseScheduledChangeCtrl", +function ($scope, $modalInstance, CSRF, Releases, sc, scheduled_changes) { + + $scope.sc = sc; + $scope.scheduled_changes = scheduled_changes; + $scope.saving = false; + + $scope.saveChanges = function () { + $scope.saving = true; + CSRF.getToken() + .then(function(csrf_token) { + Releases.deleteScheduledChange($scope.sc.sc_id, $scope.sc, csrf_token) + .success(function(response) { + $scope.scheduled_changes.splice($scope.scheduled_changes.indexOf($scope.sc), 1); + $modalInstance.close(); + }) + .error(function(response) { + if (typeof response === 'object') { + sweetAlert( + { + title: "Form submission error", + text: response.exception + }, + function() { $scope.cancel(); } + ); + } + }) + .finally(function() { + $scope.saving = false; + }); + }); + }; + + $scope.cancel = function () { + $modalInstance.dismiss("cancel"); + }; +}); diff --git a/ui/app/js/controllers/release_scheduled_change_edit_controller.js b/ui/app/js/controllers/release_scheduled_change_edit_controller.js new file mode 100644 index 0000000000..ad0b026f0f --- /dev/null +++ b/ui/app/js/controllers/release_scheduled_change_edit_controller.js @@ -0,0 +1,134 @@ + +angular.module('app').controller('EditReleaseScheduledChangeCtrl', +function ($scope, $modalInstance, CSRF, Releases, sc) { + + $scope.is_edit = true; + $scope.original_sc = sc; + $scope.sc = angular.copy(sc); + $scope.products = []; + Releases.getProducts().success(function(response) { + $scope.products = response.product; + }); + + $scope.errors = {}; + $scope.saving = false; + + $scope.setWhen = function(newDate) { + if (!newDate) { + newDate = new Date($("#id_when")[0].value); + $scope.sc.when = newDate; + } + $scope.calendar_is_open = false; + if (newDate <= new Date()) { + $scope.errors.when = ["Scheduled time cannot be in the past"]; + $scope.sc.when = $scope.original_sc.when; + } + else { + $scope.errors.when = null; + } + }; + + $scope.clearWhen = function () { + $scope.sc.when = null; + $scope.errors.when = null; + }; + + $scope.fillName = function () { + var file = $scope.dataFile; + $scope.errors.data = []; + var reader = new FileReader(); + reader.onloadend = function(evt) { + var blob = evt.target.result; + $scope.$apply( function() { + try { + var name = JSON.parse(blob).name; + if(!name) { + $scope.errors.data = ["Form submission error", "Name missing in blob.\n"]; + } + else if (name !== $scope.sc.name) { + $scope.errors.data = ["Form submission error", "Name differs compared to name in blob.\n"]; + } + } catch(err) { + $scope.errors.data = ["Form submission error", "Malformed JSON file.\n"]; + } + }); + if ($scope.errors.data.length === 0) { + $scope.sc.data = blob; + } + }; + if (typeof file !== 'undefined') { + // should work + reader.readAsText(file); + } + }; + + $scope.changeName = function () { + //wait for actual file to be loaded + setTimeout($scope.fillName, 0); + }; + + $scope.saveChanges = function () { + if ($scope.sc.change_type !== "delete") { + if (!$scope.sc.product.trim()) { + sweetAlert( + "Form Error", + "Product is required.", + "error" + ); + return; + } + + if (!$scope.sc.name.trim()) { + sweetAlert( + "Form Error", + "Name is required", + "error" + ); + return; + } + + if (!$scope.dataFile) { + delete $scope.sc.data; + } + } + + $scope.saving = true; + + CSRF.getToken() + .then(function(csrf_token) { + Releases.updateScheduledChange($scope.sc.sc_id, $scope.sc, csrf_token) + .success(function(response) { + $scope.sc.sc_data_version = response.new_data_version; + angular.copy($scope.sc, $scope.original_sc); + $scope.saving = false; + $modalInstance.close(); + }) + .error(function(response) { + if (typeof response === 'object') { + $scope.errors = response; + sweetAlert( + "Form submission error", + "See fields highlighted in red.", + "error" + ); + } + else if (typeof response === 'string') { + // quite possibly an error in the blob validation + sweetAlert( + "Form submission error", + "Unable to submit successfully.\n" + + "(" + response+ ")", + "error" + ); + } + }) + .finally(function() { + $scope.saving = false; + }); + }); + }; // /saveChanges + + $scope.cancel = function () { + $modalInstance.dismiss('cancel'); + }; +}); diff --git a/ui/app/js/controllers/release_scheduled_changes_controller.js b/ui/app/js/controllers/release_scheduled_changes_controller.js new file mode 100644 index 0000000000..7af77ae20e --- /dev/null +++ b/ui/app/js/controllers/release_scheduled_changes_controller.js @@ -0,0 +1,168 @@ +angular.module("app").controller("ReleaseScheduledChangesController", +function($scope, $routeParams, $location, $timeout, Search, $modal, $route, Releases) { + + $scope.loading = true; + $scope.failed = false; + + $scope.sc_id = $routeParams.sc_id; + + function loadPage(newPage) { + Releases.getScheduledChangeHistory($scope.sc_id, $scope.pageSize, newPage) + .success(function(response) { + // it's the same release, but this works + $scope.scheduled_changes = response.revisions; + $scope.scheduled_changes_count = response.count; + }) + .error(function() { + console.error(arguments); + $scope.failed = true; + }) + .finally(function() { + $scope.loading = false; + }); + } + + if ($scope.sc_id) { + $scope.$watch("currentPage", function(newPage) { + loadPage(newPage); + }); + } else { + + Releases.getScheduledChanges() + .success(function(response) { + // "when" is a unix timestamp, but it's much easier to work with Date objects, + // so we convert it to that before rendering. + $scope.scheduled_changes = response.scheduled_changes.map(function(sc) { + if (sc.when !== null) { + sc.when = new Date(sc.when); + } + return sc; + }); + }) + .error(function() { + console.error(arguments); + $scope.failed = true; + }) + .finally(function() { + $scope.loading = false; + }); +} + $scope.$watch("ordering_str", function(value) { + $scope.ordering = value.value.split(","); + }); + if ($scope.sc_id) { + $scope.ordering_options = [ + { + text: "Data Version", + value: "-data_version" + }, + ]; + } else { + $scope.ordering_options = [ + { + text: "When", + value: "when" + }, + { + text: "Product", + value: "product" + }, + { + text: "Name", + value: "name" + }, + ]; + } + + $scope.ordering_str = $scope.ordering_options[0]; + + $scope.currentPage = 1; + $scope.pageSize = 10; + + $scope.state_filter = [ + { + text: "Active", + value: "active", + }, + { + text: "Completed", + value: "complete", + }, + ]; + $scope.state_str = $scope.state_filter[0]; + + $scope.filterBySelect = function(sc) { + if($scope.sc_id) { + return true; + } + if ($scope.state_str.value === "complete" && sc.complete) { + return true; + } + else if ($scope.state_str.value === "active" && !sc.complete) { + return true; + } + return false; + }; + + $scope.formatMoment = function(when) { + date = moment(when); + // This is copied from app/js/directives/moment_directive.js + // We can't use that for this page, because it doesn't re-render when + // values change. + return ''; + }; + + $scope.openNewScheduledReleaseChangeModal = function() { + + var modalInstance = $modal.open({ + templateUrl: 'release_scheduled_change_modal.html', + controller: 'NewReleaseScheduledChangeCtrl', + size: 'lg', + backdrop: 'static', + resolve: { + scheduled_changes: function() { + return $scope.scheduled_changes; + }, + sc: function() { + // blank new default release + return { + name: '', + product: '', + change_type: 'insert', + }; + } + } + }); + }; + + $scope.openUpdateModal = function(sc) { + var modalInstance = $modal.open({ + templateUrl: "release_scheduled_change_modal.html", + controller: "EditReleaseScheduledChangeCtrl", + size: 'lg', + backdrop: 'static', + resolve: { + sc: function() { + sc.when = new Date(sc.when); + return sc; + } + } + }); + }; + + $scope.openDeleteModal = function(sc) { + var modalInstance = $modal.open({ + templateUrl: "release_scheduled_change_delete_modal.html", + controller: "DeleteReleaseScheduledChangeCtrl", + backdrop: 'static', + resolve: { + sc: function() { + return sc; + }, + scheduled_changes: function() { + return $scope.scheduled_changes; + } + } + }); + }; +}); diff --git a/ui/app/js/controllers/release_scheduled_changes_new_controller.js b/ui/app/js/controllers/release_scheduled_changes_new_controller.js new file mode 100644 index 0000000000..0c7da384d3 --- /dev/null +++ b/ui/app/js/controllers/release_scheduled_changes_new_controller.js @@ -0,0 +1,149 @@ +angular.module("app").controller("NewReleaseScheduledChangeCtrl", +function($scope, $http, $modalInstance, CSRF, Releases, scheduled_changes, sc) { + + + $scope.is_edit = false; + $scope.scheduled_changes = scheduled_changes; + $scope.sc = sc; + $scope.errors = {}; + $scope.saving = false; + $scope.calendar_is_open = false; + + $scope.products = []; + Releases.getProducts().success(function(response) { + $scope.products = response.product; + }); + + + $scope.setWhen = function(newDate) { + if (!newDate) { + newDate = new Date($("#id_when")[0].value); + $scope.sc.when = newDate; + } + $scope.calendar_is_open = false; + if (newDate <= new Date()) { + $scope.errors.when = ["Scheduled time cannot be in the past"]; + $scope.sc.when = null; + } + else { + $scope.errors.when = null; + } + }; + + $scope.clearWhen = function () { + $scope.sc.when = null; + $scope.errors.when = null; + }; + $scope.fillName = function () { + var file = $scope.dataFile; + $scope.errors.data = []; + $scope.sc.name = ""; + + var reader = new FileReader(); + reader.onloadend = function(evt) { + var blob = evt.target.result; + $scope.$apply( function() { + try{ + var name = JSON.parse(blob).name; + if (name) { + $scope.sc.name = name; + } + else { + $scope.errors.data = ["Form submission error", "Missing name field in JSON blob.\n"]; + } + }catch(err) { + $scope.errors.data = ["Form submission error", "Malformed JSON file.\n"]; + } + }); + }; + if (typeof file !== 'undefined') { + // should work + reader.readAsText(file); + } + + }; + + $scope.changeName = function () { + //wait for actual file to be loaded + setTimeout($scope.fillName, 0); + }; + + $scope.saveChanges = function () { + if (!$scope.sc.product.trim()) { + sweetAlert( + "Form Error", + "Product is required.", + "error" + ); + return; + } + if (!$scope.dataFile) { + sweetAlert( + "Form Error", + "No file has been selected.", + "error" + ); + return; + } + + if (!$scope.sc.name.trim()) { + sweetAlert( + "Form Error", + "Name is required", + "error" + ); + return; + } + + $scope.saving = true; + $scope.errors = {}; + + var file = $scope.dataFile; + + var reader = new FileReader(); + reader.onload = function(evt) { + var blob = evt.target.result; + CSRF.getToken() + .then(function(csrf_token) { + var data = $scope.sc; + data.data = blob; + Releases.addScheduledChange(data, csrf_token) + .success(function(response){ + $scope.sc.sc_data_version = 1; + $scope.sc.sc_id = response.sc_id; + $scope.scheduled_changes.push($scope.sc); + $modalInstance.close(); + }) + .error(function(response){ + if (typeof response === 'object') { + $scope.errors = response; + sweetAlert( + "Form submission error", + "See fields highlighted in red.", + "error" + ); + } else if (typeof response === 'string') { + // quite possibly an error in the blob validation + sweetAlert( + "Form submission error", + "Unable to submit successfully.\n" + + "(" + response + ")", + "error" + ); + } + }) + .finally(function() { + $scope.saving = false; + }); + }); + }; + // should work + reader.readAsText(file); + + }; + + $scope.cancel = function () { + $modalInstance.dismiss('cancel'); + }; +}); + diff --git a/ui/app/js/controllers/release_scheduled_delete_controller.js b/ui/app/js/controllers/release_scheduled_delete_controller.js new file mode 100644 index 0000000000..e2b5c2a54e --- /dev/null +++ b/ui/app/js/controllers/release_scheduled_delete_controller.js @@ -0,0 +1,86 @@ +angular.module("app").controller("NewReleaseScheduledDeleteCtrl", +function($scope, $http, $modalInstance, CSRF, Releases, scheduled_changes, sc) { + + + $scope.is_edit = false; + $scope.scheduled_changes = scheduled_changes; + $scope.sc = sc; + $scope.errors = {}; + $scope.saving = false; + $scope.calendar_is_open = false; + + + + $scope.setWhen = function(newDate) { + if (!newDate) { + newDate = new Date($("#id_when")[0].value); + $scope.sc.when = newDate; + } + $scope.calendar_is_open = false; + if (newDate <= new Date()) { + $scope.errors.when = ["Scheduled time cannot be in the past"]; + $scope.sc.when = null; + } + else { + $scope.errors.when = null; + } + }; + + $scope.clearWhen = function () { + $scope.sc.when = null; + $scope.errors.when = null; + }; + + + $scope.changeName = function () { + //wait for actual file to be loaded + setTimeout($scope.fillName, 0); + }; + + $scope.saveChanges = function () { + + $scope.saving = true; + $scope.errors = {}; + + + CSRF.getToken() + .then(function(csrf_token) { + var data = $scope.sc; + Releases.addScheduledChange(data, csrf_token) + .success(function(response){ + $scope.sc.sc_data_version = 1; + $scope.sc.sc_id = response.sc_id; + $scope.scheduled_changes.push($scope.sc); + $modalInstance.close(); + }) + .error(function(response){ + if (typeof response === 'object') { + $scope.errors = response; + sweetAlert( + "Form submission error", + "See fields highlighted in red.", + "error" + ); + } else if (typeof response === 'string') { + // quite possibly an error in the blob validation + sweetAlert( + "Form submission error", + "Unable to submit successfully.\n" + + "(" + response + ")", + "error" + ); + } + }) + .finally(function() { + $scope.saving = false; + }); + }); + }; + // should work + + + $scope.cancel = function () { + $modalInstance.dismiss('cancel'); + }; +}); + diff --git a/ui/app/js/controllers/releases_controller.js b/ui/app/js/controllers/releases_controller.js index a869435cae..9777ebb5c6 100644 --- a/ui/app/js/controllers/releases_controller.js +++ b/ui/app/js/controllers/releases_controller.js @@ -177,6 +177,45 @@ function($scope, $routeParams, $location, $timeout, Releases, Search, $modal) { }; /* End openUpdateModal */ + $scope.openNewScheduledDeleteModal = function(release) { + + var modalInstance = $modal.open({ + templateUrl: 'release_scheduled_delete_modal.html', + controller: 'NewReleaseScheduledDeleteCtrl', + size: 'lg', + resolve: { + scheduled_changes: function() { + return []; + }, + sc: function() { + sc = angular.copy(release); + sc["change_type"] = "delete"; + return sc; + } + } + }); + }; + + $scope.openNewScheduledReleaseChangeModal = function(release) { + + var modalInstance = $modal.open({ + templateUrl: 'release_scheduled_change_modal.html', + controller: 'NewReleaseScheduledChangeCtrl', + size: 'lg', + backdrop: 'static', + resolve: { + scheduled_changes: function() { + return []; + }, + sc: function() { + sc = angular.copy(release); + sc["change_type"] = "update"; + return sc; + } + } + }); + }; + $scope.openDeleteModal = function(release) { var modalInstance = $modal.open({ diff --git a/ui/app/js/router.js b/ui/app/js/router.js index e8cfa3836d..b10bc2990e 100644 --- a/ui/app/js/router.js +++ b/ui/app/js/router.js @@ -26,6 +26,18 @@ angular.module("app").config(function($routeProvider, $locationProvider) { reloadOnSearch: false }) + .when("/releases/scheduled_changes", { + templateUrl: "release_scheduled_changes.html", + controller: "ReleaseScheduledChangesController", + reloadOnSearch: false + }) + + .when("/scheduled_changes/releases/:sc_id", { + templateUrl: "release_scheduled_changes.html", + controller: "ReleaseScheduledChangesController", + reloadOnSearch: false + }) + .when('/releases/:name', { templateUrl: 'releases.html', controller: 'ReleasesController', diff --git a/ui/app/js/services/releases_service.js b/ui/app/js/services/releases_service.js index 080ee02f40..138ff7df9f 100644 --- a/ui/app/js/services/releases_service.js +++ b/ui/app/js/services/releases_service.js @@ -69,8 +69,53 @@ angular.module("app").factory('Releases', function($http, $q) { data.csrf_token = csrf_token; var url = '/api/releases/' + encodeURIComponent(name) + '/revisions'; return $http.post(url, data); - } + }, + + getScheduledChanges: function() { + return $http.get("/api/scheduled_changes/releases?all=1"); + }, + + getScheduledChange: function(sc_id) { + return $http.get("/api/scheduled_changes/releases/" + sc_id); + }, + getScheduledChangeHistory: function(sc_id, limit, page) { + url = '/api/scheduled_changes/releases/' + encodeURIComponent(sc_id) + '/revisions'; + url += '?limit=' + limit + '&page=' + page; + return $http.get(url); + }, + + addScheduledChange: function(data, csrf_token) { + data = jQuery.extend({}, data); + if (data.when === null) { + data.when = ""; + } + else { + data.when = data.when.getTime(); + } + data.csrf_token = csrf_token; + return $http.post("/api/scheduled_changes/releases", data); + }, + + updateScheduledChange: function(sc_id, data, csrf_token) { + data = jQuery.extend({}, data); + if (data.when === null) { + data.when = ""; + } + else { + data.when = data.when.getTime(); + } + data.csrf_token = csrf_token; + return $http.post("/api/scheduled_changes/releases/" + sc_id, data); + }, + + deleteScheduledChange: function(sc_id, data, csrf_token) { + var url = "/api/scheduled_changes/releases/" + sc_id; + url += '?data_version=' + data.sc_data_version; + url += '&csrf_token=' + encodeURIComponent(csrf_token); + return $http.delete(url); + }, }; + return service; }); diff --git a/ui/app/pages/index.us b/ui/app/pages/index.us index 626d1d3b67..df92d4c092 100644 --- a/ui/app/pages/index.us +++ b/ui/app/pages/index.us @@ -29,7 +29,12 @@