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"``
|
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.
|
VMs.
|
||||||
Valid values are ``"AUTO"``(default), ``"VNC"``, ``"SPICE"``, ``"RDP"``
|
Valid values are ``"AUTO"``(default), ``"VNC"``, ``"SPICE"``, ``"RDP"``,
|
||||||
and ``None`` (this latest value is available in version 2014.2(Juno) to allow
|
``"SERIAL"``, and ``None``.
|
||||||
deactivating the in-browser console).
|
``None`` deactivates the in-browser console and is available in version
|
||||||
|
2014.2(Juno).
|
||||||
|
``"SERIAL"`` is available since 2005.1(Kilo).
|
||||||
|
|
||||||
|
|
||||||
``INSTANCE_LOG_LENGTH``
|
``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']
|
_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):
|
class Server(base.APIResourceWrapper):
|
||||||
"""Simple wrapper around novaclient.server.Server.
|
"""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'])
|
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',
|
def flavor_create(request, name, memory, vcpu, disk, flavorid='auto',
|
||||||
ephemeral=0, swap=0, metadata=None, is_public=True):
|
ephemeral=0, swap=0, metadata=None, is_public=True):
|
||||||
flavor = novaclient(request).flavors.create(name, memory, vcpu, disk,
|
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),
|
CONSOLES = SortedDict([('VNC', api.nova.server_vnc_console),
|
||||||
('SPICE', api.nova.server_spice_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):
|
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)
|
LOG.debug('Console not available', exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
console_url = "%s&%s(%s)" % (
|
if con_type == 'SERIAL':
|
||||||
console.url,
|
console_url = console.url
|
||||||
urlencode({'title': getattr(instance, "name", "")}),
|
else:
|
||||||
instance.id)
|
console_url = "%s&%s(%s)" % (
|
||||||
|
console.url,
|
||||||
|
urlencode({'title': getattr(instance, "name", "")}),
|
||||||
|
instance.id)
|
||||||
|
|
||||||
return (con_type, console_url)
|
return (con_type, console_url)
|
||||||
|
|
||||||
raise exceptions.NotAvailable(_('No available console found.'))
|
raise exceptions.NotAvailable(_('No available console found.'))
|
||||||
|
@ -68,12 +68,17 @@ class ConsoleTab(tabs.Tab):
|
|||||||
console_type = getattr(settings, 'CONSOLE_TYPE', 'AUTO')
|
console_type = getattr(settings, 'CONSOLE_TYPE', 'AUTO')
|
||||||
console_url = None
|
console_url = None
|
||||||
try:
|
try:
|
||||||
console_url = console.get_console(request, console_type,
|
console_type, console_url = console.get_console(
|
||||||
instance)[1]
|
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:
|
except exceptions.NotAvailable:
|
||||||
exceptions.handle(request, ignore=True, force_log=True)
|
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):
|
def allowed(self, request):
|
||||||
# The ConsoleTab is available if settings.CONSOLE_TYPE is not set at
|
# The ConsoleTab is available if settings.CONSOLE_TYPE is not set at
|
||||||
|
@ -3,7 +3,11 @@
|
|||||||
|
|
||||||
<h3>{% trans "Instance Console" %}</h3>
|
<h3>{% trans "Instance Console" %}</h3>
|
||||||
{% if console_url %}
|
{% 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>
|
{% 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>
|
<iframe id="console_embed" src="{{ console_url }}" style="width:100%;height:100%"></iframe>
|
||||||
<script type="text/javascript">
|
<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([
|
console.CONSOLES = SortedDict([
|
||||||
('VNC', api.nova.server_vnc_console),
|
('VNC', api.nova.server_vnc_console),
|
||||||
('SPICE', api.nova.server_spice_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):
|
def _get_console_vnc(self, server):
|
||||||
console_mock = self.mox.CreateMock(api.nova.VNCConsole)
|
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]
|
data = console.get_console(self.request, 'RDP', server)[1]
|
||||||
self.assertEqual(data, url)
|
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):
|
def test_get_console_auto_iterate_available(self):
|
||||||
server = self.servers.first()
|
server = self.servers.first()
|
||||||
|
|
||||||
@ -4333,6 +4352,35 @@ class ConsoleManagerTests(helpers.TestCase):
|
|||||||
data = console.get_console(self.request, 'AUTO', server)[1]
|
data = console.get_console(self.request, 'AUTO', server)[1]
|
||||||
self.assertEqual(data, url)
|
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):
|
def test_invalid_console_type_raise_value_error(self):
|
||||||
self.assertRaises(exceptions.NotAvailable,
|
self.assertRaises(exceptions.NotAvailable,
|
||||||
console.get_console, None, 'FAKE', None)
|
console.get_console, None, 'FAKE', None)
|
||||||
|
@ -35,6 +35,8 @@ urlpatterns = patterns(
|
|||||||
views.DetailView.as_view(), name='detail'),
|
views.DetailView.as_view(), name='detail'),
|
||||||
url(INSTANCES % 'update', views.UpdateView.as_view(), name='update'),
|
url(INSTANCES % 'update', views.UpdateView.as_view(), name='update'),
|
||||||
url(INSTANCES % 'rebuild', views.RebuildView.as_view(), name='rebuild'),
|
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 % 'console', 'console', name='console'),
|
||||||
url(INSTANCES % 'vnc', 'vnc', name='vnc'),
|
url(INSTANCES % 'vnc', 'vnc', name='vnc'),
|
||||||
url(INSTANCES % 'spice', 'spice', name='spice'),
|
url(INSTANCES % 'spice', 'spice', name='spice'),
|
||||||
|
@ -25,6 +25,7 @@ from django import http
|
|||||||
from django import shortcuts
|
from django import shortcuts
|
||||||
from django.utils.datastructures import SortedDict
|
from django.utils.datastructures import SortedDict
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from django.views import generic
|
||||||
|
|
||||||
from horizon import exceptions
|
from horizon import exceptions
|
||||||
from horizon import forms
|
from horizon import forms
|
||||||
@ -193,6 +194,35 @@ def rdp(request, instance_id):
|
|||||||
exceptions.handle(request, msg, redirect=redirect)
|
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):
|
class UpdateView(workflows.WorkflowView):
|
||||||
workflow_class = project_workflows.UpdateInstance
|
workflow_class = project_workflows.UpdateInstance
|
||||||
success_url = reverse_lazy("horizon:project:instances:index")
|
success_url = reverse_lazy("horizon:project:instances:index")
|
||||||
|
@ -47,7 +47,7 @@ TEMPLATE_DEBUG = DEBUG
|
|||||||
# OPENSTACK_KEYSTONE_DEFAULT_DOMAIN = 'Default'
|
# OPENSTACK_KEYSTONE_DEFAULT_DOMAIN = 'Default'
|
||||||
|
|
||||||
# Set Console type:
|
# 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.
|
# Set to None explicitly if you want to deactivate the console.
|
||||||
# CONSOLE_TYPE = "AUTO"
|
# 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.qunit
|
||||||
import xstatic.pkg.rickshaw
|
import xstatic.pkg.rickshaw
|
||||||
import xstatic.pkg.spin
|
import xstatic.pkg.spin
|
||||||
|
import xstatic.pkg.termjs
|
||||||
|
|
||||||
|
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
@ -73,6 +74,8 @@ STATICFILES_DIRS = [
|
|||||||
xstatic.main.XStatic(xstatic.pkg.rickshaw).base_dir),
|
xstatic.main.XStatic(xstatic.pkg.rickshaw).base_dir),
|
||||||
('horizon/lib',
|
('horizon/lib',
|
||||||
xstatic.main.XStatic(xstatic.pkg.spin).base_dir),
|
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-Rickshaw>=1.5.0 # BSD License (prior)
|
||||||
XStatic-smart-table>=1.4.5.3 # MIT License
|
XStatic-smart-table>=1.4.5.3 # MIT License
|
||||||
XStatic-Spin>=1.2.5.2 # MIT License
|
XStatic-Spin>=1.2.5.2 # MIT License
|
||||||
|
XStatic-term.js>=0.0.4 # MIT License
|
||||||
|
Loading…
x
Reference in New Issue
Block a user