From cff009e75892ebc120f200e78cdb5aaa0f33389b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E5=B9=BF?= Date: Thu, 26 Sep 2019 19:22:17 +0800 Subject: [PATCH] Support ws (#3291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Update] add ws support * [Update] 修改log使用websocket * [Update] 修复 资产用户 右侧动作菜单,字体颜色 * [Update] 修改Dockerfile * [Bugfix] 修复settings中WSG_APPLICATION --- Dockerfile | 1 + .../templates/assets/_asset_user_list.html | 18 +++-- .../templates/assets/admin_user_assets.html | 3 +- .../assets/asset_asset_user_list.html | 5 +- .../assets/templates/assets/asset_detail.html | 6 +- apps/assets/templates/assets/asset_list.html | 8 +-- .../templates/assets/system_user_assets.html | 9 +-- .../templates/assets/system_user_detail.html | 6 +- apps/common/api.py | 2 + apps/jumpserver/asgi.py | 7 ++ apps/jumpserver/conf.py | 1 + apps/jumpserver/routing.py | 13 ++++ apps/jumpserver/settings.py | 20 +++++- apps/jumpserver/urls.py | 1 + apps/jumpserver/views.py | 7 ++ apps/ops/templates/ops/adhoc_detail.html | 3 +- .../templates/ops/adhoc_history_detail.html | 4 +- apps/ops/templates/ops/celery_task_log.html | 67 ++++--------------- apps/ops/templates/ops/task_adhoc.html | 3 +- apps/ops/templates/ops/task_detail.html | 3 +- apps/ops/templates/ops/task_history.html | 3 +- apps/ops/templates/ops/task_list.html | 3 +- apps/ops/urls/ws_urls.py | 9 +++ apps/ops/ws.py | 41 ++++++++++++ apps/static/css/jumpserver.css | 4 +- apps/static/js/jumpserver.js | 4 ++ jms | 22 ++++-- requirements/requirements.txt | 3 + 28 files changed, 171 insertions(+), 105 deletions(-) create mode 100644 apps/jumpserver/asgi.py create mode 100644 apps/jumpserver/routing.py create mode 100644 apps/ops/urls/ws_urls.py create mode 100644 apps/ops/ws.py diff --git a/Dockerfile b/Dockerfile index 9f4c5c22b..303835360 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,5 @@ ENV LANG=zh_CN.UTF-8 ENV LC_ALL=zh_CN.UTF-8 EXPOSE 8080 +EXPOSE 8081 ENTRYPOINT ["./entrypoint.sh"] diff --git a/apps/assets/templates/assets/_asset_user_list.html b/apps/assets/templates/assets/_asset_user_list.html index d39c57fb3..a85a3056d 100644 --- a/apps/assets/templates/assets/_asset_user_list.html +++ b/apps/assets/templates/assets/_asset_user_list.html @@ -1,9 +1,14 @@ {% load i18n %} @@ -137,8 +142,7 @@ $(document).ready(function(){ } var success = function (data) { var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(task_id); }; requestApi({ url: the_url, @@ -149,4 +153,4 @@ $(document).ready(function(){ }) - \ No newline at end of file + diff --git a/apps/assets/templates/assets/admin_user_assets.html b/apps/assets/templates/assets/admin_user_assets.html index 9ca433930..a2a52e0e2 100644 --- a/apps/assets/templates/assets/admin_user_assets.html +++ b/apps/assets/templates/assets/admin_user_assets.html @@ -85,8 +85,7 @@ $(document).ready(function () { var the_url = "{% url 'api-assets:admin-user-connective' pk=admin_user.id %}"; var success = function (data) { var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(task_id); }; requestApi({ url: the_url, diff --git a/apps/assets/templates/assets/asset_asset_user_list.html b/apps/assets/templates/assets/asset_asset_user_list.html index bf3cba583..c9c8e9aaa 100644 --- a/apps/assets/templates/assets/asset_asset_user_list.html +++ b/apps/assets/templates/assets/asset_asset_user_list.html @@ -81,8 +81,7 @@ $(document).ready(function () { var the_url = "{% url 'api-assets:asset-user-connective' %}" + "?asset_id={{ asset.id }}"; var success = function (data) { var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(task_id); }; requestApi({ url: the_url, @@ -92,4 +91,4 @@ $(document).ready(function () { }); }) -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/apps/assets/templates/assets/asset_detail.html b/apps/assets/templates/assets/asset_detail.html index dea949bce..c086e83f1 100644 --- a/apps/assets/templates/assets/asset_detail.html +++ b/apps/assets/templates/assets/asset_detail.html @@ -276,8 +276,7 @@ function refreshAssetHardware() { var success = function(data) { console.log(data); var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600') + showCeleryTaskLog(task_id); }; requestApi({ url: the_url, @@ -355,8 +354,7 @@ $(document).ready(function () { var success = function(data) { var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600') + showCeleryTaskLog(task_id); }; requestApi({ diff --git a/apps/assets/templates/assets/asset_list.html b/apps/assets/templates/assets/asset_list.html index 3c8f93822..b9892790d 100644 --- a/apps/assets/templates/assets/asset_list.html +++ b/apps/assets/templates/assets/asset_list.html @@ -523,8 +523,7 @@ $(document).ready(function(){ function success(data) { rMenu.css({"visibility" : "hidden"}); var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600') + showCeleryTaskLog(task_id); } requestApi({ url: the_url, @@ -538,8 +537,7 @@ $(document).ready(function(){ function success(data) { rMenu.css({"visibility" : "hidden"}); var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600') + showCeleryTaskLog(task_id); } requestApi({ url: the_url, @@ -552,4 +550,4 @@ $(document).ready(function(){ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/apps/assets/templates/assets/system_user_assets.html b/apps/assets/templates/assets/system_user_assets.html index 4cad3fd4a..d4346003a 100644 --- a/apps/assets/templates/assets/system_user_assets.html +++ b/apps/assets/templates/assets/system_user_assets.html @@ -202,8 +202,7 @@ $(document).ready(function () { }; var success = function (data) { var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(task_id); }; requestApi({ url: the_url, @@ -219,8 +218,7 @@ $(document).ready(function () { the_url = the_url.replace("{{ DEFAULT_PK }}", asset_id); var success = function (data) { var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(task_id); }; var error = function (data) { alert(data) @@ -239,8 +237,7 @@ $(document).ready(function () { }; var success = function (data) { var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(task_id); }; requestApi({ url: the_url, diff --git a/apps/assets/templates/assets/system_user_detail.html b/apps/assets/templates/assets/system_user_detail.html index d9ff1a641..9c0557fde 100644 --- a/apps/assets/templates/assets/system_user_detail.html +++ b/apps/assets/templates/assets/system_user_detail.html @@ -251,8 +251,7 @@ $(document).ready(function () { var the_url = "{% url 'api-assets:system-user-push' pk=system_user.id %}"; var success = function (data) { var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(task_id); }; requestApi({ url: the_url, @@ -265,8 +264,7 @@ $(document).ready(function () { var the_url = "{% url 'api-assets:system-user-connective' pk=system_user.id %}"; var success = function (data) { var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600') + showCeleryTaskLog(task_id); }; requestApi({ url: the_url, diff --git a/apps/common/api.py b/apps/common/api.py index d69540cfd..4c5d28254 100644 --- a/apps/common/api.py +++ b/apps/common/api.py @@ -83,6 +83,8 @@ class LogTailApi(generics.RetrieveAPIView): return Response({"data": data, 'end': end, 'mark': new_mark}) + + class ResourcesIDCacheApi(APIView): def post(self, request, *args, **kwargs): spm = str(uuid.uuid4()) diff --git a/apps/jumpserver/asgi.py b/apps/jumpserver/asgi.py new file mode 100644 index 000000000..a71974685 --- /dev/null +++ b/apps/jumpserver/asgi.py @@ -0,0 +1,7 @@ +import os +import django +from channels.routing import get_default_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "jumpserver.settings") +django.setup() +application = get_default_application() diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 687a1637c..5fb1444df 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -335,6 +335,7 @@ defaults = { 'REDIS_DB_CELERY': 3, 'REDIS_DB_CACHE': 4, 'REDIS_DB_SESSION': 5, + 'REDIS_DB_WS': 6, 'CAPTCHA_TEST_MODE': None, 'TOKEN_EXPIRATION': 3600 * 24, 'DISPLAY_PER_PAGE': 25, diff --git a/apps/jumpserver/routing.py b/apps/jumpserver/routing.py new file mode 100644 index 000000000..d76f1ccee --- /dev/null +++ b/apps/jumpserver/routing.py @@ -0,0 +1,13 @@ +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter + +from ops.urls.ws_urls import urlpatterns as ops_urlpatterns + +urlpatterns = [] +urlpatterns += ops_urlpatterns + +application = ProtocolTypeRouter({ + 'websocket': AuthMiddlewareStack( + URLRouter(urlpatterns) + ), +}) diff --git a/apps/jumpserver/settings.py b/apps/jumpserver/settings.py index d726b4bcb..894e4a2c5 100644 --- a/apps/jumpserver/settings.py +++ b/apps/jumpserver/settings.py @@ -74,6 +74,7 @@ INSTALLED_APPS = [ 'rest_framework', 'rest_framework_swagger', 'drf_yasg', + 'channels', 'django_filters', 'bootstrap3', 'captcha', @@ -140,7 +141,8 @@ TEMPLATES = [ }, ] -# WSGI_APPLICATION = 'jumpserver.wsgi.applications' +WSGI_APPLICATION = 'jumpserver.wsgi.application' +ASGI_APPLICATION = 'jumpserver.routing.application' LOGIN_REDIRECT_URL = reverse_lazy('index') LOGIN_URL = reverse_lazy('authentication:login') @@ -624,3 +626,19 @@ BACKEND_ASSET_USER_AUTH_VAULT = False PERM_SINGLE_ASSET_TO_UNGROUP_NODE = CONFIG.PERM_SINGLE_ASSET_TO_UNGROUP_NODE WINDOWS_SSH_DEFAULT_SHELL = CONFIG.WINDOWS_SSH_DEFAULT_SHELL FLOWER_URL = CONFIG.FLOWER_URL + + +# Django channels support websocket +CHANNEL_REDIS = "redis://:{}@{}:{}/{}".format( + CONFIG.REDIS_PASSWORD, CONFIG.REDIS_HOST, CONFIG.REDIS_PORT, + CONFIG.REDIS_DB_WS, +) + +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': { + "hosts": [CHANNEL_REDIS], + }, + }, +} diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 80105743c..82773a51a 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -66,6 +66,7 @@ urlpatterns = [ re_path('api/(?P\w+)/(?Pv\d)/.*', views.redirect_format_api), path('api/health/', views.HealthCheckView.as_view(), name="health"), path('luna/', views.LunaView.as_view(), name='luna-view'), + re_path('ws/.*', views.WsView.as_view(), name='ws-view'), path('i18n//', views.I18NView.as_view(), name='i18n-switch'), path('settings/', include('settings.urls.view_urls', namespace='settings')), diff --git a/apps/jumpserver/views.py b/apps/jumpserver/views.py index f3365b7ff..f8c8c1f02 100644 --- a/apps/jumpserver/views.py +++ b/apps/jumpserver/views.py @@ -226,4 +226,11 @@ class HealthCheckView(APIView): return JsonResponse({"status": 1, "time": int(time.time())}) +class WsView(APIView): + ws_port = settings.CONFIG.HTTP_LISTEN_PORT + 1 + + def get(self, request): + msg = _("Websocket server run on port: {}, you should proxy it on nginx" + .format(self.ws_port)) + return JsonResponse({"msg": msg}) diff --git a/apps/ops/templates/ops/adhoc_detail.html b/apps/ops/templates/ops/adhoc_detail.html index 8b340b808..e3a13b548 100644 --- a/apps/ops/templates/ops/adhoc_detail.html +++ b/apps/ops/templates/ops/adhoc_detail.html @@ -196,8 +196,7 @@ $(document).ready(function () { alert("没有运行历史"); return } - var url = '{% url 'ops:celery-task-log' pk=DEFAULT_PK %}'.replace('{{ DEFAULT_PK }}', history_pk); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(history_pk); }) {% endblock %} diff --git a/apps/ops/templates/ops/adhoc_history_detail.html b/apps/ops/templates/ops/adhoc_history_detail.html index 5091470d5..ccc4fce4c 100644 --- a/apps/ops/templates/ops/adhoc_history_detail.html +++ b/apps/ops/templates/ops/adhoc_history_detail.html @@ -145,8 +145,8 @@ diff --git a/apps/ops/templates/ops/celery_task_log.html b/apps/ops/templates/ops/celery_task_log.html index 3cdcb008d..d7b48e267 100644 --- a/apps/ops/templates/ops/celery_task_log.html +++ b/apps/ops/templates/ops/celery_task_log.html @@ -20,50 +20,13 @@ diff --git a/apps/ops/templates/ops/task_adhoc.html b/apps/ops/templates/ops/task_adhoc.html index a31397c14..866ef4255 100644 --- a/apps/ops/templates/ops/task_adhoc.html +++ b/apps/ops/templates/ops/task_adhoc.html @@ -130,8 +130,7 @@ $(document).ready(function () { alert("没有运行历史"); return } - var url = '{% url 'ops:celery-task-log' pk=DEFAULT_PK %}'.replace('{{ DEFAULT_PK }}', history_pk); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(history_pk); }) {% endblock %} diff --git a/apps/ops/templates/ops/task_detail.html b/apps/ops/templates/ops/task_detail.html index bbeaad660..c29fc2c4e 100644 --- a/apps/ops/templates/ops/task_detail.html +++ b/apps/ops/templates/ops/task_detail.html @@ -174,8 +174,7 @@ $(document).ready(function () { alert("没有运行历史"); return } - var url = '{% url 'ops:celery-task-log' pk=DEFAULT_PK %}'.replace('{{ DEFAULT_PK }}', history_pk); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(history_pk); }) diff --git a/apps/ops/templates/ops/task_history.html b/apps/ops/templates/ops/task_history.html index d9c5a0dfa..dea4c9324 100644 --- a/apps/ops/templates/ops/task_history.html +++ b/apps/ops/templates/ops/task_history.html @@ -155,8 +155,7 @@ $(document).ready(function () { alert("没有运行历史"); return } - var url = '{% url 'ops:celery-task-log' pk=DEFAULT_PK %}'.replace('{{ DEFAULT_PK }}', history_pk); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(history_pk); }) diff --git a/apps/ops/templates/ops/task_list.html b/apps/ops/templates/ops/task_list.html index f97b83630..83da62071 100644 --- a/apps/ops/templates/ops/task_list.html +++ b/apps/ops/templates/ops/task_list.html @@ -98,8 +98,7 @@ $(document).ready(function () { }; var success = function(data) { var task_id = data.task; - var url = '{% url "ops:celery-task-log" pk=DEFAULT_PK %}'.replace("{{ DEFAULT_PK }}", task_id); - window.open(url, '', 'width=800,height=600,left=400,top=400') + showCeleryTaskLog(task_id); }; requestApi({ url: the_url, diff --git a/apps/ops/urls/ws_urls.py b/apps/ops/urls/ws_urls.py new file mode 100644 index 000000000..d3148982e --- /dev/null +++ b/apps/ops/urls/ws_urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .. import ws + +app_name = 'ops' + +urlpatterns = [ + path('ws/ops/tasks//log/', ws.CeleryLogWebsocket, name='task-log-ws'), +] diff --git a/apps/ops/ws.py b/apps/ops/ws.py new file mode 100644 index 000000000..fbc46c1db --- /dev/null +++ b/apps/ops/ws.py @@ -0,0 +1,41 @@ +import time +import threading + +from .celery.utils import get_celery_task_log_path +from channels.generic.websocket import JsonWebsocketConsumer + + +class CeleryLogWebsocket(JsonWebsocketConsumer): + task = '' + task_log_f = None + disconnected = False + + def connect(self): + task_id = self.scope['url_route']['kwargs']['task_id'] + log_path = get_celery_task_log_path(task_id) + try: + self.task_log_f = open(log_path) + except OSError: + self.send({'message': "Task {} log not found".format(task_id)}) + self.disconnect(None) + return + + self.accept() + self.send_log_to_client() + + def disconnect(self, close_code): + self.disconnected = True + if self.task_log_f and not self.task_log_f.closed: + self.task_log_f.close() + self.close() + + def send_log_to_client(self): + def func(): + while not self.disconnected: + data = self.task_log_f.read(4096) + if data: + data = data.replace('\n', '\r\n') + self.send_json({'message': data}) + time.sleep(0.2) + thread = threading.Thread(target=func) + thread.start() diff --git a/apps/static/css/jumpserver.css b/apps/static/css/jumpserver.css index 489279189..02e738d24 100644 --- a/apps/static/css/jumpserver.css +++ b/apps/static/css/jumpserver.css @@ -88,7 +88,7 @@ table.dataTable tbody td.selected a, table.dataTable tbody tr.selected td i.text-navy, table.dataTable tbody th.selected td i.text-navy, table.dataTable tbody td.selected td i.text-navy { - color: white !important; + color: white; } .m-0 { @@ -473,4 +473,4 @@ span.select2-selection__placeholder { .p-r-5 { padding-right: 5px; -} \ No newline at end of file +} diff --git a/apps/static/js/jumpserver.js b/apps/static/js/jumpserver.js index 4ec64fbbb..f7f7b655c 100644 --- a/apps/static/js/jumpserver.js +++ b/apps/static/js/jumpserver.js @@ -1201,3 +1201,7 @@ function nodesSelect2Init(selector, url) { }) } +function showCeleryTaskLog(taskId) { + var url = '/ops/celery/task/taskId/log/'.replace('taskId', taskId); + window.open(url, '', 'width=900,height=600') +} diff --git a/jms b/jms index d67c2fa35..a6eaddeb5 100755 --- a/jms +++ b/jms @@ -47,6 +47,7 @@ LOG_DIR = os.path.join(BASE_DIR, 'logs') TMP_DIR = os.path.join(BASE_DIR, 'tmp') HTTP_HOST = CONFIG.HTTP_BIND_HOST or '127.0.0.1' HTTP_PORT = CONFIG.HTTP_LISTEN_PORT or 8080 +WS_PORT = HTTP_PORT + 1 DEBUG = CONFIG.DEBUG or False LOG_LEVEL = CONFIG.LOG_LEVEL or 'INFO' @@ -201,12 +202,15 @@ def is_running(s, unlink=True): def parse_service(s): all_services = [ - 'gunicorn', 'celery_ansible', 'celery_default', 'beat', 'flower' + 'gunicorn', 'celery_ansible', 'celery_default', + 'beat', 'flower', 'daphne', ] if s == 'all': return all_services elif s == "web": - return ['gunicorn', 'flower'] + return ['gunicorn', 'flower', 'daphne'] + elif s == 'ws': + return ['daphne'] elif s == "task": return ["celery_ansible", "celery_default", "beat"] elif s == 'gunicorn': @@ -225,10 +229,8 @@ def parse_service(s): def get_start_gunicorn_kwargs(): print("\n- Start Gunicorn WSGI HTTP Server") prepare() - service = 'gunicorn' bind = '{}:{}'.format(HTTP_HOST, HTTP_PORT) log_format = '%(h)s %(t)s "%(r)s" %(s)s %(b)s ' - pid_file = get_pid_file_path(service) cmd = [ 'gunicorn', 'jumpserver.wsgi', @@ -238,7 +240,6 @@ def get_start_gunicorn_kwargs(): '-w', str(WORKERS), '--max-requests', '4096', '--access-logformat', log_format, - '-p', pid_file, '--access-logfile', '-' ] @@ -247,6 +248,16 @@ def get_start_gunicorn_kwargs(): return {'cmd': cmd, 'cwd': APPS_DIR} +def get_start_daphne_kwargs(): + print("\n- Start Daphne ASGI WS Server") + cmd = [ + 'daphne', 'jumpserver.asgi:application', + '-b', HTTP_HOST, + '-p', str(WS_PORT), + ] + return {'cmd': cmd, 'cwd': APPS_DIR} + + def get_start_celery_ansible_kwargs(): print("\n- Start Celery as Distributed Task Queue: Ansible") return get_start_worker_kwargs('ansible', 4) @@ -362,6 +373,7 @@ def start_service(s): "celery_default": get_start_celery_default_kwargs, "beat": get_start_beat_kwargs, "flower": get_start_flower_kwargs, + "daphne": get_start_daphne_kwargs, } kwargs = services_kwargs.get(s)() diff --git a/requirements/requirements.txt b/requirements/requirements.txt index ad0dd119b..030e8dd16 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -86,3 +86,6 @@ httpsig==1.3.0 treelib==1.5.3 django-proxy==1.2.1 flower==0.9.3 +channels-redis==2.4.0 +channels==2.3.0 +daphne==2.3.0 -- GitLab