Add support for editing Ironic network ports
The port table in node-detais/configuration tab has been updated to include an "Edit port" action for each port. Closes-Bug: #1648563 Change-Id: I04ec8904dc67f98ff9f0d94a7fa46618cfba956c
This commit is contained in:
parent
9e977e1328
commit
0c4c948324
@ -17,6 +17,7 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from ironicclient import client
|
from ironicclient import client
|
||||||
|
from ironicclient.v1 import resource_fields as res_fields
|
||||||
|
|
||||||
from horizon.utils.memoized import memoized # noqa
|
from horizon.utils.memoized import memoized # noqa
|
||||||
|
|
||||||
@ -227,3 +228,18 @@ def port_delete(request, port_uuid):
|
|||||||
:return: Port
|
:return: Port
|
||||||
"""
|
"""
|
||||||
return ironicclient(request).port.delete(port_uuid)
|
return ironicclient(request).port.delete(port_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def port_update(request, port_id, patch):
|
||||||
|
"""Update a specified port.
|
||||||
|
|
||||||
|
:param request: HTTP request.
|
||||||
|
:param node_id: The uuid of the port.
|
||||||
|
:param patch: Sequence of update operations
|
||||||
|
:return: port.
|
||||||
|
|
||||||
|
http://docs.openstack.org/developer/python-ironicclient/api/ironicclient.v1.port.html#ironicclient.v1.port.PortManager.update
|
||||||
|
"""
|
||||||
|
port = ironicclient(request).port.update(port_id, patch)
|
||||||
|
return dict([(f, getattr(port, f, ''))
|
||||||
|
for f in res_fields.PORT_DETAILED_RESOURCE.fields])
|
||||||
|
@ -123,6 +123,22 @@ class Ports(generic.View):
|
|||||||
return ironic.port_delete(request, params)
|
return ironic.port_delete(request, params)
|
||||||
|
|
||||||
|
|
||||||
|
@urls.register
|
||||||
|
class Port(generic.View):
|
||||||
|
|
||||||
|
url_regex = r'ironic/ports/(?P<port_id>[0-9a-f-]+)$'
|
||||||
|
|
||||||
|
@rest_utils.ajax(data_required=True)
|
||||||
|
def patch(self, request, port_id):
|
||||||
|
"""Update an Ironic port
|
||||||
|
|
||||||
|
:param request: HTTP request
|
||||||
|
:param port_id: Port id.
|
||||||
|
"""
|
||||||
|
patch = request.DATA.get('patch')
|
||||||
|
return ironic.port_update(request, port_id, patch)
|
||||||
|
|
||||||
|
|
||||||
@urls.register
|
@urls.register
|
||||||
class StatesPower(generic.View):
|
class StatesPower(generic.View):
|
||||||
|
|
||||||
|
@ -0,0 +1,126 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2016 Cray Inc.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var UNABLE_TO_UPDATE_CONNECTIVITY_ATTR_MSG = gettext("This field is disabled because a port cannot have any connectivity attributes (pxe_enabled, local_link_connection, portgroup_id) updated unless its associated node is in an enroll, inspecting, mangeable state; or in maintenance mode."); // eslint-disable-line max-len
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller used to edit a specified node port
|
||||||
|
*/
|
||||||
|
angular
|
||||||
|
.module('horizon.dashboard.admin.ironic')
|
||||||
|
.controller('EditPortController', EditPortController);
|
||||||
|
|
||||||
|
EditPortController.$inject = [
|
||||||
|
'$rootScope',
|
||||||
|
'$controller',
|
||||||
|
'$uibModalInstance',
|
||||||
|
'$log',
|
||||||
|
'$q',
|
||||||
|
'horizon.app.core.openstack-service-api.ironic',
|
||||||
|
'horizon.dashboard.admin.ironic.events',
|
||||||
|
'horizon.dashboard.admin.ironic.update-patch.service',
|
||||||
|
'port',
|
||||||
|
'node'
|
||||||
|
];
|
||||||
|
|
||||||
|
function EditPortController($rootScope,
|
||||||
|
$controller,
|
||||||
|
$uibModalInstance,
|
||||||
|
$log,
|
||||||
|
$q,
|
||||||
|
ironic,
|
||||||
|
ironicEvents,
|
||||||
|
updatePatchService,
|
||||||
|
port,
|
||||||
|
node) {
|
||||||
|
var ctrl = this;
|
||||||
|
$controller('BasePortController',
|
||||||
|
{ctrl: ctrl,
|
||||||
|
$uibModalInstance: $uibModalInstance});
|
||||||
|
|
||||||
|
ctrl.modalTitle = gettext("Edit Port");
|
||||||
|
ctrl.submitButtonTitle = gettext("Update Port");
|
||||||
|
|
||||||
|
var cannotEditConnectivityAttr =
|
||||||
|
!(node.maintenance || (node.provision_state === "enroll" ||
|
||||||
|
node.provision_state === "inspecting" ||
|
||||||
|
node.provision_state === "manageable"));
|
||||||
|
|
||||||
|
// Initialize form fields
|
||||||
|
ctrl.port.address = port.address;
|
||||||
|
|
||||||
|
ctrl.pxeEnabled.value = port.pxe_enabled ? 'True' : 'False';
|
||||||
|
if (cannotEditConnectivityAttr) {
|
||||||
|
ctrl.pxeEnabled.disabled = true;
|
||||||
|
ctrl.pxeEnabled.info = UNABLE_TO_UPDATE_CONNECTIVITY_ATTR_MSG;
|
||||||
|
}
|
||||||
|
|
||||||
|
angular.forEach(
|
||||||
|
['port_id', 'switch_id', 'switch_info'],
|
||||||
|
function(prop) {
|
||||||
|
if (angular.isDefined(port.local_link_connection[prop])) {
|
||||||
|
ctrl.localLinkConnection[prop].value =
|
||||||
|
port.local_link_connection[prop];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cannotEditConnectivityAttr) {
|
||||||
|
ctrl.localLinkConnection.$setDisabled(
|
||||||
|
true,
|
||||||
|
UNABLE_TO_UPDATE_CONNECTIVITY_ATTR_MSG);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrl.port.extra = angular.copy(port.extra);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply updates to the port being edited
|
||||||
|
*
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
ctrl.updatePort = function() {
|
||||||
|
var patcher = new updatePatchService.UpdatePatch();
|
||||||
|
|
||||||
|
$log.info("Updating port " + JSON.stringify(port));
|
||||||
|
|
||||||
|
patcher.buildPatch(port.address, ctrl.port.address, "/address");
|
||||||
|
patcher.buildPatch(port.pxe_enabled ? 'True' : 'False',
|
||||||
|
ctrl.pxeEnabled.value,
|
||||||
|
"/pxe_enabled");
|
||||||
|
patcher.buildPatch(port.local_link_connection,
|
||||||
|
ctrl.localLinkConnection.$toPortAttr(),
|
||||||
|
"/local_link_connection");
|
||||||
|
patcher.buildPatch(port.extra, ctrl.port.extra, "/extra");
|
||||||
|
|
||||||
|
var patch = patcher.getPatch();
|
||||||
|
$log.info("patch = " + JSON.stringify(patch.patch));
|
||||||
|
if (patch.status === updatePatchService.UpdatePatch.status.OK) {
|
||||||
|
ironic.updatePort(port.uuid, patch.patch).then(function(port) {
|
||||||
|
$rootScope.$emit(ironicEvents.EDIT_PORT_SUCCESS);
|
||||||
|
$uibModalInstance.close(port);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toastService.add('error',
|
||||||
|
gettext('Unable to create port update patch.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctrl.submit = function() {
|
||||||
|
ctrl.updatePort();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2017 Cray Inc.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
angular
|
||||||
|
.module('horizon.dashboard.admin.ironic')
|
||||||
|
.factory('horizon.dashboard.admin.ironic.edit-port.service',
|
||||||
|
editPortService);
|
||||||
|
|
||||||
|
editPortService.$inject = [
|
||||||
|
'$uibModal',
|
||||||
|
'horizon.dashboard.admin.ironic.basePath'
|
||||||
|
];
|
||||||
|
|
||||||
|
function editPortService($uibModal, basePath) {
|
||||||
|
var service = {
|
||||||
|
modal: modal
|
||||||
|
};
|
||||||
|
|
||||||
|
function modal(port, node) {
|
||||||
|
var options = {
|
||||||
|
controller: 'EditPortController as ctrl',
|
||||||
|
backdrop: 'static',
|
||||||
|
resolve: {
|
||||||
|
port: function() {
|
||||||
|
return port;
|
||||||
|
},
|
||||||
|
node: function() {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
templateUrl: basePath + '/base-port/base-port.html'
|
||||||
|
};
|
||||||
|
return $uibModal.open(options).result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
})();
|
@ -52,7 +52,8 @@
|
|||||||
DELETE_NODE_SUCCESS:'horizon.dashboard.admin.ironic.DELETE_NODE_SUCCESS',
|
DELETE_NODE_SUCCESS:'horizon.dashboard.admin.ironic.DELETE_NODE_SUCCESS',
|
||||||
EDIT_NODE_SUCCESS:'horizon.dashboard.admin.ironic.EDIT_NODE_SUCCESS',
|
EDIT_NODE_SUCCESS:'horizon.dashboard.admin.ironic.EDIT_NODE_SUCCESS',
|
||||||
CREATE_PORT_SUCCESS:'horizon.dashboard.admin.ironic.CREATE_PORT_SUCCESS',
|
CREATE_PORT_SUCCESS:'horizon.dashboard.admin.ironic.CREATE_PORT_SUCCESS',
|
||||||
DELETE_PORT_SUCCESS:'horizon.dashboard.admin.ironic.DELETE_PORT_SUCCESS'
|
DELETE_PORT_SUCCESS:'horizon.dashboard.admin.ironic.DELETE_PORT_SUCCESS',
|
||||||
|
EDIT_PORT_SUCCESS:'horizon.dashboard.admin.ironic.EDIT_PORT_SUCCESS'
|
||||||
};
|
};
|
||||||
$provide.constant('horizon.dashboard.admin.ironic.events', events);
|
$provide.constant('horizon.dashboard.admin.ironic.events', events);
|
||||||
}
|
}
|
||||||
|
@ -55,6 +55,7 @@
|
|||||||
removeNodeFromMaintenanceMode: removeNodeFromMaintenanceMode,
|
removeNodeFromMaintenanceMode: removeNodeFromMaintenanceMode,
|
||||||
setNodeProvisionState: setNodeProvisionState,
|
setNodeProvisionState: setNodeProvisionState,
|
||||||
updateNode: updateNode,
|
updateNode: updateNode,
|
||||||
|
updatePort: updatePort,
|
||||||
validateNode: validateNode
|
validateNode: validateNode
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -456,6 +457,32 @@
|
|||||||
return $q.reject(msg);
|
return $q.reject(msg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Update the definition of a specified port.
|
||||||
|
*
|
||||||
|
* http://developer.openstack.org/api-ref/baremetal/#update-a-port
|
||||||
|
*
|
||||||
|
* @param {string} portUuid – UUID of a port.
|
||||||
|
* @param {object[]} patch – Sequence of update operations
|
||||||
|
* @return {promise} Promise
|
||||||
|
*/
|
||||||
|
function updatePort(portUuid, patch) {
|
||||||
|
return apiService.patch('/api/ironic/ports/' + portUuid,
|
||||||
|
{patch: patch})
|
||||||
|
.then(function(response) {
|
||||||
|
var msg = gettext('Successfully updated port %s');
|
||||||
|
toastService.add('success', interpolate(msg, [portUuid], false));
|
||||||
|
return response.data; // The updated port
|
||||||
|
})
|
||||||
|
.catch(function(response) {
|
||||||
|
var msg = interpolate(gettext('Unable to update port %s: %s'),
|
||||||
|
[portUuid, response.data],
|
||||||
|
false);
|
||||||
|
toastService.add('error', msg);
|
||||||
|
return $q.reject(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}());
|
}());
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
'horizon.dashboard.admin.ironic.actions',
|
'horizon.dashboard.admin.ironic.actions',
|
||||||
'horizon.dashboard.admin.ironic.basePath',
|
'horizon.dashboard.admin.ironic.basePath',
|
||||||
'horizon.dashboard.admin.ironic.edit-node.service',
|
'horizon.dashboard.admin.ironic.edit-node.service',
|
||||||
|
'horizon.dashboard.admin.ironic.edit-port.service',
|
||||||
'horizon.dashboard.admin.ironic.maintenance.service',
|
'horizon.dashboard.admin.ironic.maintenance.service',
|
||||||
'horizon.dashboard.admin.ironic.node-state-transition.service',
|
'horizon.dashboard.admin.ironic.node-state-transition.service',
|
||||||
'horizon.dashboard.admin.ironic.validUuidPattern'
|
'horizon.dashboard.admin.ironic.validUuidPattern'
|
||||||
@ -46,6 +47,7 @@
|
|||||||
actions,
|
actions,
|
||||||
basePath,
|
basePath,
|
||||||
editNodeService,
|
editNodeService,
|
||||||
|
editPortService,
|
||||||
maintenanceService,
|
maintenanceService,
|
||||||
nodeStateTransitionService,
|
nodeStateTransitionService,
|
||||||
validUuidPattern) {
|
validUuidPattern) {
|
||||||
@ -81,6 +83,7 @@
|
|||||||
ctrl.editNode = editNode;
|
ctrl.editNode = editNode;
|
||||||
ctrl.createPort = createPort;
|
ctrl.createPort = createPort;
|
||||||
ctrl.deletePort = deletePort;
|
ctrl.deletePort = deletePort;
|
||||||
|
ctrl.editPort = editPort;
|
||||||
ctrl.refresh = refresh;
|
ctrl.refresh = refresh;
|
||||||
|
|
||||||
$scope.emptyObject = function(obj) {
|
$scope.emptyObject = function(obj) {
|
||||||
@ -223,6 +226,18 @@
|
|||||||
ctrl.actions.createPort(ctrl.node);
|
ctrl.actions.createPort(ctrl.node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description: Edit a specified port
|
||||||
|
*
|
||||||
|
* @param {port} port - Port to be edited
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
function editPort(port) {
|
||||||
|
editPortService.modal(port, ctrl.node).then(function() {
|
||||||
|
ctrl.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name horizon.dashboard.admin.ironic.NodeDetailsController.deletePort
|
* @name horizon.dashboard.admin.ironic.NodeDetailsController.deletePort
|
||||||
* @description Delete a list of ports
|
* @description Delete a list of ports
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
describe('horizon.dashboard.admin.ironic.node-details', function () {
|
describe('horizon.dashboard.admin.ironic.node-details', function () {
|
||||||
var ctrl, $q;
|
var ctrl, $q, nodeStateTransitionService;
|
||||||
var nodeUuid = "0123abcd-0123-4567-abcd-0123456789ab";
|
var nodeUuid = "0123abcd-0123-4567-abcd-0123456789ab";
|
||||||
var nodeName = "herp";
|
var nodeName = "herp";
|
||||||
var numPorts = 2;
|
var numPorts = 2;
|
||||||
@ -27,9 +27,17 @@
|
|||||||
return '' + index + index + nodeUuid.substring(2);
|
return '' + index + index + nodeUuid.substring(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function portMacAddr(index) {
|
||||||
|
var mac = '' + index + index;
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
mac += ':' + index + index;
|
||||||
|
}
|
||||||
|
return mac;
|
||||||
|
}
|
||||||
|
|
||||||
function createPort(nodeUuid, index, extra) {
|
function createPort(nodeUuid, index, extra) {
|
||||||
var uuid = portUuid(nodeUuid, index);
|
var port = {uuid: portUuid(nodeUuid, index),
|
||||||
var port = {uuid: uuid, id: uuid};
|
address: portMacAddr(index)};
|
||||||
if (angular.isDefined(extra)) {
|
if (angular.isDefined(extra)) {
|
||||||
port.extra = extra;
|
port.extra = extra;
|
||||||
}
|
}
|
||||||
@ -37,7 +45,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createNode(name, uuid) {
|
function createNode(name, uuid) {
|
||||||
return {name: name, uuid: uuid, id: uuid};
|
return {name: name,
|
||||||
|
uuid: uuid,
|
||||||
|
provision_state: 'enroll'};
|
||||||
}
|
}
|
||||||
|
|
||||||
var ironicAPI = {
|
var ironicAPI = {
|
||||||
@ -88,16 +98,20 @@
|
|||||||
var $location = _$location_;
|
var $location = _$location_;
|
||||||
$location.path('/admin/ironic/' + nodeUuid + '/');
|
$location.path('/admin/ironic/' + nodeUuid + '/');
|
||||||
|
|
||||||
|
nodeStateTransitionService = $injector.get(
|
||||||
|
'horizon.dashboard.admin.ironic.node-state-transition.service');
|
||||||
|
|
||||||
ctrl = controller(
|
ctrl = controller(
|
||||||
'horizon.dashboard.admin.ironic.NodeDetailsController',
|
'horizon.dashboard.admin.ironic.NodeDetailsController',
|
||||||
{$scope: scope,
|
{$scope: scope,
|
||||||
$location: $location,
|
$location: $location,
|
||||||
|
'horizon.dashboard.admin.ironic.edit-port.service': {},
|
||||||
'horizon.dashboard.admin.ironic.actions': {}});
|
'horizon.dashboard.admin.ironic.actions': {}});
|
||||||
|
|
||||||
scope.$apply();
|
scope.$apply();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should be defined', function () {
|
it('controller should be defined', function () {
|
||||||
expect(ctrl).toBeDefined();
|
expect(ctrl).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -107,7 +121,9 @@
|
|||||||
|
|
||||||
it('should have a node', function () {
|
it('should have a node', function () {
|
||||||
expect(ctrl.node).toBeDefined();
|
expect(ctrl.node).toBeDefined();
|
||||||
expect(ctrl.node).toEqual(createNode(nodeName, nodeUuid));
|
var node = createNode(nodeName, nodeUuid);
|
||||||
|
node.id = node.uuid;
|
||||||
|
expect(ctrl.node).toEqual(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have ports', function () {
|
it('should have ports', function () {
|
||||||
@ -116,7 +132,10 @@
|
|||||||
|
|
||||||
var ports = [];
|
var ports = [];
|
||||||
for (var i = 0; i < numPorts; i++) {
|
for (var i = 0; i < numPorts; i++) {
|
||||||
ports.push(createPort(ctrl.node.uuid, i));
|
var port = createPort(ctrl.node.uuid, i);
|
||||||
|
port.id = port.uuid;
|
||||||
|
port.name = port.address;
|
||||||
|
ports.push(port);
|
||||||
}
|
}
|
||||||
expect(ctrl.portsSrc).toEqual(ports);
|
expect(ctrl.portsSrc).toEqual(ports);
|
||||||
});
|
});
|
||||||
@ -138,5 +157,16 @@
|
|||||||
expect(ctrl.getVifPortId(createPort(ctrl.node.uuid, 1, extra))).
|
expect(ctrl.getVifPortId(createPort(ctrl.node.uuid, 1, extra))).
|
||||||
toEqual("port_uuid");
|
toEqual("port_uuid");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should have node-state-transitions', function () {
|
||||||
|
expect(ctrl.nodeStateTransitions).toBeDefined();
|
||||||
|
expect(ctrl.nodeStateTransitions).toEqual(
|
||||||
|
nodeStateTransitionService.getTransitions(ctrl.node.provision_state));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have node-validation', function () {
|
||||||
|
expect(ctrl.nodeValidation).toBeDefined();
|
||||||
|
expect(ctrl.nodeValidation).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
@ -90,12 +90,21 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</td>
|
</td>
|
||||||
<td class="actions_column">
|
<td class="actions_column">
|
||||||
<action-list>
|
<action-list uib-dropdown class="pull-right">
|
||||||
<action action-classes="'btn btn-default btn-sm'"
|
<action button-type="split-button"
|
||||||
|
action-classes="'btn btn-default btn-sm'"
|
||||||
|
callback="ctrl.editPort"
|
||||||
|
item="port">
|
||||||
|
{$ ::'Edit port' | translate $}
|
||||||
|
</action>
|
||||||
|
<menu>
|
||||||
|
<action button-type="menu-item"
|
||||||
callback="ctrl.deletePort"
|
callback="ctrl.deletePort"
|
||||||
item="[port]">
|
item="[port]">
|
||||||
<span class="fa fa-trash"></span>
|
<span class="fa fa-trash"></span>
|
||||||
|
{$ ::'Delete port' | translate $}
|
||||||
</action>
|
</action>
|
||||||
|
</menu>
|
||||||
</action-list>
|
</action-list>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
Loading…
Reference in New Issue
Block a user