Merge pull request #1383 from ellisonbg/nbparallel
IPython clusters can now be managed using the Notebook.
commit
5a444db9f7
@ -0,0 +1,175 @@
|
||||
"""Manage IPython.parallel clusters in the notebook.
|
||||
|
||||
Authors:
|
||||
|
||||
* Brian Granger
|
||||
"""
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
# Copyright (C) 2008-2011 The IPython Development Team
|
||||
#
|
||||
# Distributed under the terms of the BSD License. The full license is in
|
||||
# the file COPYING, distributed as part of this software.
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
# Imports
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
import os
|
||||
|
||||
from tornado import web
|
||||
from zmq.eventloop import ioloop
|
||||
|
||||
from IPython.config.configurable import LoggingConfigurable
|
||||
from IPython.config.loader import load_pyconfig_files
|
||||
from IPython.utils.traitlets import Dict, Instance, CFloat
|
||||
from IPython.parallel.apps.ipclusterapp import IPClusterStart
|
||||
from IPython.core.profileapp import list_profiles_in
|
||||
from IPython.core.profiledir import ProfileDir
|
||||
from IPython.utils.path import get_ipython_dir
|
||||
from IPython.utils.sysinfo import num_cpus
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
# Classes
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DummyIPClusterStart(IPClusterStart):
|
||||
"""Dummy subclass to skip init steps that conflict with global app.
|
||||
|
||||
Instantiating and initializing this class should result in fully configured
|
||||
launchers, but no other side effects or state.
|
||||
"""
|
||||
|
||||
def init_signal(self):
|
||||
pass
|
||||
def init_logging(self):
|
||||
pass
|
||||
def reinit_logging(self):
|
||||
pass
|
||||
|
||||
|
||||
class ClusterManager(LoggingConfigurable):
|
||||
|
||||
profiles = Dict()
|
||||
|
||||
delay = CFloat(1., config=True,
|
||||
help="delay (in s) between starting the controller and the engines")
|
||||
|
||||
loop = Instance('zmq.eventloop.ioloop.IOLoop')
|
||||
def _loop_default(self):
|
||||
from zmq.eventloop.ioloop import IOLoop
|
||||
return IOLoop.instance()
|
||||
|
||||
def build_launchers(self, profile_dir):
|
||||
starter = DummyIPClusterStart(log=self.log)
|
||||
starter.initialize(['--profile-dir', profile_dir])
|
||||
cl = starter.controller_launcher
|
||||
esl = starter.engine_launcher
|
||||
n = starter.n
|
||||
return cl, esl, n
|
||||
|
||||
def get_profile_dir(self, name, path):
|
||||
p = ProfileDir.find_profile_dir_by_name(path,name=name)
|
||||
return p.location
|
||||
|
||||
def update_profiles(self):
|
||||
"""List all profiles in the ipython_dir and cwd.
|
||||
"""
|
||||
for path in [get_ipython_dir(), os.getcwdu()]:
|
||||
for profile in list_profiles_in(path):
|
||||
pd = self.get_profile_dir(profile, path)
|
||||
if profile not in self.profiles:
|
||||
self.log.debug("Overwriting profile %s" % profile)
|
||||
self.profiles[profile] = {
|
||||
'profile': profile,
|
||||
'profile_dir': pd,
|
||||
'status': 'stopped'
|
||||
}
|
||||
|
||||
def list_profiles(self):
|
||||
self.update_profiles()
|
||||
result = [self.profile_info(p) for p in self.profiles.keys()]
|
||||
result.sort()
|
||||
return result
|
||||
|
||||
def check_profile(self, profile):
|
||||
if profile not in self.profiles:
|
||||
raise web.HTTPError(404, u'profile not found')
|
||||
|
||||
def profile_info(self, profile):
|
||||
self.check_profile(profile)
|
||||
result = {}
|
||||
data = self.profiles.get(profile)
|
||||
result['profile'] = profile
|
||||
result['profile_dir'] = data['profile_dir']
|
||||
result['status'] = data['status']
|
||||
if 'n' in data:
|
||||
result['n'] = data['n']
|
||||
return result
|
||||
|
||||
def start_cluster(self, profile, n=None):
|
||||
"""Start a cluster for a given profile."""
|
||||
self.check_profile(profile)
|
||||
data = self.profiles[profile]
|
||||
if data['status'] == 'running':
|
||||
raise web.HTTPError(409, u'cluster already running')
|
||||
cl, esl, default_n = self.build_launchers(data['profile_dir'])
|
||||
n = n if n is not None else default_n
|
||||
def clean_data():
|
||||
data.pop('controller_launcher',None)
|
||||
data.pop('engine_set_launcher',None)
|
||||
data.pop('n',None)
|
||||
data['status'] = 'stopped'
|
||||
def engines_stopped(r):
|
||||
self.log.debug('Engines stopped')
|
||||
if cl.running:
|
||||
cl.stop()
|
||||
clean_data()
|
||||
esl.on_stop(engines_stopped)
|
||||
def controller_stopped(r):
|
||||
self.log.debug('Controller stopped')
|
||||
if esl.running:
|
||||
esl.stop()
|
||||
clean_data()
|
||||
cl.on_stop(controller_stopped)
|
||||
|
||||
dc = ioloop.DelayedCallback(lambda: cl.start(), 0, self.loop)
|
||||
dc.start()
|
||||
dc = ioloop.DelayedCallback(lambda: esl.start(n), 1000*self.delay, self.loop)
|
||||
dc.start()
|
||||
|
||||
self.log.debug('Cluster started')
|
||||
data['controller_launcher'] = cl
|
||||
data['engine_set_launcher'] = esl
|
||||
data['n'] = n
|
||||
data['status'] = 'running'
|
||||
return self.profile_info(profile)
|
||||
|
||||
def stop_cluster(self, profile):
|
||||
"""Stop a cluster for a given profile."""
|
||||
self.check_profile(profile)
|
||||
data = self.profiles[profile]
|
||||
if data['status'] == 'stopped':
|
||||
raise web.HTTPError(409, u'cluster not running')
|
||||
data = self.profiles[profile]
|
||||
cl = data['controller_launcher']
|
||||
esl = data['engine_set_launcher']
|
||||
if cl.running:
|
||||
cl.stop()
|
||||
if esl.running:
|
||||
esl.stop()
|
||||
# Return a temp info dict, the real one is updated in the on_stop
|
||||
# logic above.
|
||||
result = {
|
||||
'profile': data['profile'],
|
||||
'profile_dir': data['profile_dir'],
|
||||
'status': 'stopped'
|
||||
}
|
||||
return result
|
||||
|
||||
def stop_all_clusters(self):
|
||||
for p in self.profiles.keys():
|
||||
self.stop_cluster(profile)
|
||||
@ -0,0 +1,6 @@
|
||||
|
||||
#main_app {
|
||||
height: 100px;
|
||||
width: 350px;
|
||||
margin: 50px auto;
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
|
||||
#main_app {
|
||||
height: 100px;
|
||||
width: 200px;
|
||||
margin: 50px auto;
|
||||
}
|
||||
|
||||
@ -0,0 +1,180 @@
|
||||
//----------------------------------------------------------------------------
|
||||
// Copyright (C) 2008-2011 The IPython Development Team
|
||||
//
|
||||
// Distributed under the terms of the BSD License. The full license is in
|
||||
// the file COPYING, distributed as part of this software.
|
||||
//----------------------------------------------------------------------------
|
||||
|
||||
//============================================================================
|
||||
// NotebookList
|
||||
//============================================================================
|
||||
|
||||
var IPython = (function (IPython) {
|
||||
|
||||
var ClusterList = function (selector) {
|
||||
this.selector = selector;
|
||||
if (this.selector !== undefined) {
|
||||
this.element = $(selector);
|
||||
this.style();
|
||||
this.bind_events();
|
||||
}
|
||||
};
|
||||
|
||||
ClusterList.prototype.style = function () {
|
||||
$('#cluster_toolbar').addClass('list_toolbar');
|
||||
$('#cluster_list_info').addClass('toolbar_info');
|
||||
$('#cluster_buttons').addClass('toolbar_buttons');
|
||||
$('div#cluster_header').addClass('list_header ui-widget ui-widget-header ui-helper-clearfix');
|
||||
$('div#cluster_header').children().eq(0).addClass('profile_col');
|
||||
$('div#cluster_header').children().eq(1).addClass('action_col');
|
||||
$('div#cluster_header').children().eq(2).addClass('engines_col');
|
||||
$('div#cluster_header').children().eq(3).addClass('status_col');
|
||||
$('#refresh_cluster_list').button({
|
||||
icons : {primary: 'ui-icon-arrowrefresh-1-s'},
|
||||
text : false
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
ClusterList.prototype.bind_events = function () {
|
||||
var that = this;
|
||||
$('#refresh_cluster_list').click(function () {
|
||||
that.load_list();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
ClusterList.prototype.load_list = function () {
|
||||
var settings = {
|
||||
processData : false,
|
||||
cache : false,
|
||||
type : "GET",
|
||||
dataType : "json",
|
||||
success : $.proxy(this.load_list_success, this)
|
||||
};
|
||||
var url = $('body').data('baseProjectUrl') + 'clusters';
|
||||
$.ajax(url, settings);
|
||||
};
|
||||
|
||||
|
||||
ClusterList.prototype.clear_list = function () {
|
||||
this.element.children('.list_item').remove();
|
||||
}
|
||||
|
||||
ClusterList.prototype.load_list_success = function (data, status, xhr) {
|
||||
this.clear_list();
|
||||
var len = data.length;
|
||||
for (var i=0; i<len; i++) {
|
||||
var item_div = $('<div/>');
|
||||
var item = new ClusterItem(item_div);
|
||||
item.update_state(data[i]);
|
||||
item_div.data('item', item);
|
||||
this.element.append(item_div);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
var ClusterItem = function (element) {
|
||||
this.element = $(element);
|
||||
this.data = null;
|
||||
this.style();
|
||||
};
|
||||
|
||||
|
||||
ClusterItem.prototype.style = function () {
|
||||
this.element.addClass('list_item ui-widget ui-widget-content ui-helper-clearfix');
|
||||
this.element.css('border-top-style','none');
|
||||
}
|
||||
|
||||
ClusterItem.prototype.update_state = function (data) {
|
||||
this.data = data;
|
||||
if (data.status === 'running') {
|
||||
this.state_running();
|
||||
} else if (data.status === 'stopped') {
|
||||
this.state_stopped();
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
ClusterItem.prototype.state_stopped = function () {
|
||||
var that = this;
|
||||
this.element.empty();
|
||||
var profile_col = $('<span/>').addClass('profile_col').text(this.data.profile);
|
||||
var status_col = $('<span/>').addClass('status_col').html('stopped');
|
||||
var engines_col = $('<span/>').addClass('engines_col');
|
||||
var input = $('<input/>').attr('type','text').
|
||||
attr('size',3).addClass('engine_num_input');
|
||||
engines_col.append(input);
|
||||
var action_col = $('<span/>').addClass('action_col');
|
||||
var start_button = $('<button>Start</button>').button();
|
||||
action_col.append(start_button);
|
||||
this.element.append(profile_col).
|
||||
append(action_col).
|
||||
append(engines_col).
|
||||
append(status_col);
|
||||
start_button.click(function (e) {
|
||||
var n = that.element.find('.engine_num_input').val();
|
||||
if (!/^\d+$/.test(n) && n.length>0) {
|
||||
status_col.html('invalid engine #');
|
||||
} else {
|
||||
var settings = {
|
||||
cache : false,
|
||||
data : {n:n},
|
||||
type : "POST",
|
||||
dataType : "json",
|
||||
success : function (data, status, xhr) {
|
||||
that.update_state(data);
|
||||
},
|
||||
error : function (data, status, xhr) {
|
||||
status_col.html("error starting cluster")
|
||||
}
|
||||
};
|
||||
status_col.html('starting');
|
||||
var url = $('body').data('baseProjectUrl') + 'clusters/' + that.data.profile + '/start';
|
||||
$.ajax(url, settings);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
ClusterItem.prototype.state_running = function () {
|
||||
this.element.empty();
|
||||
var that = this;
|
||||
var profile_col = $('<span/>').addClass('profile_col').text(this.data.profile);
|
||||
var status_col = $('<span/>').addClass('status_col').html('running');
|
||||
var engines_col = $('<span/>').addClass('engines_col').html(this.data.n);
|
||||
var action_col = $('<span/>').addClass('action_col');
|
||||
var stop_button = $('<button>Stop</button>').button();
|
||||
action_col.append(stop_button);
|
||||
this.element.append(profile_col).
|
||||
append(action_col).
|
||||
append(engines_col).
|
||||
append(status_col);
|
||||
stop_button.click(function (e) {
|
||||
var settings = {
|
||||
cache : false,
|
||||
type : "POST",
|
||||
dataType : "json",
|
||||
success : function (data, status, xhr) {
|
||||
that.update_state(data);
|
||||
},
|
||||
error : function (data, status, xhr) {
|
||||
console.log('error',data);
|
||||
status_col.html("error stopping cluster")
|
||||
}
|
||||
};
|
||||
status_col.html('stopping')
|
||||
var url = $('body').data('baseProjectUrl') + 'clusters/' + that.data.profile + '/stop';
|
||||
$.ajax(url, settings);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
IPython.ClusterList = ClusterList;
|
||||
IPython.ClusterItem = ClusterItem;
|
||||
|
||||
return IPython;
|
||||
|
||||
}(IPython));
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
//----------------------------------------------------------------------------
|
||||
// Copyright (C) 2008-2011 The IPython Development Team
|
||||
//
|
||||
// Distributed under the terms of the BSD License. The full license is in
|
||||
// the file COPYING, distributed as part of this software.
|
||||
//----------------------------------------------------------------------------
|
||||
|
||||
//============================================================================
|
||||
// On document ready
|
||||
//============================================================================
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
|
||||
IPython.page = new IPython.Page();
|
||||
$('div#main_app').addClass('border-box-sizing ui-widget');
|
||||
IPython.page.show();
|
||||
|
||||
});
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
//----------------------------------------------------------------------------
|
||||
// Copyright (C) 2008-2011 The IPython Development Team
|
||||
//
|
||||
// Distributed under the terms of the BSD License. The full license is in
|
||||
// the file COPYING, distributed as part of this software.
|
||||
//----------------------------------------------------------------------------
|
||||
|
||||
//============================================================================
|
||||
// Global header/site setup.
|
||||
//============================================================================
|
||||
|
||||
var IPython = (function (IPython) {
|
||||
|
||||
var Page = function () {
|
||||
this.style();
|
||||
this.bind_events();
|
||||
};
|
||||
|
||||
Page.prototype.style = function () {
|
||||
$('div#header').addClass('border-box-sizing').
|
||||
addClass('ui-widget ui-widget-content').
|
||||
css('border-top-style','none').
|
||||
css('border-left-style','none').
|
||||
css('border-right-style','none');
|
||||
$('div#site').addClass('border-box-sizing')
|
||||
};
|
||||
|
||||
|
||||
Page.prototype.bind_events = function () {
|
||||
};
|
||||
|
||||
|
||||
Page.prototype.show = function () {
|
||||
// The header and site divs start out hidden to prevent FLOUC.
|
||||
// Main scripts should call this method after styling everything.
|
||||
this.show_header();
|
||||
this.show_site();
|
||||
};
|
||||
|
||||
|
||||
Page.prototype.show_header = function () {
|
||||
// The header and site divs start out hidden to prevent FLOUC.
|
||||
// Main scripts should call this method after styling everything.
|
||||
$('div#header').css('display','block');
|
||||
};
|
||||
|
||||
|
||||
Page.prototype.show_site = function () {
|
||||
// The header and site divs start out hidden to prevent FLOUC.
|
||||
// Main scripts should call this method after styling everything.
|
||||
$('div#site').css('display','block');
|
||||
};
|
||||
|
||||
|
||||
IPython.Page = Page;
|
||||
|
||||
return IPython;
|
||||
|
||||
}(IPython));
|
||||
@ -0,0 +1,19 @@
|
||||
//----------------------------------------------------------------------------
|
||||
// Copyright (C) 2008-2011 The IPython Development Team
|
||||
//
|
||||
// Distributed under the terms of the BSD License. The full license is in
|
||||
// the file COPYING, distributed as part of this software.
|
||||
//----------------------------------------------------------------------------
|
||||
|
||||
//============================================================================
|
||||
// On document ready
|
||||
//============================================================================
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
|
||||
IPython.page = new IPython.Page();
|
||||
IPython.page.show();
|
||||
|
||||
});
|
||||
|
||||
@ -1,26 +1,42 @@
|
||||
{% extends layout.html %}
|
||||
{% extends page.html %}
|
||||
|
||||
{% block content_panel %}
|
||||
{% block stylesheet %}
|
||||
|
||||
{% if login_available %}
|
||||
<link rel="stylesheet" href="{{static_url("css/login.css") }}" type="text/css"/>
|
||||
|
||||
{% end %}
|
||||
|
||||
|
||||
{% block login_widget %}
|
||||
{% end %}
|
||||
|
||||
|
||||
{% block site %}
|
||||
|
||||
<div id="main_app">
|
||||
|
||||
{% if login_available %}
|
||||
<form action="/login?next={{url_escape(next)}}" method="post">
|
||||
Password: <input type="password" name="password" id="focus">
|
||||
<input type="submit" value="Sign in" id="signin">
|
||||
Password: <input type="password" name="password" id="password_input">
|
||||
<input type="submit" value="Log in" id="login_submit">
|
||||
</form>
|
||||
{% end %}
|
||||
|
||||
{% if message %}
|
||||
{% for key in message %}
|
||||
<div class="message {{key}}">
|
||||
{{message[key]}}
|
||||
</div>
|
||||
{% end %}
|
||||
{% end %}
|
||||
|
||||
{% end %}
|
||||
<div/>
|
||||
|
||||
{% block login_widget %}
|
||||
{% end %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
IPython.login_widget = new IPython.LoginWidget('span#login_widget');
|
||||
$('#focus').focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="{{static_url("js/loginmain.js") }}" type="text/javascript" charset="utf-8"></script>
|
||||
|
||||
{% end %}
|
||||
|
||||
@ -1,28 +1,40 @@
|
||||
{% extends layout.html %}
|
||||
{% extends page.html %}
|
||||
|
||||
{% block content_panel %}
|
||||
<ul>
|
||||
{% if read_only or not login_available %}
|
||||
{% block stylesheet %}
|
||||
|
||||
Proceed to the <a href="/">list of notebooks</a>.</li>
|
||||
<link rel="stylesheet" href="{{static_url("css/logout.css") }}" type="text/css"/>
|
||||
|
||||
{% else %}
|
||||
{% end %}
|
||||
|
||||
|
||||
{% block login_widget %}
|
||||
{% end %}
|
||||
|
||||
{% block site %}
|
||||
|
||||
Proceed to the <a href="/login">login page</a>.</li>
|
||||
<div id="main_app">
|
||||
|
||||
{% if message %}
|
||||
{% for key in message %}
|
||||
<div class="message {{key}}">
|
||||
{{message[key]}}
|
||||
</div>
|
||||
{% end %}
|
||||
{% end %}
|
||||
|
||||
</ul>
|
||||
{% if read_only or not login_available %}
|
||||
Proceed to the <a href="/">dashboard</a>.
|
||||
{% else %}
|
||||
Proceed to the <a href="/login">login page</a>.
|
||||
{% end %}
|
||||
|
||||
{% end %}
|
||||
|
||||
{% block login_widget %}
|
||||
<div/>
|
||||
|
||||
{% end %}
|
||||
|
||||
{% block script %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
IPython.login_widget = new IPython.LoginWidget('span#login_widget');
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="{{static_url("js/logoutmain.js") }}" type="text/javascript" charset="utf-8"></script>
|
||||
|
||||
{% end %}
|
||||
|
||||
@ -1,43 +1,77 @@
|
||||
{% extends layout.html %}
|
||||
{% extends page.html %}
|
||||
|
||||
{% block title %}
|
||||
IPython Dashboard
|
||||
{% end %}
|
||||
{% block title %}IPython Dashboard{% end %}
|
||||
|
||||
{% block stylesheet %}
|
||||
<link rel="stylesheet" href="{{static_url("css/projectdashboard.css") }}" type="text/css" />
|
||||
{% end %}
|
||||
|
||||
{% block meta %}
|
||||
<meta name="read_only" content="{{read_only}}"/>
|
||||
{% end %}
|
||||
|
||||
{% block params %}
|
||||
|
||||
data-project={{project}}
|
||||
data-base-project-url={{base_project_url}}
|
||||
data-base-kernel-url={{base_kernel_url}}
|
||||
data-read-only={{read_only}}
|
||||
|
||||
{% end %}
|
||||
|
||||
{% block content_panel %}
|
||||
{% if logged_in or not read_only %}
|
||||
|
||||
<div id="content_toolbar">
|
||||
<span id="drag_info">Drag files onto the list to import
|
||||
notebooks.</span>
|
||||
{% block site %}
|
||||
|
||||
<div id="main_app">
|
||||
|
||||
<div id="tabs">
|
||||
<ul>
|
||||
<li><a href="#tab1">Notebooks</a></li>
|
||||
<li><a href="#tab2">Clusters</a></li>
|
||||
</ul>
|
||||
|
||||
<span id="notebooks_buttons">
|
||||
<button id="new_notebook">New Notebook</button>
|
||||
</span>
|
||||
<div id="tab1">
|
||||
{% if logged_in or not read_only %}
|
||||
<div id="notebook_toolbar">
|
||||
<span id="drag_info">Drag files onto the list to import
|
||||
notebooks.</span>
|
||||
|
||||
<span id="notebook_buttons">
|
||||
<button id="refresh_notebook_list" title="Refresh notebook list">Refresh</button>
|
||||
<button id="new_notebook" title="Create new notebook">New Notebook</button>
|
||||
</span>
|
||||
</div>
|
||||
{% end %}
|
||||
|
||||
<div id="notebook_list">
|
||||
<div id="project_name"><h2>{{project}}</h2></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab2">
|
||||
|
||||
<div id="cluster_toolbar">
|
||||
<span id="cluster_list_info">IPython parallel computing clusters</span>
|
||||
|
||||
{% end %}
|
||||
<span id="cluster_buttons">
|
||||
<button id="refresh_cluster_list" title="Refresh cluster list">Refresh</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="cluster_list">
|
||||
<div id="cluster_header">
|
||||
<span>profile</span>
|
||||
<span>action</span>
|
||||
<span title="Enter the number of engines to start or empty for default"># of engines</span>
|
||||
<span>status</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="notebook_list">
|
||||
<div id="project_name"><h2>{{project}}</h2></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% end %}
|
||||
|
||||
{% block script %}
|
||||
<script src="{{static_url("js/notebooklist.js") }}" type="text/javascript" charset="utf-8"></script>
|
||||
<script src="{{static_url("js/clusterlist.js") }}" type="text/javascript" charset="utf-8"></script>
|
||||
<script src="{{static_url("js/projectdashboardmain.js") }}" type="text/javascript" charset="utf-8"></script>
|
||||
{% end %}
|
||||
|
||||
Loading…
Reference in new issue