Merge "Serial Console"
This commit is contained in:
commit
738cdf26ef
@ -326,11 +326,13 @@ If you do not have multiple regions you should use the ``OPENSTACK_HOST`` and
|
||||
|
||||
Default: ``"AUTO"``
|
||||
|
||||
This setting specifies the type of in-browser VNC console used to access the
|
||||
This setting specifies the type of in-browser console used to access the
|
||||
VMs.
|
||||
Valid values are ``"AUTO"``(default), ``"VNC"``, ``"SPICE"``, ``"RDP"``
|
||||
and ``None`` (this latest value is available in version 2014.2(Juno) to allow
|
||||
deactivating the in-browser console).
|
||||
Valid values are ``"AUTO"``(default), ``"VNC"``, ``"SPICE"``, ``"RDP"``,
|
||||
``"SERIAL"``, and ``None``.
|
||||
``None`` deactivates the in-browser console and is available in version
|
||||
2014.2(Juno).
|
||||
``"SERIAL"`` is available since 2005.1(Kilo).
|
||||
|
||||
|
||||
``INSTANCE_LOG_LENGTH``
|
||||
|
95
horizon/static/horizon/js/angular/directives/serialConsole.js
vendored
Normal file
95
horizon/static/horizon/js/angular/directives/serialConsole.js
vendored
Normal file
@ -0,0 +1,95 @@
|
||||
/*
|
||||
Copyright 2014, Rackspace, US, 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.
|
||||
*/
|
||||
|
||||
/*global Terminal,Blob,FileReader,gettext,interpolate */
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
angular.module('serialConsoleApp', [])
|
||||
.constant('protocols', ['binary', 'base64'])
|
||||
.constant('states', [gettext('Connecting'), gettext('Open'), gettext('Closing'), gettext('Closed')])
|
||||
|
||||
/**
|
||||
* @ngdoc directive
|
||||
* @ngname serialConsole
|
||||
*
|
||||
* @description
|
||||
* The serial-console element creates a terminal based on the widely-used term.js.
|
||||
* The "connection" attribute is input to a WebSocket object, which connects
|
||||
* to a server. In Horizon, this directive is used to connect to nova-serialproxy,
|
||||
* opening a serial console to any instance. Each key the user types is transmitted
|
||||
* to the instance, and each character the instance reponds with is displayed.
|
||||
*/
|
||||
.directive('serialConsole', function(protocols, states) {
|
||||
return {
|
||||
scope: true,
|
||||
template: '<div id="terminalNode"></div><br>{{statusMessage()}}',
|
||||
restrict: 'E',
|
||||
link: function postLink(scope, element, attrs) {
|
||||
|
||||
var connection = scope.$eval(attrs.connection);
|
||||
var term = new Terminal();
|
||||
var socket = new WebSocket(connection, protocols);
|
||||
|
||||
socket.onerror = function() {
|
||||
scope.$apply(scope.status);
|
||||
};
|
||||
socket.onopen = function() {
|
||||
scope.$apply(scope.status);
|
||||
// initialize by "hitting enter"
|
||||
socket.send(String.fromCharCode(13));
|
||||
};
|
||||
socket.onclose = function() {
|
||||
scope.$apply(scope.status);
|
||||
};
|
||||
|
||||
// turn the angular jQlite element into a raw DOM element so we can
|
||||
// attach the Terminal to it
|
||||
var termElement = angular.element(element)[0];
|
||||
term.open(termElement.ownerDocument.getElementById('terminalNode'));
|
||||
|
||||
term.on('data', function(data) {
|
||||
socket.send(data);
|
||||
});
|
||||
|
||||
socket.onmessage = function(e) {
|
||||
if (e.data instanceof Blob) {
|
||||
var f = new FileReader();
|
||||
f.onload = function() {
|
||||
term.write(f.result);
|
||||
};
|
||||
f.readAsText(e.data);
|
||||
} else {
|
||||
term.write(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
scope.status = function() {
|
||||
return states[socket.readyState];
|
||||
};
|
||||
|
||||
scope.statusMessage = function() {
|
||||
return interpolate(gettext('Status: %s'), [scope.status()]);
|
||||
};
|
||||
|
||||
scope.$on('$destroy', function() {
|
||||
socket.close();
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
});
|
||||
}());
|
@ -75,6 +75,14 @@ class RDPConsole(base.APIDictWrapper):
|
||||
_attrs = ['url', 'type']
|
||||
|
||||
|
||||
class SerialConsole(base.APIDictWrapper):
|
||||
"""Wrapper for the "console" dictionary.
|
||||
|
||||
Returned by the novaclient.servers.get_serial_console method.
|
||||
"""
|
||||
_attrs = ['url', 'type']
|
||||
|
||||
|
||||
class Server(base.APIResourceWrapper):
|
||||
"""Simple wrapper around novaclient.server.Server.
|
||||
|
||||
@ -455,6 +463,11 @@ def server_rdp_console(request, instance_id, console_type='rdp-html5'):
|
||||
instance_id, console_type)['console'])
|
||||
|
||||
|
||||
def server_serial_console(request, instance_id, console_type='serial'):
|
||||
return SerialConsole(novaclient(request).servers.get_serial_console(
|
||||
instance_id, console_type)['console'])
|
||||
|
||||
|
||||
def flavor_create(request, name, memory, vcpu, disk, flavorid='auto',
|
||||
ephemeral=0, swap=0, metadata=None, is_public=True):
|
||||
flavor = novaclient(request).flavors.create(name, memory, vcpu, disk,
|
||||
|
@ -27,7 +27,8 @@ LOG = logging.getLogger(__name__)
|
||||
|
||||
CONSOLES = SortedDict([('VNC', api.nova.server_vnc_console),
|
||||
('SPICE', api.nova.server_spice_console),
|
||||
('RDP', api.nova.server_rdp_console)])
|
||||
('RDP', api.nova.server_rdp_console),
|
||||
('SERIAL', api.nova.server_serial_console)])
|
||||
|
||||
|
||||
def get_console(request, console_type, instance):
|
||||
@ -58,10 +59,14 @@ def get_console(request, console_type, instance):
|
||||
LOG.debug('Console not available', exc_info=True)
|
||||
continue
|
||||
|
||||
if con_type == 'SERIAL':
|
||||
console_url = console.url
|
||||
else:
|
||||
console_url = "%s&%s(%s)" % (
|
||||
console.url,
|
||||
urlencode({'title': getattr(instance, "name", "")}),
|
||||
instance.id)
|
||||
|
||||
return (con_type, console_url)
|
||||
|
||||
raise exceptions.NotAvailable(_('No available console found.'))
|
||||
|
@ -68,12 +68,17 @@ class ConsoleTab(tabs.Tab):
|
||||
console_type = getattr(settings, 'CONSOLE_TYPE', 'AUTO')
|
||||
console_url = None
|
||||
try:
|
||||
console_url = console.get_console(request, console_type,
|
||||
instance)[1]
|
||||
console_type, console_url = console.get_console(
|
||||
request, console_type, instance)
|
||||
# For serial console, the url is different from VNC, etc.
|
||||
# because it does not include parms for title and token
|
||||
if console_type == "SERIAL":
|
||||
console_url = "/project/instances/%s/serial" % (instance.id)
|
||||
except exceptions.NotAvailable:
|
||||
exceptions.handle(request, ignore=True, force_log=True)
|
||||
|
||||
return {'console_url': console_url, 'instance_id': instance.id}
|
||||
return {'console_url': console_url, 'instance_id': instance.id,
|
||||
'console_type': console_type}
|
||||
|
||||
def allowed(self, request):
|
||||
# The ConsoleTab is available if settings.CONSOLE_TYPE is not set at
|
||||
|
@ -3,7 +3,11 @@
|
||||
|
||||
<h3>{% trans "Instance Console" %}</h3>
|
||||
{% if console_url %}
|
||||
<p class='alert alert-info'>{% blocktrans %}If console is not responding to keyboard input: click the grey status bar below.{% endblocktrans %} <a href="{{ console_url }}" style="text-decoration: underline">{% trans "Click here to show only console" %}</a><br />
|
||||
<p class='alert alert-info'>
|
||||
{% if console_type != 'SERIAL' %}
|
||||
{% blocktrans %}If console is not responding to keyboard input: click the grey status bar below.{% endblocktrans %}
|
||||
{% endif %}
|
||||
<a href="{{ console_url }}" style="text-decoration: underline">{% trans "Click here to show only console" %}</a><br />
|
||||
{% trans "To exit the fullscreen mode, click the browser's back button." %}</p>
|
||||
<iframe id="console_embed" src="{{ console_url }}" style="width:100%;height:100%"></iframe>
|
||||
<script type="text/javascript">
|
||||
|
@ -0,0 +1,24 @@
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta content='IE=edge' http-equiv='X-UA-Compatible' />
|
||||
<meta content='text/html; charset=utf-8' http-equiv='Content-Type' />
|
||||
<title>{{instance_name}} ({{instance_id}})</title>
|
||||
<link rel="stylesheet" href="{{ STATIC_URL }}dashboard/scss/serial_console.css" type="text/css" media="screen">
|
||||
<script src="{% url 'horizon:jsi18n' 'horizon' %}"></script>
|
||||
<script src='{{ STATIC_URL }}horizon/lib/term.js'></script>
|
||||
<script src="{{ STATIC_URL }}horizon/lib/jquery/jquery.js"></script>
|
||||
<script src="{{ STATIC_URL }}horizon/lib/angular/angular.js"></script>
|
||||
<script src="{{ STATIC_URL }}horizon/js/angular/directives/serialConsole.js"></script>
|
||||
</head>
|
||||
<body ng-app='serialConsoleApp'>
|
||||
|
||||
{% if error_message %}
|
||||
{{ error_message }}
|
||||
{% else %}
|
||||
<serial-console connection='"{{console_url}}"'></serial-console>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
@ -4252,7 +4252,8 @@ class ConsoleManagerTests(helpers.TestCase):
|
||||
console.CONSOLES = SortedDict([
|
||||
('VNC', api.nova.server_vnc_console),
|
||||
('SPICE', api.nova.server_spice_console),
|
||||
('RDP', api.nova.server_rdp_console)])
|
||||
('RDP', api.nova.server_rdp_console),
|
||||
('SERIAL', api.nova.server_serial_console)])
|
||||
|
||||
def _get_console_vnc(self, server):
|
||||
console_mock = self.mox.CreateMock(api.nova.VNCConsole)
|
||||
@ -4308,6 +4309,24 @@ class ConsoleManagerTests(helpers.TestCase):
|
||||
data = console.get_console(self.request, 'RDP', server)[1]
|
||||
self.assertEqual(data, url)
|
||||
|
||||
def _get_console_serial(self, server):
|
||||
console_mock = self.mox.CreateMock(api.nova.SerialConsole)
|
||||
console_mock.url = '/SERIAL'
|
||||
|
||||
self.mox.StubOutWithMock(api.nova, 'server_serial_console')
|
||||
api.nova.server_serial_console(IgnoreArg(), server.id) \
|
||||
.AndReturn(console_mock)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
self.setup_consoles()
|
||||
|
||||
def test_get_console_serial(self):
|
||||
server = self.servers.first()
|
||||
self._get_console_serial(server)
|
||||
url = '/SERIAL'
|
||||
data = console.get_console(self.request, 'SERIAL', server)[1]
|
||||
self.assertEqual(data, url)
|
||||
|
||||
def test_get_console_auto_iterate_available(self):
|
||||
server = self.servers.first()
|
||||
|
||||
@ -4333,6 +4352,35 @@ class ConsoleManagerTests(helpers.TestCase):
|
||||
data = console.get_console(self.request, 'AUTO', server)[1]
|
||||
self.assertEqual(data, url)
|
||||
|
||||
def test_get_console_auto_iterate_serial_available(self):
|
||||
server = self.servers.first()
|
||||
|
||||
console_mock = self.mox.CreateMock(api.nova.SerialConsole)
|
||||
console_mock.url = '/SERIAL'
|
||||
|
||||
self.mox.StubOutWithMock(api.nova, 'server_vnc_console')
|
||||
api.nova.server_vnc_console(IgnoreArg(), server.id) \
|
||||
.AndRaise(self.exceptions.nova)
|
||||
|
||||
self.mox.StubOutWithMock(api.nova, 'server_spice_console')
|
||||
api.nova.server_spice_console(IgnoreArg(), server.id) \
|
||||
.AndRaise(self.exceptions.nova)
|
||||
|
||||
self.mox.StubOutWithMock(api.nova, 'server_rdp_console')
|
||||
api.nova.server_rdp_console(IgnoreArg(), server.id) \
|
||||
.AndRaise(self.exceptions.nova)
|
||||
|
||||
self.mox.StubOutWithMock(api.nova, 'server_serial_console')
|
||||
api.nova.server_serial_console(IgnoreArg(), server.id) \
|
||||
.AndReturn(console_mock)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
self.setup_consoles()
|
||||
|
||||
url = '/SERIAL'
|
||||
data = console.get_console(self.request, 'AUTO', server)[1]
|
||||
self.assertEqual(data, url)
|
||||
|
||||
def test_invalid_console_type_raise_value_error(self):
|
||||
self.assertRaises(exceptions.NotAvailable,
|
||||
console.get_console, None, 'FAKE', None)
|
||||
|
@ -35,6 +35,8 @@ urlpatterns = patterns(
|
||||
views.DetailView.as_view(), name='detail'),
|
||||
url(INSTANCES % 'update', views.UpdateView.as_view(), name='update'),
|
||||
url(INSTANCES % 'rebuild', views.RebuildView.as_view(), name='rebuild'),
|
||||
url(INSTANCES % 'serial', views.SerialConsoleView.as_view(),
|
||||
name='serial'),
|
||||
url(INSTANCES % 'console', 'console', name='console'),
|
||||
url(INSTANCES % 'vnc', 'vnc', name='vnc'),
|
||||
url(INSTANCES % 'spice', 'spice', name='spice'),
|
||||
|
@ -25,6 +25,7 @@ from django import http
|
||||
from django import shortcuts
|
||||
from django.utils.datastructures import SortedDict
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views import generic
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
@ -193,6 +194,35 @@ def rdp(request, instance_id):
|
||||
exceptions.handle(request, msg, redirect=redirect)
|
||||
|
||||
|
||||
class SerialConsoleView(generic.TemplateView):
|
||||
template_name = 'project/instances/serial_console.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(SerialConsoleView, self).get_context_data(**kwargs)
|
||||
context['instance_id'] = self.kwargs['instance_id']
|
||||
instance = None
|
||||
try:
|
||||
instance = api.nova.server_get(context['view'].request,
|
||||
self.kwargs['instance_id'])
|
||||
except Exception:
|
||||
context["error_message"] = _(
|
||||
"Cannot find instance %s.") % self.kwargs['instance_id']
|
||||
# name is unknown, so leave it blank for the window title
|
||||
# in full-screen mode, so only the instance id is shown.
|
||||
context['instance_name'] = ''
|
||||
return context
|
||||
context['instance_name'] = instance.name
|
||||
try:
|
||||
console_url = project_console.get_console(context['view'].request,
|
||||
"SERIAL", instance)[1]
|
||||
context["console_url"] = console_url
|
||||
except exceptions.NotAvailable:
|
||||
context["error_message"] = _(
|
||||
"Cannot get console for instance %s.") % self.kwargs[
|
||||
'instance_id']
|
||||
return context
|
||||
|
||||
|
||||
class UpdateView(workflows.WorkflowView):
|
||||
workflow_class = project_workflows.UpdateInstance
|
||||
success_url = reverse_lazy("horizon:project:instances:index")
|
||||
|
@ -47,7 +47,7 @@ TEMPLATE_DEBUG = DEBUG
|
||||
# OPENSTACK_KEYSTONE_DEFAULT_DOMAIN = 'Default'
|
||||
|
||||
# Set Console type:
|
||||
# valid options would be "AUTO"(default), "VNC", "SPICE", "RDP" or None
|
||||
# valid options would be "AUTO"(default), "VNC", "SPICE", "RDP", "SERIAL" or None
|
||||
# Set to None explicitly if you want to deactivate the console.
|
||||
# CONSOLE_TYPE = "AUTO"
|
||||
|
||||
|
19
openstack_dashboard/static/dashboard/scss/serial_console.css
Normal file
19
openstack_dashboard/static/dashboard/scss/serial_console.css
Normal file
@ -0,0 +1,19 @@
|
||||
/* Stand-alone CSS file for Serial Console IFrame. */
|
||||
|
||||
#terminalNode {
|
||||
display:inline-block;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
float: left;
|
||||
border: black solid 5px;
|
||||
font-family: "DejaVu Sans Mono", "Liberation Mono", monospace;
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
background: black;
|
||||
}
|
||||
|
||||
.terminal-cursor {
|
||||
color: black;
|
||||
background: white;
|
||||
};
|
@ -36,6 +36,7 @@ import xstatic.pkg.jsencrypt
|
||||
import xstatic.pkg.qunit
|
||||
import xstatic.pkg.rickshaw
|
||||
import xstatic.pkg.spin
|
||||
import xstatic.pkg.termjs
|
||||
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
@ -73,6 +74,8 @@ STATICFILES_DIRS = [
|
||||
xstatic.main.XStatic(xstatic.pkg.rickshaw).base_dir),
|
||||
('horizon/lib',
|
||||
xstatic.main.XStatic(xstatic.pkg.spin).base_dir),
|
||||
('horizon/lib',
|
||||
xstatic.main.XStatic(xstatic.pkg.termjs).base_dir),
|
||||
]
|
||||
|
||||
|
||||
|
@ -58,3 +58,4 @@ XStatic-QUnit>=1.14.0.2 # MIT License
|
||||
XStatic-Rickshaw>=1.5.0 # BSD License (prior)
|
||||
XStatic-smart-table>=1.4.5.3 # MIT License
|
||||
XStatic-Spin>=1.2.5.2 # MIT License
|
||||
XStatic-term.js>=0.0.4 # MIT License
|
||||
|
Loading…
Reference in New Issue
Block a user