diff --git a/_0330_cloud_shell_settings.py.sample b/_0330_cloud_shell_settings.py.sample new file mode 100644 index 0000000..b8122a2 --- /dev/null +++ b/_0330_cloud_shell_settings.py.sample @@ -0,0 +1 @@ +CLOUD_SHELL_IMAGE = "gbraad/openstack-client:alpine" diff --git a/doc/source/configuration/index.rst b/doc/source/configuration/index.rst index f5be40a..23cf5b3 100644 --- a/doc/source/configuration/index.rst +++ b/doc/source/configuration/index.rst @@ -2,9 +2,19 @@ Configuration ============= -Zun UI has no configuration option. +Image for Cloud Shell +--------------------- -For more configurations, see +The image for Cloud Shell is set as `gbraad/openstack-client:alpine` +by default. If you want to use other image, edit `CLOUD_SHELL_IMAGE` +variable in file `_0330_cloud_shell_settings.py.sample`, and copy +it to `horizon/openstack_dashboard/local/local_settings.d/_0330_cloud_shell_settings.py`, +and restart Horizon. + +For more configurations +----------------------- + +See `Configuration Guide `__ in the Horizon documentation. diff --git a/zun_ui/api/client.py b/zun_ui/api/client.py index afb42aa..40b5e1f 100644 --- a/zun_ui/api/client.py +++ b/zun_ui/api/client.py @@ -205,6 +205,10 @@ def container_attach(request, id): return zunclient(request).containers.attach(id) +def container_resize(request, id, width, height): + return zunclient(request).containers.resize(id, width, height) + + def image_list(request, limit=None, marker=None, sort_key=None, sort_dir=None, detail=True): return zunclient(request).images.list(limit, marker, sort_key, diff --git a/zun_ui/api/rest_api.py b/zun_ui/api/rest_api.py index 1d3c073..7485c0a 100644 --- a/zun_ui/api/rest_api.py +++ b/zun_ui/api/rest_api.py @@ -90,6 +90,10 @@ class ContainerActions(generic.View): return client.container_kill(request, id, signal) elif action == 'attach': return client.container_attach(request, id) + elif action == 'resize': + width = request.DATA.get("width") or 500 + height = request.DATA.get("height") or 400 + return client.container_resize(request, id, width, height) @urls.register diff --git a/zun_ui/content/cloud_shell/__init__.py b/zun_ui/content/cloud_shell/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zun_ui/content/cloud_shell/views.py b/zun_ui/content/cloud_shell/views.py new file mode 100644 index 0000000..b9ca941 --- /dev/null +++ b/zun_ui/content/cloud_shell/views.py @@ -0,0 +1,27 @@ +# 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. + +from django.conf import settings + +from horizon import views + + +class CloudShellView(views.HorizonTemplateView): + template_name = 'cloud_shell/cloud_shell.html' + + def get_context_data(self, **kwargs): + context = super(CloudShellView, self).get_context_data(**kwargs) + if hasattr(settings, "CLOUD_SHELL_IMAGE"): + context['CLOUD_SHELL_IMAGE'] = settings.CLOUD_SHELL_IMAGE + else: + context['CLOUD_SHELL_IMAGE'] = "gbraad/openstack-client:alpine" + return context diff --git a/zun_ui/enabled/_0330_cloud_shell.py b/zun_ui/enabled/_0330_cloud_shell.py new file mode 100644 index 0000000..3f0db81 --- /dev/null +++ b/zun_ui/enabled/_0330_cloud_shell.py @@ -0,0 +1,26 @@ +# 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. + +FEATURE = True + +ADD_ANGULAR_MODULES = [ + 'horizon.cloud-shell' +] + +ADD_SCSS_FILES = [ + 'cloud-shell/cloud-shell.scss' +] + +# A list of extensible header views to be displayed +ADD_HEADER_SECTIONS = [ + 'zun_ui.content.cloud_shell.views.CloudShellView', +] diff --git a/zun_ui/static/cloud-shell/cloud-shell.controller.js b/zun_ui/static/cloud-shell/cloud-shell.controller.js new file mode 100644 index 0000000..ab57af4 --- /dev/null +++ b/zun_ui/static/cloud-shell/cloud-shell.controller.js @@ -0,0 +1,147 @@ +/** + * 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.cloud-shell') + .controller('horizon.cloud-shell.controller', cloudShellController); + + cloudShellController.$inject = [ + '$scope', + 'horizon.app.core.openstack-service-api.zun', + 'horizon.dashboard.container.webRoot', + 'horizon.framework.util.http.service' + ]; + + function cloudShellController( + $scope, + zun, + webRoot, + http + ) { + var ctrl = this; + ctrl.openInNewWindow = openInNewWindow; + ctrl.close = closeShell; + ctrl.consoleUrl = null; + ctrl.container = {}; + ctrl.resizeTerminal = resizeTerminal; + + // close existing shell + closeShell(); + + // default size for shell + var cols = 80; + var rows = 24; + + // get openrc v3 for OpenStack Client + var cloudsYaml; + http.get('/project/api_access/clouds.yaml/').then(function(response) { + // cloud.yaml to be set to .config/openstack/clouds.yaml in container + cloudsYaml = response.data; + + ctrl.user = cloudsYaml.match(/username: "(.+)"/)[1]; + ctrl.project = cloudsYaml.match(/project_name: "(.+)"/)[1]; + ctrl.userDomain = cloudsYaml.match(/user_domain_name: "(.+)"/); + ctrl.projectDomain = cloudsYaml.match(/project_domain_name: "(.+)"/); + ctrl.domain = (ctrl.userDomain.length === 2) ? ctrl.userDomain[1] : ctrl.projectDomain[1]; + ctrl.region = cloudsYaml.match(/region_name: "(.+)"/)[1]; + + // container name + ctrl.container.name = "cloud-shell-" + ctrl.user + "-" + ctrl.project + + "-" + ctrl.domain + "-" + ctrl.region; + + // get container + zun.getContainer(ctrl.container.name, true).then(onGetContainer, onFailGetContainer); + }); + + function onGetContainer(response) { + ctrl.container = response.data; + + // attach console to existing container + ctrl.consoleUrl = webRoot + "containers/" + ctrl.container.id + "/console"; + var console = $("

To display console, interactive mode needs to be enabled " + + "when this container was created.

"); + if (ctrl.container.status !== "Running") { + console = $("

Container is not running. Please wait for starting up container.

"); + } else if (ctrl.container.interactive) { + console = $(""); + + // execute openrc.sh on the container + var command = "sh -c 'printf \"" + cloudsYaml + "\" > ~/.config/openstack/clouds.yaml'"; + zun.executeContainer(ctrl.container.id, {command: command}).then(function() { + var command = "sh -c 'printf \"export OS_CLOUD=openstack\" > ~/.bashrc'"; + zun.executeContainer(ctrl.container.id, {command: command}).then(function() { + angular.noop(); + }); + }); + } + // append shell content + angular.element("#shell-content").append(console); + } + + // watcher for iframe contents loading, seems to emit once. + $scope.$watch(function() { + return angular.element("#shell-content > iframe").contents() + .find("#terminalNode").attr("termCols"); + }, resizeTerminal); + // event handler to resize console according to window resize. + angular.element(window).bind('resize', resizeTerminal); + // also, add resizeTerminal into callback attribute for resizer directive + function resizeTerminal() { + var shellIframe = angular.element("#shell-content > iframe"); + var newCols = shellIframe.contents().find("#terminalNode").attr("termCols"); + var newRows = shellIframe.contents().find("#terminalNode").attr("termRows"); + if ((newCols !== cols || newRows !== rows) && newCols > 0 && newRows > 0) { + // resize tty + zun.resizeContainer(ctrl.container.id, {width: newCols, height: newRows}).then(function() { + cols = newCols; + rows = newRows; + }); + } + } + + function onFailGetContainer() { + // create new container and attach console to it. + var image = angular.element("#cloud-shell-menu").attr("cloud-shell-image"); + var model = { + name: ctrl.container.name, + image: image, + command: "/bin/bash", + interactive: true, + run: true, + environment: "OS_CLOUD=openstack", + labels: "cloud-shell=" + ctrl.container.name + }; + zun.createContainer(model).then(function (response) { + // attach + onGetContainer({data: {id: response.data.id}}); + }); + } + + function openInNewWindow() { + // open shell in new window + window.open(ctrl.consoleUrl, "_blank"); + closeShell(); + } + + function closeShell() { + // close shell + angular.element("#cloud-shell").remove(); + angular.element("#cloud-shell-resizer").remove(); + } + } +})(); diff --git a/zun_ui/static/cloud-shell/cloud-shell.html b/zun_ui/static/cloud-shell/cloud-shell.html new file mode 100644 index 0000000..126255c --- /dev/null +++ b/zun_ui/static/cloud-shell/cloud-shell.html @@ -0,0 +1,23 @@ +
+ + +
+
+ {$ ctrl.container.name $} + + + + +
+
+
+
+
diff --git a/zun_ui/static/cloud-shell/cloud-shell.module.js b/zun_ui/static/cloud-shell/cloud-shell.module.js new file mode 100644 index 0000000..d995762 --- /dev/null +++ b/zun_ui/static/cloud-shell/cloud-shell.module.js @@ -0,0 +1,37 @@ +/** + * 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'; + + /** + * @ngdoc overview + * @name horizon.cloud-shell + * @description + * cloud_shell module to host container for cloud shell. + */ + angular + .module('horizon.cloud-shell', [ + 'horizon.cloud-shell.resizer', + 'ngRoute' + ]) + .config(config); + + config.$inject = ['$provide', '$windowProvider']; + + function config($provide, $windowProvider) { + var path = $windowProvider.$get().STATIC_URL + 'cloud-shell/'; + $provide.constant('horizon.cloud-shell.basePath', path); + } +})(); diff --git a/zun_ui/static/cloud-shell/cloud-shell.scss b/zun_ui/static/cloud-shell/cloud-shell.scss new file mode 100644 index 0000000..8cadea2 --- /dev/null +++ b/zun_ui/static/cloud-shell/cloud-shell.scss @@ -0,0 +1,51 @@ +#cloud-shell { + position: fixed; + z-index: 10; + bottom: 0px; + width: 100%; + height: 200px; + background-color: black; + color: white; +} +#shell-header { + position: relative; + width: 100%; + height: 20px; + background-color: gray; + padding-left: 3px; +} +#shell-content { + height: calc(100% - 20px); +} +.cloud-shell-external { + position: relative; + top: 0px; + padding-left: 3px; + color: cyan; +} +.cloud-shell-external:hover { + color: red; +} +.cloud-shell-close { + position: relative; + top: 0px; + float: right; + padding-right: 3px; + color: white; +} +.cloud-shell-close:hover { + color: red; +} +#cloud-shell-resizer { + position: absolute; + z-index: 10; + bottom: 200px; + height: 6px; + width: 100%; + background-color: lightgray; + cursor: n-resize; +} +#cloud-shell-resize-holder { + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/zun_ui/static/cloud-shell/cloud-shell.service.js b/zun_ui/static/cloud-shell/cloud-shell.service.js new file mode 100644 index 0000000..1975948 --- /dev/null +++ b/zun_ui/static/cloud-shell/cloud-shell.service.js @@ -0,0 +1,56 @@ +/** + * 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.cloud-shell') + .factory('horizon.cloud-shell.service', cloudShellService); + + cloudShellService.$inject = [ + '$rootScope', + '$templateRequest', + 'horizon.cloud-shell.basePath' + ]; + + function cloudShellService( + $rootScope, + $templateRequest, + basePath + ) { + + var service = { + init: init + }; + + return service; + + function init () { + // remove existing cloud shell + angular.element(".cloud_shell").remove(); + + // load html for cloud shell + $templateRequest(basePath + 'cloud-shell.html').then(function (html) { + var scope = $rootScope.$new(); + var template = angular.element(html); + // compile html + angular.element(document.body).injector().invoke(['$compile', function ($compile) { + $compile(template)(scope); + angular.element('body').append(template); + }]); + }); + } + } +})(); diff --git a/zun_ui/static/cloud-shell/resizer.directive.js b/zun_ui/static/cloud-shell/resizer.directive.js new file mode 100644 index 0000000..ee5b136 --- /dev/null +++ b/zun_ui/static/cloud-shell/resizer.directive.js @@ -0,0 +1,94 @@ +/** + * 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.cloud-shell.resizer', []) + .directive('resizer', resizer); + + resizer.$inject = ['$document']; + + function resizer($document) { + + var directive = { + restrict: 'E', + scope: { + direction: '@', + max: '@', + left: '@', + right: '@', + top: '@', + bottom: '@', + width: '@', + height: '@', + callback: '&' + }, + link: link + }; + + return directive; + + //////////////////// + + function link($scope, $element) { + $element.on('mousedown', function(event) { + event.preventDefault(); + $document.on('mousemove', mousemove); + $document.on('mouseup', mouseup); + }); + + function mousemove(event) { + if ($scope.direction === 'vertical') { + // Handle vertical resizer + var x = event.pageX; + + if ($scope.max && x > $scope.max) { + x = parseInt($scope.max, 10); + } + $element.css({ + left: x + 'px' + }); + $($scope.left).css({ + width: x + 'px' + }); + $($scope.right).css({ + left: (x + parseInt($scope.width, 10)) + 'px' + }); + } else { + // Handle horizontal resizer + var y = window.innerHeight - event.pageY; + $element.css({ + bottom: y + 'px' + }); + $($scope.top).css({ + bottom: (y + parseInt($scope.height, 10)) + 'px' + }); + $($scope.bottom).css({ + height: y + 'px' + }); + } + } + + function mouseup() { + $document.unbind('mousemove', mousemove); + $document.unbind('mouseup', mouseup); + if (typeof $scope.callback === "function") { + $scope.callback(); + } + } + } + } +})(); diff --git a/zun_ui/static/dashboard/container/containers/containers.scss b/zun_ui/static/dashboard/container/containers/containers.scss index f9cd560..1cd91e0 100644 --- a/zun_ui/static/dashboard/container/containers/containers.scss +++ b/zun_ui/static/dashboard/container/containers/containers.scss @@ -3,7 +3,7 @@ } .console { margin-top: 10px; - height: 500px; + height: calc(100vh - 300px); } textarea#output { height: 25em; diff --git a/zun_ui/static/dashboard/container/zun.service.js b/zun_ui/static/dashboard/container/zun.service.js index f168ad4..4ac6f9c 100644 --- a/zun_ui/static/dashboard/container/zun.service.js +++ b/zun_ui/static/dashboard/container/zun.service.js @@ -43,6 +43,7 @@ unpauseContainer: unpauseContainer, executeContainer: executeContainer, killContainer: killContainer, + resizeContainer: resizeContainer, pullImage: pullImage, getImages: getImages }; @@ -63,9 +64,12 @@ return apiService.patch(containersPath + id, params).error(error(msg)); } - function getContainer(id) { - var msg = gettext('Unable to retrieve the Container.'); - return apiService.get(containersPath + id).error(error(msg)); + function getContainer(id, suppressError) { + var promise = apiService.get(containersPath + id); + return suppressError ? promise : promise.error(function() { + var msg = gettext('Unable to retrieve the Container.'); + toastService.add('error', msg); + }); } function getContainers() { @@ -135,6 +139,11 @@ return apiService.post(containersPath + id + '/kill', params).error(error(msg)); } + function resizeContainer(id, params) { + var msg = gettext('Unable to resize console.'); + return apiService.post(containersPath + id + '/resize', params).error(error(msg)); + } + //////////// // Images // //////////// diff --git a/zun_ui/templates/cloud_shell/cloud_shell.html b/zun_ui/templates/cloud_shell/cloud_shell.html new file mode 100644 index 0000000..2c4acbc --- /dev/null +++ b/zun_ui/templates/cloud_shell/cloud_shell.html @@ -0,0 +1,10 @@ +{% load i18n %} + + + + {% trans "Cloud Shell" %} +