diff --git a/README.rst b/README.rst index 9835a03..b59c701 100644 --- a/README.rst +++ b/README.rst @@ -203,6 +203,26 @@ Revert all migrations: :: python manage_collector.py --mode test db downgrade -d collector/api/db/migrations/ +Switching off Elasticsearch +--------------------------- + +Elasticsearch was chosen as data storage for the dynamically generated +statistics reports, but now only CSV reports are used for analytical purposes. +Thus, Elasticsearch is an unnecessary complication of the infrastructure and +data flow. + +Without Elasticsearch, we are using memcached as cache for the web UI. Data +expiration is configured by the parameter MEMCACHED_JSON_REPORTS_EXPIRATION +for fuel_analytics. + +Changes in the Nginx config: :: + + # Add this to the block 'server' + location /api/ { + proxy_pass http://IP_OF_ANALYTICS_SERVICE:PORT_OF_ANALYTICS_SERVICE/api/; + } + + .. _Fuel: https://docs.mirantis.com/openstack/fuel/ .. _Elasticsearch: https://www.elastic.co/ .. _uWSGI: https://pypi.python.org/pypi/uWSGI/ diff --git a/analytics/fuel_analytics/api/app.py b/analytics/fuel_analytics/api/app.py index 31ff5ef..fd55c4e 100644 --- a/analytics/fuel_analytics/api/app.py +++ b/analytics/fuel_analytics/api/app.py @@ -26,9 +26,11 @@ db = flask_sqlalchemy.SQLAlchemy(app) # Registering blueprints from fuel_analytics.api.resources.csv_exporter import bp as csv_exporter_bp from fuel_analytics.api.resources.json_exporter import bp as json_exporter_bp +from fuel_analytics.api.resources.json_reports import bp as json_reports_bp app.register_blueprint(csv_exporter_bp, url_prefix='/api/v1/csv') app.register_blueprint(json_exporter_bp, url_prefix='/api/v1/json') +app.register_blueprint(json_reports_bp, url_prefix='/api/v1/json/report') @app.errorhandler(DateExtractionError) diff --git a/analytics/fuel_analytics/api/config.py b/analytics/fuel_analytics/api/config.py index 76d8ffb..a40baeb 100644 --- a/analytics/fuel_analytics/api/config.py +++ b/analytics/fuel_analytics/api/config.py @@ -32,7 +32,10 @@ class Production(object): CSV_DEFAULT_FROM_DATE_DAYS = 90 CSV_DB_YIELD_PER = 100 JSON_DB_DEFAULT_LIMIT = 1000 + JSON_DB_YIELD_PER = 100 CSV_DEFAULT_LIST_ITEMS_NUM = 5 + MEMCACHED_HOSTS = ['localhost:11211'] + MEMCACHED_JSON_REPORTS_EXPIRATION = 3600 class Testing(Production): diff --git a/analytics/fuel_analytics/api/db/model.py b/analytics/fuel_analytics/api/db/model.py index 26f5305..0ba0dba 100644 --- a/analytics/fuel_analytics/api/db/model.py +++ b/analytics/fuel_analytics/api/db/model.py @@ -39,6 +39,7 @@ class InstallationStructure(db.Model): creation_date = db.Column(db.DateTime) modification_date = db.Column(db.DateTime) is_filtered = db.Column(db.Boolean) + release = db.Column(db.Text) class ActionLog(db.Model): diff --git a/analytics/fuel_analytics/api/resources/json_reports.py b/analytics/fuel_analytics/api/resources/json_reports.py new file mode 100644 index 0000000..cf6cc64 --- /dev/null +++ b/analytics/fuel_analytics/api/resources/json_reports.py @@ -0,0 +1,151 @@ +# Copyright 2015 Mirantis, 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. + +from collections import defaultdict +import copy +import json + +from flask import Blueprint +from flask import request +from flask import Response +import memcache + +from fuel_analytics.api.app import app +from fuel_analytics.api.app import db +from fuel_analytics.api.db.model import InstallationStructure as IS + +bp = Blueprint('reports', __name__) + + +@bp.route('/installations', methods=['GET']) +def get_installations_info(): + release = request.args.get('release') + refresh = request.args.get('refresh') + cache_key_prefix = 'fuel-stats-installations-info' + mc = memcache.Client(app.config.get('MEMCACHED_HOSTS')) + app.logger.debug("Fetching installations info for release: %s", release) + + # Checking cache + if not refresh: + cache_key = '{0}{1}'.format(cache_key_prefix, release) + app.logger.debug("Checking installations info by key: %s in cache", + cache_key) + cached_result = mc.get(cache_key) + if cached_result: + app.logger.debug("Installations info cache found by key: %s", + cache_key) + return Response(cached_result, mimetype='application/json') + else: + app.logger.debug("No cached installations info for key: %s", + cache_key) + else: + app.logger.debug("Enforce refresh cache of installations info " + "for release: %s", release) + + # Fetching data from DB + info_from_db = get_installations_info_from_db(release) + + # Saving fetched data to cache + for for_release, info in info_from_db.items(): + cache_key = '{0}{1}'.format(cache_key_prefix, for_release) + app.logger.debug("Caching installations info for key: %s, data: %s", + cache_key, info) + mc.set(cache_key, json.dumps(info), + app.config.get('MEMCACHED_JSON_REPORTS_EXPIRATION')) + + return Response(json.dumps(info_from_db[release]), + mimetype='application/json') + + +def get_installations_info_from_db(release): + query = db.session.query(IS.structure, IS.release).\ + filter(IS.is_filtered == bool(0)) + if release: + query = query.filter(IS.release == release) + + info_template = { + 'installations': { + 'count': 0, + 'environments_num': defaultdict(int) + }, + 'environments': { + 'count': 0, + 'operable_envs_count': 0, + 'statuses': defaultdict(int), + 'nodes_num': defaultdict(int), + 'hypervisors_num': defaultdict(int), + 'oses_num': defaultdict(int) + } + } + + info = defaultdict(lambda: copy.deepcopy(info_template)) + + app.logger.debug("Fetching installations info from DB for release: %s", + release) + + yield_per = app.config['JSON_DB_YIELD_PER'] + for row in query.yield_per(yield_per): + structure = row[0] + extract_installation_info(structure, info[release]) + + cur_release = row[1] + # Splitting info by release if fetching for all releases + if not release and cur_release != release: + extract_installation_info(structure, info[cur_release]) + + app.logger.debug("Fetched installations info from DB for release: " + "%s, info: %s", release, info) + + return info + + +def extract_installation_info(source, result): + """Extracts installation info from structure + + :param source: source of installation info data + :type source: dict + :param result: placeholder for extracted data + :type result: dict + """ + + inst_info = result['installations'] + env_info = result['environments'] + + production_statuses = ('operational', 'error') + + inst_info['count'] += 1 + envs_num = 0 + + for cluster in source.get('clusters', []): + envs_num += 1 + env_info['count'] += 1 + + if cluster.get('status') in production_statuses: + current_nodes_num = cluster.get('nodes_num', 0) + env_info['nodes_num'][current_nodes_num] += 1 + env_info['operable_envs_count'] += 1 + + hypervisor = cluster.get('attributes', {}).get('libvirt_type') + if hypervisor: + env_info['hypervisors_num'][hypervisor.lower()] += 1 + + os = cluster.get('release', {}).get('os') + if os: + env_info['oses_num'][os.lower()] += 1 + + status = cluster.get('status') + if status is not None: + env_info['statuses'][status] += 1 + + inst_info['environments_num'][envs_num] += 1 diff --git a/analytics/fuel_analytics/test/api/resources/utils/test_json_reports.py b/analytics/fuel_analytics/test/api/resources/utils/test_json_reports.py new file mode 100644 index 0000000..a3d741d --- /dev/null +++ b/analytics/fuel_analytics/test/api/resources/utils/test_json_reports.py @@ -0,0 +1,527 @@ +# Copyright 2015 Mirantis, 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. + +import json +import memcache +import mock + +from fuel_analytics.test.base import DbTest + +from fuel_analytics.api.app import app +from fuel_analytics.api.app import db +from fuel_analytics.api.db import model + + +class JsonReportsTest(DbTest): + + @mock.patch.object(memcache.Client, 'get', return_value=None) + def test_get_installations_num(self, _): + structures = [ + model.InstallationStructure( + master_node_uid='x0', + structure={}, + is_filtered=False, + release='9.0' + ), + model.InstallationStructure( + master_node_uid='x1', + structure={}, + is_filtered=False, + release='8.0' + ), + model.InstallationStructure( + master_node_uid='x2', + structure={}, + is_filtered=True, + release='8.0' + ), + ] + for structure in structures: + db.session.add(structure) + db.session.flush() + + with app.test_request_context(): + url = '/api/v1/json/report/installations' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual(2, resp['installations']['count']) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=8.0' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual(1, resp['installations']['count']) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=xxx' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual(0, resp['installations']['count']) + + @mock.patch.object(memcache.Client, 'get', return_value=None) + def test_get_env_statuses(self, _): + structures = [ + model.InstallationStructure( + master_node_uid='x0', + structure={ + 'clusters': [ + {'status': 'new'}, + {'status': 'operational'}, + {'status': 'error'} + ] + }, + is_filtered=False, + release='9.0' + ), + model.InstallationStructure( + master_node_uid='x1', + structure={ + 'clusters': [ + {'status': 'deployment'}, + {'status': 'operational'}, + {'status': 'operational'}, + ] + }, + is_filtered=False, + release='8.0' + ), + model.InstallationStructure( + master_node_uid='x2', + structure={ + 'clusters': [ + {'status': 'deployment'}, + {'status': 'operational'}, + ] + }, + is_filtered=True, + release='8.0' + ), + ] + for structure in structures: + db.session.add(structure) + db.session.flush() + + with app.test_request_context(): + url = '/api/v1/json/report/installations' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual( + {'new': 1, 'deployment': 1, 'error': 1, 'operational': 3}, + resp['environments']['statuses'] + ) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=8.0' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual( + {'deployment': 1, 'operational': 2}, + resp['environments']['statuses'] + ) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=xxx' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual({}, resp['environments']['statuses']) + + @mock.patch.object(memcache.Client, 'get', return_value=None) + def test_get_env_num(self, _): + structures = [ + model.InstallationStructure( + master_node_uid='x0', + structure={'clusters': [{}, {}, {}]}, + is_filtered=False, + release='9.0' + ), + model.InstallationStructure( + master_node_uid='x1', + structure={'clusters': [{}, {}]}, + is_filtered=False, + release='8.0' + ), + model.InstallationStructure( + master_node_uid='x2', + structure={'clusters': []}, + is_filtered=False, + release='8.0' + ), + model.InstallationStructure( + master_node_uid='x3', + structure={'clusters': []}, + is_filtered=False, + release='8.0' + ), + model.InstallationStructure( + master_node_uid='x4', + structure={'clusters': [{}, {}, {}]}, + is_filtered=True, + release='8.0' + ), + ] + for structure in structures: + db.session.add(structure) + db.session.flush() + + with app.test_request_context(): + url = '/api/v1/json/report/installations' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual(5, resp['environments']['count']) + self.assertEqual( + {'0': 2, '2': 1, '3': 1}, + resp['installations']['environments_num'] + ) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=8.0' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual(2, resp['environments']['count']) + self.assertEqual( + {'0': 2, '2': 1}, + resp['installations']['environments_num'] + ) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=xxx' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual(0, resp['environments']['count']) + self.assertEqual({}, resp['installations']['environments_num']) + + @mock.patch.object(memcache.Client, 'set') + @mock.patch.object(memcache.Client, 'get', return_value=None) + def test_caching(self, cached_mc_get, cached_mc_set): + structures = [ + model.InstallationStructure( + master_node_uid='x0', + structure={'clusters': [{}, {}, {}]}, + is_filtered=False, + release='9.0' + ), + model.InstallationStructure( + master_node_uid='x1', + structure={'clusters': [{}, {}]}, + is_filtered=False, + release='8.0' + ), + model.InstallationStructure( + master_node_uid='x2', + structure={'clusters': [{}]}, + is_filtered=True, + release='8.0' + ) + ] + for structure in structures: + db.session.add(structure) + db.session.flush() + + with app.test_request_context(): + url = '/api/v1/json/report/installations' + resp = self.client.get(url) + self.check_response_ok(resp) + self.assertEqual(1, cached_mc_get.call_count) + + # Checking that mc.set was called for each release and + # for all releases summary info + + # Mock call args have structure (args, kwargs) + expected_cache_keys = ['fuel-stats-installations-info8.0', + 'fuel-stats-installations-info9.0', + 'fuel-stats-installations-infoNone'] + + actual_cache_keys = [call_args[0][0] for call_args in + cached_mc_set.call_args_list] + self.assertItemsEqual(expected_cache_keys, actual_cache_keys) + + # cached_mc_set.assert_has_calls(calls, any_order=True) + self.assertEqual(len(expected_cache_keys), + cached_mc_set.call_count) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=8.0' + resp = self.client.get(url) + self.check_response_ok(resp) + self.assertEqual(2, cached_mc_get.call_count) + self.assertEqual(4, cached_mc_set.call_count) + expected_cache_key = 'fuel-stats-installations-info8.0' + actual_cache_key = cached_mc_set.call_args[0][0] + self.assertEqual(expected_cache_key, actual_cache_key) + + @mock.patch.object(memcache.Client, 'set') + @mock.patch.object(memcache.Client, 'get') + def test_refresh_cached_data(self, cached_mc_get, cached_mc_set): + structures = [ + model.InstallationStructure( + master_node_uid='x0', + structure={'clusters': [{}, {}, {}]}, + is_filtered=False, + release='9.0' + ), + model.InstallationStructure( + master_node_uid='x1', + structure={'clusters': [{}, {}]}, + is_filtered=False, + release='8.0' + ), + model.InstallationStructure( + master_node_uid='x2', + structure={'clusters': [{}]}, + is_filtered=True, + release='8.0' + ) + ] + for structure in structures: + db.session.add(structure) + db.session.flush() + + with app.test_request_context(): + url = '/api/v1/json/report/installations?refresh=1' + resp = self.client.get(url) + self.check_response_ok(resp) + self.assertEqual(0, cached_mc_get.call_count) + self.assertEquals(3, cached_mc_set.call_count) + + @mock.patch.object(memcache.Client, 'get', return_value=None) + def test_get_nodes_num(self, _): + structures = [ + model.InstallationStructure( + master_node_uid='x0', + structure={ + 'clusters': [ + {'status': 'operational', 'nodes_num': 3}, + {'status': 'new', 'nodes_num': 2}, + {'status': 'error', 'nodes_num': 1}, + ] + }, + is_filtered=False, + release='9.0' + ), + model.InstallationStructure( + master_node_uid='x1', + structure={ + 'clusters': [ + {'status': 'operational', 'nodes_num': 3} + ], + }, + is_filtered=False, + release='8.0' + ), + model.InstallationStructure( + master_node_uid='x2', + structure={ + 'clusters': [ + {'status': 'operational', 'nodes_num': 5}, + {'status': 'new', 'nodes_num': 6}, + {'status': 'error', 'nodes_num': 7}, + ] + }, + is_filtered=True, + release='8.0' + ), + ] + for structure in structures: + db.session.add(structure) + db.session.flush() + + with app.test_request_context(): + url = '/api/v1/json/report/installations' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual(3, resp['environments']['operable_envs_count']) + self.assertEqual( + {'3': 2, '1': 1}, + resp['environments']['nodes_num'] + ) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=9.0' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual(2, resp['environments']['operable_envs_count']) + self.assertEqual( + {'3': 1, '1': 1}, + resp['environments']['nodes_num'] + ) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=xxx' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual(0, resp['environments']['operable_envs_count']) + self.assertEqual({}, resp['environments']['nodes_num']) + + @mock.patch.object(memcache.Client, 'get', return_value=None) + def test_get_hypervisors_num(self, _): + structures = [ + model.InstallationStructure( + master_node_uid='x0', + structure={ + 'clusters': [ + {'status': 'operational', 'attributes': + {'libvirt_type': 'kvm'}}, + {'status': 'operational', 'attributes': + {'libvirt_type': 'Qemu'}}, + {'status': 'operational', 'attributes': + {'libvirt_type': 'Kvm'}}, + {'status': 'new', 'attributes': + {'libvirt_type': 'kvm'}}, + {'status': 'error', 'attributes': + {'libvirt_type': 'qemu'}}, + ] + }, + is_filtered=False, + release='9.0' + ), + model.InstallationStructure( + master_node_uid='x1', + structure={ + 'clusters': [ + {'status': 'new', 'attributes': + {'libvirt_type': 'qemu'}}, + {'status': 'error', 'attributes': + {'libvirt_type': 'Kvm'}}, + {'status': 'error', 'attributes': + {'libvirt_type': 'vcenter'}}, + ], + }, + is_filtered=False, + release='8.0' + ), + model.InstallationStructure( + master_node_uid='x2', + structure={ + 'clusters': [ + {'status': 'operational', 'attributes': + {'libvirt_type': 'kvm'}}, + {'status': 'new', 'attributes': + {'libvirt_type': 'kvm'}}, + {'status': 'error', 'attributes': + {'libvirt_type': 'qemu'}}, + ] + }, + is_filtered=True, + release='8.0' + ), + ] + for structure in structures: + db.session.add(structure) + db.session.flush() + + with app.test_request_context(): + url = '/api/v1/json/report/installations' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual( + {'kvm': 3, 'vcenter': 1, 'qemu': 2}, + resp['environments']['hypervisors_num'] + ) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=8.0' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual( + {'kvm': 1, 'vcenter': 1}, + resp['environments']['hypervisors_num'] + ) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=xxx' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual({}, resp['environments']['hypervisors_num']) + + @mock.patch.object(memcache.Client, 'get', return_value=None) + def test_get_oses_num(self, _): + structures = [ + model.InstallationStructure( + master_node_uid='x0', + structure={ + 'clusters': [ + {'status': 'operational', 'release': {'os': 'Ubuntu'}}, + {'status': 'error', 'release': {'os': 'ubuntu'}}, + {'status': 'error', 'release': {'os': 'Centos'}} + ] + }, + is_filtered=False, + release='9.0' + ), + model.InstallationStructure( + master_node_uid='x1', + structure={ + 'clusters': [ + {'status': 'new', 'release': {'os': 'Ubuntu'}}, + {'status': 'operational', 'release': {'os': 'ubuntu'}} + ], + }, + is_filtered=False, + release='8.0' + ), + model.InstallationStructure( + master_node_uid='x2', + structure={ + 'clusters': [ + {'status': 'new', 'release': {'os': 'centos'}}, + {'status': 'operational', 'release': {'os': 'centos'}}, + {'status': 'operational', 'release': {'os': 'centos'}} + ] + }, + is_filtered=True, + release='8.0' + ), + ] + for structure in structures: + db.session.add(structure) + db.session.flush() + + with app.test_request_context(): + url = '/api/v1/json/report/installations' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual( + {'ubuntu': 3, 'centos': 1}, + resp['environments']['oses_num'] + ) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=8.0' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual({'ubuntu': 1}, resp['environments']['oses_num']) + + with app.test_request_context(): + url = '/api/v1/json/report/installations?release=xxx' + resp = self.client.get(url) + self.check_response_ok(resp) + resp = json.loads(resp.data) + self.assertEqual({}, resp['environments']['oses_num']) diff --git a/analytics/requirements.txt b/analytics/requirements.txt index a4d25c1..e09a9cc 100644 --- a/analytics/requirements.txt +++ b/analytics/requirements.txt @@ -2,5 +2,6 @@ psycopg2==2.5.4 Flask==0.10.1 Flask-Script==2.0.5 Flask-SQLAlchemy==2.0 +python-memcached>=1.56 SQLAlchemy==0.9.8 six>=1.8.0 diff --git a/analytics/static/bower.json b/analytics/static/bower.json index 462c5e7..74c8eed 100644 --- a/analytics/static/bower.json +++ b/analytics/static/bower.json @@ -8,7 +8,6 @@ "d3-tip": "0.6.3", "d3pie": "0.1.4", "nvd3": "1.1.15-beta", - "elasticsearch": "3.0.0", "requirejs": "2.1.15" }, "overrides": { diff --git a/analytics/static/css/fuel-stat.css b/analytics/static/css/fuel-stat.css index 1138884..a5a9c55 100644 --- a/analytics/static/css/fuel-stat.css +++ b/analytics/static/css/fuel-stat.css @@ -41,6 +41,48 @@ html, body { background-color: #f3f3f4; height: 100%; } + +#loader { + background-color: #415766; + position: absolute; + top: 0; + width: 100%; + height: 100%; +} +#load-error { + margin-left: 82px; + padding-top: 20px; + font-size: 20px; + color: #F1594C; +} +@keyframes loading { + from {transform:rotate(0deg);} + to {transform:rotate(360deg);} +} +.loading:before { + content: ""; + display: block; + width: 98px; + height: 98px; + overflow: hidden; + margin: 200px auto 0; + background: url(../img/loader-bg.svg); + animation: loading 6s linear infinite; +} +.loading:after { + content: ""; + display: block; + width: 98px; + height: 98px; + margin: -124px auto 0; + position: relative; + z-index: 9999; + background: url(../img/loader-logo.svg); +} + +.hidden { + display: none; +} .nav-pannel { width: 70px; height: 100%; diff --git a/analytics/static/img/loader-bg.svg b/analytics/static/img/loader-bg.svg new file mode 100644 index 0000000..a858ac5 --- /dev/null +++ b/analytics/static/img/loader-bg.svg @@ -0,0 +1,26 @@ + + + + + + diff --git a/analytics/static/img/loader-logo.svg b/analytics/static/img/loader-logo.svg new file mode 100644 index 0000000..ae98d6b --- /dev/null +++ b/analytics/static/img/loader-logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/analytics/static/index.html b/analytics/static/index.html index 138e823..3cab382 100644 --- a/analytics/static/index.html +++ b/analytics/static/index.html @@ -36,120 +36,129 @@ +
+
+
+ +
- -
-
-
-
-

-

- Number of installations: +