Remove Node

This patch adds controls necessary to remove nodes from Ironic. Included
are:

- NodeActionController, a generic, initializable controller that handles actions
  on nodes.
- RemoveNodeModalController, a controller for a modal window that permits
  the deletion of _multiple_ nodes.
- Test coverage for the above, with an updated coverage threshold.

Note that at this time, the code path to batch delete nodes is not yet surfaced
in the user interface.

Change-Id: I325d43fead7c63bb527307a6ee608ca36a59ab83
This commit is contained in:
Michael Krotscheck 2016-02-17 09:56:39 -08:00
parent 54b77c02da
commit ea9c34e5ed
8 changed files with 644 additions and 48 deletions

View File

@ -0,0 +1,94 @@
/*
* Copyright (c) 2016 Hewlett Packard Enterprise Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* This controller backs the "Remove Node" modal.
*/
angular.module('ironic').controller('RemoveNodeModalController',
function($modalInstance, nodes, $q) {
'use strict';
var vm = this;
// Wrap all the nodes in their own context objects.
vm.someDeleted = false;
vm.deleting = false;
vm.nodes = [];
angular.forEach(nodes, function(node) {
vm.nodes.push({
node: node,
error: null,
state: 'present'
});
});
/**
* Remove all the nodes on this controller.
*
* @returns {void}
*/
vm.remove = function() {
vm.deleting = true;
var promises = [];
// For each context object in our controller...
angular.forEach(vm.nodes, function(ctx) {
// Try to delete it.
ctx.state = 'removing';
var promise = ctx.node.$remove(
function() {
vm.someDeleted = true;
ctx.state = 'removed';
},
function(error) {
ctx.state = 'error';
ctx.error = error.data.error_message;
}
);
promises.push(promise);
});
// Wait for all the promises...
$q.all(promises).then(
function() {
// If all are successful, just close.
vm.deleting = false;
if (vm.someDeleted) {
$modalInstance.close();
} else {
$modalInstance.dismiss();
}
},
function() {
// Flip the deleting bit.
vm.deleting = false;
});
};
/**
* Close this modal.
*
* @returns {void}
*/
vm.close = function() {
if (vm.someDeleted) {
// Something was changed.
$modalInstance.close();
} else {
// Nothing was changed.
$modalInstance.dismiss();
}
};
});

View File

@ -0,0 +1,63 @@
/*
* Copyright (c) 2016 Hewlett Packard Enterprise Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* Controller which handles manipulation of individual nodes.
*/
angular.module('ironic').controller('NodeActionController',
function($modal, $q) {
'use strict';
var vm = this;
// Set up controller parameters
vm.errorMessage = null;
vm.node = null;
/**
* Initialize this controller with a specific node.
*
* @param {IronicNode} node The node to initialize this controller with.
* @return {void}
*/
vm.init = function(node) {
vm.node = node;
};
/**
* Delete the node in this controller.
*
* @return {Promise} A promise that will resolve true if the modal closed with some deletions.
*/
vm.remove = function() {
if (vm.node === null) {
// init() not called, or called with invalid value.
return $q.reject();
}
// Return the result of the modal.
return $modal.open({
'templateUrl': 'view/ironic/action/remove_node.html',
'controller': 'RemoveNodeModalController as ctrl',
'backdrop': 'static',
'resolve': {
nodes: function() {
return [vm.node];
}
}
}).result;
};
});

View File

@ -27,7 +27,7 @@ angular.module('ironic')
vm.nodes = null;
// Load the node list.
function loadNodes () {
vm.loadNodes = function() {
vm.errorMessage = null;
vm.nodes = IronicNode.query({}, function() {
// Do nothing on success.
@ -35,15 +35,15 @@ angular.module('ironic')
vm.errorMessage = error.data.error_message;
vm.nodes = null;
});
}
};
vm.enroll = function() {
$modal.open({
'templateUrl': 'view/ironic/enroll/index.html',
'controller': 'EnrollModalController as ctrl',
'backdrop': 'static'
}).result.then(loadNodes);
}).result.then(vm.loadNodes);
};
loadNodes();
vm.loadNodes();
});

View File

@ -0,0 +1,52 @@
<div class="modal-header">
<button type="button"
class="close"
ng-disabled="ctrl.deleting"
ng-click="ctrl.close()">&times;</button>
<h3 class="panel-title">Remove Nodes</h3>
</div>
<div class="modal-body">
<div class="row">
<div class="col-xs-12">
<div class="alert alert-danger">
<span class="fa fa-exclamation-circle"></span>
You are about to remove the following nodes.
</div>
<dl class="dl-horizontal">
<dt ng-repeat-start="e in ctrl.nodes"
ng-switch="e.state">
<div ng-switch-when="removing" class="text-warning">
<i class="fa fa-spin fa-refresh"></i> Removing...
</div>
<div ng-switch-when="removed" class="text-muted">
Removed:
</div>
<div ng-switch-when="error" class="text-danger">
<i class="fa fa-exclamation-triangle"></i> Error:
</div>
<div ng-switch-default>
Node:
</div>
</dt>
<dd ng-repeat-end>
{{e.node.uuid}}
<span ng-if="e.node.name">({{e.node.name}})</span>
<span class="text-danger" ng-if="e.error"><br/>
{{e.error.faultcode}}: {{e.error.faultstring}}
</span>
</dd>
</dl>
This will remove all knowledge of the nodes from the system. It will not modify the nodes,
however you will lose the ability to manage them via this application.
</div>
</div>
</div>
<div class="modal-footer ng-scope">
<p class="text-danger" ng-if="!!ctrl.error_message">{{ctrl.error_message}}</p>
<button class="btn btn-danger" ng-click="ctrl.remove()"
ng-disabled="ctrl.deleting">Remove
</button>
<button class="btn btn-default" ng-click="ctrl.close()"
ng-disabled="ctrl.deleting">Cancel
</button>
</div>

View File

@ -8,45 +8,54 @@
</div>
</div>
<div class="row">
<div class="col-xs-12">
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>Power State</th>
<th>UUID</th>
<th>Instance</th>
<th>Provision State</th>
<th>Maintenance</th>
</tr>
</thead>
<tbody ng-if="nodeListCtrl.nodes.$resolved && nodeListCtrl.nodes.length > 0">
<tr ng-repeat="node in nodeListCtrl.nodes">
<td>{{node.power_state}}</td>
<td>
<a href="#/ironic/nodes/{{node.uuid}}/node">
{{node.uuid}}
</a>
</td>
<td>{{node.instance_uuid || 'None'}}</td>
<td>{{node.provision_state}}</td>
<td>{{node.maintenance}}</td>
</tr>
</tbody>
<tbody ng-if="nodeListCtrl.nodes.$resolved && nodeListCtrl.nodes.length == 0">
<tr>
<td colspan="5" class="text-center text-muted">
<small><em>No nodes were found.</em></small>
</td>
</tr>
</tbody>
<tbody ng-if="!nodeListCtrl.nodes.$resolved">
<tr>
<td colspan="5" class="text-center text-muted">
<i class="fa fa-refresh fa-spin"></i>
Loading nodes...
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-xs-12">
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>Power State</th>
<th>UUID</th>
<th>Instance</th>
<th>Provision State</th>
<th>Maintenance</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody ng-if="nodeListCtrl.nodes.$resolved && nodeListCtrl.nodes.length > 0">
<tr ng-repeat="node in nodeListCtrl.nodes"
ng-controller="NodeActionController as nodeCtrl"
ng-init="nodeCtrl.init(node)">
<td>{{nodeCtrl.node.power_state}}</td>
<td>
<a href="#/ironic/nodes/{{nodeCtrl.node.uuid}}/node">
{{nodeCtrl.node.uuid}}
</a>
</td>
<td>{{nodeCtrl.node.instance_uuid || 'None'}}</td>
<td>{{nodeCtrl.node.provision_state}}</td>
<td>{{nodeCtrl.node.maintenance}}</td>
<td>
<button type="button" class="close"
ng-click="nodeCtrl.remove().then(nodeListCtrl.loadNodes)">
<span>&times;</span>
</button>
</td>
</tr>
</tbody>
<tbody ng-if="nodeListCtrl.nodes.$resolved && nodeListCtrl.nodes.length == 0">
<tr>
<td colspan="6" class="text-center text-muted">
<small><em>No nodes were found.</em></small>
</td>
</tr>
</tbody>
<tbody ng-if="!nodeListCtrl.nodes.$resolved">
<tr>
<td colspan="6" class="text-center text-muted">
<i class="fa fa-refresh fa-spin"></i>
Loading nodes...
</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -69,10 +69,10 @@
// Coverage threshold values.
thresholdReporter: {
statements: 92, // target 100
statements: 93, // target 100
branches: 96, // target 100
functions: 88, // target 100
lines: 92 // target 100
functions: 89, // target 100
lines: 93 // target 100
},
'exclude': [],

View File

@ -0,0 +1,262 @@
/*
* Copyright (c) 2015 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* Unit tests for the RemoveNodeModalController.
*/
describe('RemoveNodeModalController',
function() {
'use strict';
var $controller, $httpBackend, mockInjectionProperties, $rootScope, $q;
beforeEach(function() {
module('ironic.api.mock.IronicNode');
module('template.mock');
module('ironic');
mockInjectionProperties = {
$scope: {},
$modalInstance: {
close: function() {
},
dismiss: function() {
}
},
nodes: []
};
});
beforeEach(inject(function(_$controller_, $injector) {
$httpBackend = $injector.get('$httpBackend');
$rootScope = $injector.get('$rootScope');
$q = $injector.get('$q');
$controller = _$controller_;
}));
afterEach(inject(function($$persistentStorage) {
// Clear any config selections we've made.
$$persistentStorage.remove('$$selectedConfiguration');
// Assert no outstanding requests.
$httpBackend.verifyNoOutstandingExpectation();
$httpBackend.verifyNoOutstandingRequest();
}));
describe('Controller Properties', function() {
it('does not pollute the $scope',
function() {
$controller('RemoveNodeModalController', mockInjectionProperties);
expect(mockInjectionProperties.$scope).toEqual({});
});
it('starts not deleting anything',
function() {
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
expect(controller.deleting).toBeFalsy();
});
it('starts not having deleted anything',
function() {
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
expect(controller.someDeleted).toBeFalsy();
});
it('Creates a scope object for each passed node',
function() {
mockInjectionProperties.nodes = [{'node': 'node1'}, {'node': 'node2'}];
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
expect(controller.nodes.length).toBe(2);
angular.forEach(controller.nodes, function(ctx) {
expect(ctx.node).toBeDefined();
expect(ctx.error).toBeNull();
expect(ctx.state).toBe('present');
});
});
});
describe('close()', function() {
it('invokes dismiss() if nothing deleted',
function() {
var spyDismiss = spyOn(mockInjectionProperties.$modalInstance, 'dismiss');
var spyClose = spyOn(mockInjectionProperties.$modalInstance, 'close');
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
controller.someDeleted = false;
controller.close();
expect(spyDismiss.calls.count()).toEqual(1);
expect(spyClose.calls.count()).toEqual(0);
});
it('invokes close() if something deleted',
function() {
var spyDismiss = spyOn(mockInjectionProperties.$modalInstance, 'dismiss');
var spyClose = spyOn(mockInjectionProperties.$modalInstance, 'close');
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
controller.someDeleted = true;
controller.close();
expect(spyDismiss.calls.count()).toEqual(0);
expect(spyClose.calls.count()).toEqual(1);
});
});
describe('remove()', function() {
var mockError = {
data: {
error_message: {
faultstring: "faultstring",
faultcode: "faultcode"
}
}
};
function removeMock (success) {
return function(successHandler, failureHandler) {
var promise = success ? $q.resolve() : $q.reject(mockError);
promise.then(successHandler, failureHandler);
return promise;
};
}
it('deletes nothing if invoked with no nodes.',
function() {
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
expect(controller.deleting).toBeFalsy();
expect(controller.someDeleted).toBeFalsy();
controller.remove();
$rootScope.$apply(); // Resolve promises
expect(controller.deleting).toBeFalsy();
expect(controller.someDeleted).toBeFalsy();
});
it('dismisses the modal if invoked with no nodes..',
function() {
var spyDismiss = spyOn(mockInjectionProperties.$modalInstance, 'dismiss');
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
expect(controller.deleting).toBeFalsy();
expect(controller.someDeleted).toBeFalsy();
controller.remove();
expect(controller.deleting).toBeTruthy();
$rootScope.$apply(); // Resolve promises
expect(controller.deleting).toBeFalsy();
expect(spyDismiss.calls.count()).toEqual(1);
});
it('correctly flips the deleting flag',
function() {
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
expect(controller.deleting).toBeFalsy();
controller.remove();
expect(controller.deleting).toBeTruthy();
$rootScope.$apply(); // Resolve promises
expect(controller.deleting).toBeFalsy();
});
it('flips the someDeleted flag if some nodes are deleted and others are not',
function() {
var spyDismiss = spyOn(mockInjectionProperties.$modalInstance, 'dismiss');
var spyClose = spyOn(mockInjectionProperties.$modalInstance, 'close');
mockInjectionProperties.nodes = [
{$remove: removeMock(true)},
{$remove: removeMock(false)}
];
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
expect(controller.deleting).toBeFalsy();
expect(controller.someDeleted).toBeFalsy();
controller.remove();
expect(controller.nodes[0].state).toBe('removing');
expect(controller.nodes[1].state).toBe('removing');
expect(controller.deleting).toBeTruthy();
$rootScope.$apply();
expect(controller.deleting).toBeFalsy();
expect(controller.someDeleted).toBeTruthy();
expect(controller.nodes[0].state).toBe('removed');
expect(controller.nodes[1].state).toBe('error');
expect(spyDismiss.calls.count()).toEqual(0);
expect(spyClose.calls.count()).toEqual(0);
});
it('changes a node\'s context state to "removing" and "removed".',
function() {
mockInjectionProperties.nodes = [
{$remove: removeMock(true)}
];
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
expect(controller.deleting).toBeFalsy();
expect(controller.someDeleted).toBeFalsy();
controller.remove();
expect(controller.nodes[0].state).toBe('removing');
expect(controller.deleting).toBeTruthy();
$rootScope.$apply();
expect(controller.deleting).toBeFalsy();
expect(controller.someDeleted).toBeTruthy();
expect(controller.nodes[0].state).toBe('removed');
});
it('Correctly reports a returned error if a node is not deleted.',
function() {
mockInjectionProperties.nodes = [
{$remove: removeMock(false)}
];
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
expect(controller.deleting).toBeFalsy();
expect(controller.someDeleted).toBeFalsy();
controller.remove();
expect(controller.nodes[0].state).toBe('removing');
expect(controller.deleting).toBeTruthy();
$rootScope.$apply();
expect(controller.deleting).toBeFalsy();
expect(controller.someDeleted).toBeFalsy();
expect(controller.nodes[0].state).toBe('error');
expect(controller.nodes[0].error).toBeDefined();
});
it('invokes $modalInstance.close() if all nodes have been deleted.',
function() {
var spyDismiss = spyOn(mockInjectionProperties.$modalInstance, 'dismiss');
var spyClose = spyOn(mockInjectionProperties.$modalInstance, 'close');
mockInjectionProperties.nodes = [
{$remove: removeMock(true)},
{$remove: removeMock(true)}
];
var controller = $controller('RemoveNodeModalController', mockInjectionProperties);
expect(controller.deleting).toBeFalsy();
expect(controller.someDeleted).toBeFalsy();
controller.remove();
expect(controller.nodes[0].state).toBe('removing');
expect(controller.nodes[1].state).toBe('removing');
expect(controller.deleting).toBeTruthy();
$rootScope.$apply();
expect(controller.deleting).toBeFalsy();
expect(controller.someDeleted).toBeTruthy();
expect(controller.nodes[0].state).toBe('removed');
expect(controller.nodes[1].state).toBe('removed');
expect(spyDismiss.calls.count()).toEqual(0);
expect(spyClose.calls.count()).toEqual(1);
});
});
});

View File

@ -0,0 +1,116 @@
/*
* Copyright (c) 2015 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* Unit tests for the node action controller.
*/
describe('Unit: Ironic-webclient NodeActionController',
function() {
'use strict';
var $controller, $httpBackend;
var mockInjectionProperties = {
$scope: {}
};
beforeEach(function() {
module('ironic.api.mock.IronicNode');
module('template.mock');
module('ironic');
});
beforeEach(inject(function(_$controller_, $injector) {
$httpBackend = $injector.get('$httpBackend');
$controller = _$controller_;
}));
afterEach(inject(function($$persistentStorage) {
// Clear any config selections we've made.
$$persistentStorage.remove('$$selectedConfiguration');
// Assert no outstanding requests.
$httpBackend.verifyNoOutstandingExpectation();
$httpBackend.verifyNoOutstandingRequest();
}));
describe('Controller', function() {
it('does not pollute the $scope',
function() {
$controller('NodeActionController', mockInjectionProperties);
expect(mockInjectionProperties.$scope).toEqual({});
});
it('starts without an error object',
function() {
var controller = $controller('NodeActionController', mockInjectionProperties);
expect(controller.errorMessage).toBeDefined();
expect(controller.errorMessage).toBeFalsy();
});
it('starts without a node object',
function() {
var controller = $controller('NodeActionController', mockInjectionProperties);
expect(controller.node).toBeDefined();
expect(controller.node).toBeFalsy();
});
});
describe('Controller Initialization', function() {
it('should assign the node property via init()',
function() {
var controller = $controller('NodeActionController', mockInjectionProperties);
expect(controller.node).toBeDefined();
expect(controller.node).toBeFalsy();
var mockNode = {};
controller.init(mockNode);
expect(controller.node).toBeDefined();
expect(controller.node).toEqual(mockNode);
});
});
describe('remove', function() {
it('should do nothing if no node is found.',
function() {
var controller = $controller('NodeActionController', mockInjectionProperties);
expect(controller.node).toBeDefined();
expect(controller.node).toBeFalsy();
var promise = controller.remove();
expect(promise.$$state.status).toEqual(2);
});
it('open a modal if called with a node.',
inject(function($q, $modal) {
var controller = $controller('NodeActionController', mockInjectionProperties);
var mockNode = {};
var spy = spyOn($modal, 'open').and.callThrough();
$httpBackend.expectGET('view/ironic/action/remove_node.html').respond(200, '');
controller.init(mockNode);
var promise = controller.remove();
expect(promise.$$state.status).toEqual(0); // Unresolved promise.
expect(spy.calls.count()).toBe(1);
$httpBackend.flush();
}));
});
});