mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Compare commits
401 Commits
v0.4.0+b60
...
v0.7.0-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d30c8b87 | ||
|
|
076710f0c6 | ||
|
|
a9172dac00 | ||
|
|
accca51f39 | ||
|
|
5f5774d01b | ||
|
|
00e99d858c | ||
|
|
da56dc883f | ||
|
|
02582cab65 | ||
|
|
bff4d31ada | ||
|
|
83554207e1 | ||
|
|
1c0c3e0b93 | ||
|
|
5feb563dc9 | ||
|
|
07b88d0b53 | ||
|
|
21f33462d5 | ||
|
|
6a9d95f1ac | ||
|
|
36b80fc4ef | ||
|
|
d89dd2c9af | ||
|
|
658af526c7 | ||
|
|
3d859ec5f3 | ||
|
|
fdff799d23 | ||
|
|
5fc0b88b23 | ||
|
|
63de247478 | ||
|
|
5d3caac1b5 | ||
|
|
e4b9d23dfe | ||
|
|
890f59a4c9 | ||
|
|
d4a18ba611 | ||
|
|
c4502b2925 | ||
|
|
1d5efdd93f | ||
|
|
2b95da102e | ||
|
|
d512cd0c1d | ||
|
|
3dc9c84a98 | ||
|
|
4a33b987b8 | ||
|
|
f7041977d5 | ||
|
|
83bc38579e | ||
|
|
4b8a94e795 | ||
|
|
406010a7a6 | ||
|
|
4f11f28efa | ||
|
|
c919602b20 | ||
|
|
7702b05635 | ||
|
|
5fc7c499a3 | ||
|
|
628240906e | ||
|
|
41b9b21a20 | ||
|
|
dbd3f754ba | ||
|
|
4ef3c27fe6 | ||
|
|
58a005c71b | ||
|
|
9d7ff31178 | ||
|
|
93d6b01fbf | ||
|
|
7d57f9d0f1 | ||
|
|
e80f470255 | ||
|
|
5636cec0eb | ||
|
|
912bbc1a4a | ||
|
|
d3bb58167e | ||
|
|
2911fa8af7 | ||
|
|
4503c6af66 | ||
|
|
7fc2d5ee0b | ||
|
|
3c9c1466a3 | ||
|
|
4a7c066bf0 | ||
|
|
b850da52a2 | ||
|
|
1a3657572e | ||
|
|
666e3281e4 | ||
|
|
66084b1a3b | ||
|
|
421470666a | ||
|
|
f8e2bc9eca | ||
|
|
079fbf33f4 | ||
|
|
c195362710 | ||
|
|
b671dd0431 | ||
|
|
7793f3b257 | ||
|
|
e09aa6f81a | ||
|
|
780e0c0418 | ||
|
|
43edb009d6 | ||
|
|
81978c5049 | ||
|
|
239813e195 | ||
|
|
28dd571a03 | ||
|
|
808126cf91 | ||
|
|
69a8295f4c | ||
|
|
a692e3f664 | ||
|
|
6860dde1f7 | ||
|
|
e183affdd0 | ||
|
|
6338be3811 | ||
|
|
3ee6371250 | ||
|
|
4f38d42182 | ||
|
|
39db74ff20 | ||
|
|
05c2c21a85 | ||
|
|
00edc29e50 | ||
|
|
3771af0a8c | ||
|
|
c32c2d43f7 | ||
|
|
4e2e3f9077 | ||
|
|
2a27422df9 | ||
|
|
f9e0ce8e9c | ||
|
|
a1d49f13d3 | ||
|
|
26aa199f9c | ||
|
|
4c77f3f914 | ||
|
|
d6be792595 | ||
|
|
59c1ea7f16 | ||
|
|
4d24005eff | ||
|
|
2dab35b614 | ||
|
|
0b61b88f5f | ||
|
|
e5cb58207c | ||
|
|
fc17d1af81 | ||
|
|
e6650e1e2d | ||
|
|
3aa1cd0133 | ||
|
|
e04833c327 | ||
|
|
b743cceb60 | ||
|
|
a0e134d3b5 | ||
|
|
d7fb2d7458 | ||
|
|
b913ce6022 | ||
|
|
1eb7945d16 | ||
|
|
37d0026ee4 | ||
|
|
9cdc2cb2f7 | ||
|
|
a9bff9063e | ||
|
|
380126ee44 | ||
|
|
d8377375b8 | ||
|
|
98ff701f9a | ||
|
|
f5ea3e97d3 | ||
|
|
719e96dd2f | ||
|
|
6c6c0256ba | ||
|
|
723df51cdd | ||
|
|
a0f4e263b2 | ||
|
|
4706bf8060 | ||
|
|
f96a9f659a | ||
|
|
63c273f896 | ||
|
|
622ac6d781 | ||
|
|
8dc564a8bc | ||
|
|
3ae5baef22 | ||
|
|
8d819068b5 | ||
|
|
585e056265 | ||
|
|
1914ed7c7c | ||
|
|
bd216e93e7 | ||
|
|
5e351de896 | ||
|
|
de0e534c77 | ||
|
|
5fa1f9440d | ||
|
|
b3ddc5f8b9 | ||
|
|
8cde5f9673 | ||
|
|
1bb53ca497 | ||
|
|
0a3cd9267f | ||
|
|
075d843354 | ||
|
|
b14e5e8c0e | ||
|
|
c9da4be422 | ||
|
|
276ee7c27a | ||
|
|
334040532a | ||
|
|
335a3a98b5 | ||
|
|
b17080a7f5 | ||
|
|
8441c12b01 | ||
|
|
3b4af1b6fa | ||
|
|
c3deb8e2fa | ||
|
|
a60b1686da | ||
|
|
b56e87ceb2 | ||
|
|
fc89bcdaf3 | ||
|
|
15ec8321bb | ||
|
|
e6ba62485c | ||
|
|
9077b01fb9 | ||
|
|
f45281be96 | ||
|
|
a1c8ef9037 | ||
|
|
f46e8af23f | ||
|
|
30a89bfd2c | ||
|
|
6312f8738d | ||
|
|
9e3d5c10c5 | ||
|
|
59b87ec4fd | ||
|
|
27ecf5f25c | ||
|
|
105971c4c8 | ||
|
|
690f8323c3 | ||
|
|
20eb110ce3 | ||
|
|
571c9d0aee | ||
|
|
0ee7292f16 | ||
|
|
8c28392dfd | ||
|
|
671f1f4478 | ||
|
|
557d3748be | ||
|
|
f00d080ed2 | ||
|
|
4e76c1305f | ||
|
|
36ef388e92 | ||
|
|
2e1ee7f76c | ||
|
|
fc1e38772d | ||
|
|
0e631a5121 | ||
|
|
d74175efca | ||
|
|
bf5fe7d2c7 | ||
|
|
0f022aba92 | ||
|
|
0b6e55e55a | ||
|
|
e1c409366c | ||
|
|
3b942118e9 | ||
|
|
7f1543db8f | ||
|
|
74a5121be2 | ||
|
|
26fe136a1a | ||
|
|
83fb189b05 | ||
|
|
5e8d0d36c0 | ||
|
|
4ae4cffa04 | ||
|
|
bc433e88fe | ||
|
|
513ef501a4 | ||
|
|
f2bdcbedfb | ||
|
|
fd056edb2a | ||
|
|
0f0acfdd12 | ||
|
|
1e3b507b2b | ||
|
|
84d95272f3 | ||
|
|
3b08e9e214 | ||
|
|
f4be83b06f | ||
|
|
4918d0430c | ||
|
|
e25b86b10d | ||
|
|
d3d305a843 | ||
|
|
825b93bfe9 | ||
|
|
8c98282200 | ||
|
|
768ac9eb04 | ||
|
|
71011d2fca | ||
|
|
9683a8ed82 | ||
|
|
10a6ac9313 | ||
|
|
dba325e9a2 | ||
|
|
fcd9ab533c | ||
|
|
68e3e8e1c5 | ||
|
|
7f8b738b9e | ||
|
|
8a35dcedfa | ||
|
|
ef763b7157 | ||
|
|
498e1d4474 | ||
|
|
73de936c75 | ||
|
|
e32b709a41 | ||
|
|
60652f63c4 | ||
|
|
d0d4101f90 | ||
|
|
646875794f | ||
|
|
cdad4be0d5 | ||
|
|
8f4285be62 | ||
|
|
acfa55e2d0 | ||
|
|
0b7cd07db0 | ||
|
|
6297ffd523 | ||
|
|
368f4fdbef | ||
|
|
f52044a209 | ||
|
|
9fb33cf746 | ||
|
|
e3c5da5bc5 | ||
|
|
e675690cc6 | ||
|
|
edc1622cf5 | ||
|
|
5ab3d4a40d | ||
|
|
cb29d87b63 | ||
|
|
6ff6bdad9f | ||
|
|
e3cc3ef9a4 | ||
|
|
1fe4f291f2 | ||
|
|
a54119f4a2 | ||
|
|
c5b7fe5321 | ||
|
|
d487ec9153 | ||
|
|
fa19b1ddc8 | ||
|
|
267c32b390 | ||
|
|
aeff3f1494 | ||
|
|
e80e52f6c9 | ||
|
|
fe41a70602 | ||
|
|
976d9abe2d | ||
|
|
041bc1100a | ||
|
|
5d095ff6ab | ||
|
|
ef01b61b29 | ||
|
|
faad6b656b | ||
|
|
0bc775584b | ||
|
|
f2d96d61a1 | ||
|
|
09bf2dd608 | ||
|
|
ad1b9b06cf | ||
|
|
a4bceae60b | ||
|
|
9385449feb | ||
|
|
562e1bb8c9 | ||
|
|
082b718303 | ||
|
|
c0872899e9 | ||
|
|
086bbf129d | ||
|
|
4b7561e538 | ||
|
|
407c5a839b | ||
|
|
b8aefd26b8 | ||
|
|
85a762bcd2 | ||
|
|
4f1b3d5beb | ||
|
|
9218a7c437 | ||
|
|
71a3f066a5 | ||
|
|
89436d779c | ||
|
|
3631e938da | ||
|
|
c0a9db68f0 | ||
|
|
bec9c9e14e | ||
|
|
47bbc25277 | ||
|
|
f02c2588d2 | ||
|
|
7db5449dad | ||
|
|
7f6c7f0634 | ||
|
|
73955c74f7 | ||
|
|
7de85da8ef | ||
|
|
0aab35252a | ||
|
|
141dbc9e70 | ||
|
|
2e513c347c | ||
|
|
335c136ec2 | ||
|
|
df1170eb9b | ||
|
|
69bcaddbe0 | ||
|
|
67958cc27b | ||
|
|
6c716f23d9 | ||
|
|
bea11b0ac2 | ||
|
|
4927386299 | ||
|
|
30a8550f6b | ||
|
|
0389a45be4 | ||
|
|
707c169867 | ||
|
|
fca034ac0d | ||
|
|
97691ea5ee | ||
|
|
40335a0e21 | ||
|
|
9344cbd078 | ||
|
|
9442fd9465 | ||
|
|
c816f1003d | ||
|
|
2107b79a80 | ||
|
|
8fae6de8c7 | ||
|
|
d798c77574 | ||
|
|
0abce27381 | ||
|
|
8a171ba39a | ||
|
|
20af276772 | ||
|
|
4058342763 | ||
|
|
af64657260 | ||
|
|
b6bd46e59e | ||
|
|
31fe547e03 | ||
|
|
aff324071e | ||
|
|
131266e408 | ||
|
|
b1f97e8c8d | ||
|
|
9783d6e839 | ||
|
|
8eea2fb367 | ||
|
|
b585480c81 | ||
|
|
89e307daba | ||
|
|
a5eb0e293c | ||
|
|
48d1113225 | ||
|
|
d82d5c3bdc | ||
|
|
dfe58b3953 | ||
|
|
44019b8357 | ||
|
|
3c15a44faf | ||
|
|
8d113dadd2 | ||
|
|
c1dd26aee7 | ||
|
|
b2228c2a39 | ||
|
|
d9618cb09c | ||
|
|
c8ca683d3a | ||
|
|
888963ffaa | ||
|
|
ae947a8310 | ||
|
|
bee9cde347 | ||
|
|
c131dab125 | ||
|
|
e113642ae4 | ||
|
|
b76906b168 | ||
|
|
3960005002 | ||
|
|
3dde578b86 | ||
|
|
813f0e74ff | ||
|
|
1e4e37c2ce | ||
|
|
a00c80eab2 | ||
|
|
496e5ebe8c | ||
|
|
18cc8434a0 | ||
|
|
5eba318019 | ||
|
|
63274dbb17 | ||
|
|
4c73e788ae | ||
|
|
b71a2b3651 | ||
|
|
521a32dfff | ||
|
|
fd6ebe6e12 | ||
|
|
6fb97675ad | ||
|
|
c0c102207d | ||
|
|
3b9d9ac75d | ||
|
|
2536fd57ed | ||
|
|
d941e5e5b1 | ||
|
|
039b0a89bb | ||
|
|
febf9939c8 | ||
|
|
bb84c6dab8 | ||
|
|
cddc00e2cc | ||
|
|
091e3d41e1 | ||
|
|
9dc3a35c1a | ||
|
|
f8878d3006 | ||
|
|
1c0d596f26 | ||
|
|
1afd2ab388 | ||
|
|
4aa9500402 | ||
|
|
4a8a4482fc | ||
|
|
d83849a1b5 | ||
|
|
44272f5d66 | ||
|
|
83727ae931 | ||
|
|
0b0b88a255 | ||
|
|
f23d709f4e | ||
|
|
88abbc7ea6 | ||
|
|
16f0413af8 | ||
|
|
f47020a64d | ||
|
|
55e1ef81f7 | ||
|
|
6bb43d0411 | ||
|
|
f51c2328c9 | ||
|
|
fd37188ace | ||
|
|
758e27ce91 | ||
|
|
9a3b25eb50 | ||
|
|
6da890dfb8 | ||
|
|
0d35ec7139 | ||
|
|
dc0f9a63cb | ||
|
|
21c042996e | ||
|
|
5f22adadf2 | ||
|
|
4e8888ce2f | ||
|
|
0a69609d38 | ||
|
|
2dbcd88313 | ||
|
|
6b0775f7c7 | ||
|
|
e85d3c3c9f | ||
|
|
e20f57bba8 | ||
|
|
933ace2e38 | ||
|
|
4c1e5aed6b | ||
|
|
77d982b4aa | ||
|
|
02c8163265 | ||
|
|
ef868dbb6e | ||
|
|
b2bab33baa | ||
|
|
149e0835f8 | ||
|
|
50bed1d8f2 | ||
|
|
d4b5d78743 | ||
|
|
7fc82a2562 | ||
|
|
92fb138c2c | ||
|
|
71b4b45a3c | ||
|
|
07f4a1b227 | ||
|
|
e116e88e98 | ||
|
|
2278a181ca | ||
|
|
98dc75a404 | ||
|
|
536918aab3 | ||
|
|
c75ac80c7a | ||
|
|
522d8542e9 | ||
|
|
562df44c22 | ||
|
|
86e6798c96 | ||
|
|
db7a287e82 | ||
|
|
518206f208 |
15
.env.example
15
.env.example
@@ -1,9 +1,6 @@
|
||||
REDASH_CONNECTION_ADAPTER=pg
|
||||
REDASH_CONNECTION_STRING="dbname=data"
|
||||
REDASH_STATIC_ASSETS_PATH=../rd_ui/app/
|
||||
REDASH_GOOGLE_APPS_DOMAIN=
|
||||
REDASH_ADMINS=
|
||||
REDASH_WORKERS_COUNT=2
|
||||
REDASH_COOKIE_SECRET=
|
||||
REDASH_DATABASE_URL='postgresql://rd'
|
||||
REDASH_LOG_LEVEL = "INFO"
|
||||
export REDASH_STATIC_ASSETS_PATH="../rd_ui/app/"
|
||||
export REDASH_LOG_LEVEL="INFO"
|
||||
export REDASH_REDIS_URL=redis://localhost:6379/1
|
||||
export REDASH_DATABASE_URL="postgresql://redash"
|
||||
export REDASH_COOKIE_SECRET=veryverysecret
|
||||
export REDASH_GOOGLE_APPS_DOMAIN=
|
||||
|
||||
7
Makefile
7
Makefile
@@ -1,6 +1,7 @@
|
||||
NAME=redash
|
||||
VERSION=`python ./manage.py version`
|
||||
FULL_VERSION=$(VERSION)+b$(CIRCLE_BUILD_NUM)
|
||||
BASE_VERSION=$(shell python ./manage.py version | cut -d + -f 1)
|
||||
# VERSION gets evaluated every time it's referenced, therefore we need to use VERSION here instead of FULL_VERSION.
|
||||
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(VERSION).tar.gz
|
||||
|
||||
@@ -12,11 +13,11 @@ deps:
|
||||
|
||||
pack:
|
||||
sed -ri "s/^__version__ = '([0-9.]*)'/__version__ = '$(FULL_VERSION)'/" redash/__init__.py
|
||||
tar -zcv -f $(FILENAME) --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="rd_ui/node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
|
||||
tar -zcv -f $(FILENAME) --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="rd_ui/node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
|
||||
|
||||
upload:
|
||||
python bin/upload_version.py $(VERSION) $(FILENAME)
|
||||
python bin/release_manager.py $(CIRCLE_SHA1) $(BASE_VERSION) $(FILENAME)
|
||||
|
||||
test:
|
||||
nosetests --with-coverage --cover-package=redash tests/*.py
|
||||
cd rd_ui && grunt test
|
||||
#cd rd_ui && grunt test
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
web: ./manage.py runserver -p $PORT
|
||||
web: ./manage.py runserver -p $PORT --host 0.0.0.0
|
||||
worker: ./bin/run celery worker --app=redash.worker --beat -Qqueries,celery,scheduled_queries
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<p align="center">
|
||||
<img title="re:dash" src='https://raw.githubusercontent.com/EverythingMe/redash/screenshots/redash_logo.png' />
|
||||
|
||||
<img title="re:dash" src='http://redash.io/static/img/redash_logo.png' width="200px"/>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img title="Build Status" src='https://circleci.com/gh/EverythingMe/redash.png?circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040'/>
|
||||
@@ -11,7 +10,8 @@
|
||||
Prior to **_re:dash_**, we tried to use traditional BI suites and discovered a set of bloated, technically challenged and slow tools/flows. What we were looking for was a more hacker'ish way to look at data, so we built one.
|
||||
|
||||
**_re:dash_** was built to allow fast and easy access to billions of records, that we process and collect using Amazon Redshift ("petabyte scale data warehouse" that "speaks" PostgreSQL).
|
||||
Today **_re:dash_** has support for querying multiple databases, including: Redshift, Google BigQuery, PostgreSQL, MySQL, Graphite and custom scripts.
|
||||
Today **_re:dash_** has support for querying multiple databases, including: Redshift, Google BigQuery, PostgreSQL, MySQL, Graphite,
|
||||
Presto, Google Spreadsheets, Cloudera Impala and custom scripts.
|
||||
|
||||
**_re:dash_** consists of two parts:
|
||||
|
||||
@@ -28,7 +28,7 @@ You can try out the demo instance: http://demo.redash.io/ (login with any Google
|
||||
|
||||
## Getting Started
|
||||
|
||||
* [Setting up re:dash instance](https://github.com/EverythingMe/redash/wiki/Setting-up-re:dash-instance) (includes links to ready made AWS/GCE images).
|
||||
* [Setting up re:dash instance](http://redash.io/deployment/setup.html) (includes links to ready made AWS/GCE images).
|
||||
* Additional documentation in the [Wiki](https://github.com/everythingme/redash/wiki).
|
||||
|
||||
|
||||
|
||||
11
Vagrantfile
vendored
Normal file
11
Vagrantfile
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
|
||||
# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
|
||||
VAGRANTFILE_API_VERSION = "2"
|
||||
|
||||
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
config.vm.box = "redash/dev"
|
||||
config.vm.synced_folder "./", "/opt/redash/current"
|
||||
config.vm.network "forwarded_port", guest: 5000, host: 9001
|
||||
end
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import requests
|
||||
|
||||
if __name__ == '__main__':
|
||||
response = requests.get('https://api.github.com/repos/EverythingMe/redash/releases')
|
||||
|
||||
if response.status_code != 200:
|
||||
exit("Failed getting releases (status code: %s)." % response.status_code)
|
||||
|
||||
sorted_releases = sorted(response.json(), key=lambda release: release['id'], reverse=True)
|
||||
|
||||
latest_release = sorted_releases[0]
|
||||
asset_url = latest_release['assets'][0]['url']
|
||||
filename = latest_release['assets'][0]['name']
|
||||
|
||||
wget_command = 'wget --header="Accept: application/octet-stream" %s -O %s' % (asset_url, filename)
|
||||
|
||||
if '--url-only' in sys.argv:
|
||||
print asset_url
|
||||
elif '--wget' in sys.argv:
|
||||
print wget_command
|
||||
else:
|
||||
print "Latest release: %s" % latest_release['tag_name']
|
||||
print latest_release['body']
|
||||
|
||||
print "\nTarball URL: %s" % asset_url
|
||||
print 'wget: %s' % (wget_command)
|
||||
|
||||
|
||||
147
bin/release_manager.py
Normal file
147
bin/release_manager.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import requests
|
||||
|
||||
github_token = os.environ['GITHUB_TOKEN']
|
||||
auth = (github_token, 'x-oauth-basic')
|
||||
repo = 'EverythingMe/redash'
|
||||
|
||||
def _github_request(method, path, params=None, headers={}):
|
||||
if not path.startswith('https://api.github.com'):
|
||||
url = "https://api.github.com/{}".format(path)
|
||||
else:
|
||||
url = path
|
||||
|
||||
if params is not None:
|
||||
params = json.dumps(params)
|
||||
|
||||
response = requests.request(method, url, data=params, auth=auth)
|
||||
return response
|
||||
|
||||
def exception_from_error(message, response):
|
||||
return Exception("({}) {}: {}".format(response.status_code, message, response.json().get('message', '?')))
|
||||
|
||||
def rc_tag_name(version):
|
||||
return "v{}-rc".format(version)
|
||||
|
||||
def get_rc_release(version):
|
||||
tag = rc_tag_name(version)
|
||||
response = _github_request('get', 'repos/{}/releases/tags/{}'.format(repo, tag))
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
elif response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
raise exception_from_error("Unknown error while looking RC release: ", response)
|
||||
|
||||
def create_release(version, commit_sha):
|
||||
tag = rc_tag_name(version)
|
||||
|
||||
params = {
|
||||
'tag_name': tag,
|
||||
'name': "{} - RC".format(version),
|
||||
'target_commitish': commit_sha,
|
||||
'prerelease': True
|
||||
}
|
||||
|
||||
response = _github_request('post', 'repos/{}/releases'.format(repo), params)
|
||||
|
||||
if response.status_code != 201:
|
||||
raise exception_from_error("Failed creating new release", response)
|
||||
|
||||
return response.json()
|
||||
|
||||
def upload_asset(release, filepath):
|
||||
upload_url = release['upload_url'].replace('{?name}', '')
|
||||
filename = filepath.split('/')[-1]
|
||||
|
||||
with open(filepath) as file_content:
|
||||
headers = {'Content-Type': 'application/gzip'}
|
||||
response = requests.post(upload_url, file_content, params={'name': filename}, headers=headers, auth=auth, verify=False)
|
||||
|
||||
if response.status_code != 201: # not 200/201/...
|
||||
raise exception_from_error('Failed uploading asset', response)
|
||||
|
||||
return response
|
||||
|
||||
def remove_previous_builds(release):
|
||||
for asset in release['assets']:
|
||||
response = _github_request('delete', asset['url'])
|
||||
if response.status_code != 204:
|
||||
raise exception_from_error("Failed deleting asset", response)
|
||||
|
||||
def get_changelog(commit_sha):
|
||||
latest_release = _github_request('get', 'repos/{}/releases/latest'.format(repo))
|
||||
if latest_release.status_code != 200:
|
||||
raise exception_from_error('Failed getting latest release', latest_release)
|
||||
|
||||
latest_release = latest_release.json()
|
||||
previous_sha = latest_release['target_commitish']
|
||||
|
||||
args = ['git', '--no-pager', 'log', '--merges', '--grep', 'Merge pull request', '--pretty=format:"%h|%s|%b|%p"', '{}...{}'.format(previous_sha, commit_sha)]
|
||||
log = subprocess.check_output(args)
|
||||
changes = ["Changes since {}:".format(latest_release['name'])]
|
||||
|
||||
for line in log.split('\n'):
|
||||
try:
|
||||
sha, subject, body, parents = line[1:-1].split('|')
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
try:
|
||||
pull_request = re.match("Merge pull request #(\d+)", subject).groups()[0]
|
||||
pull_request = " #{}".format(pull_request)
|
||||
except Exception, ex:
|
||||
pull_request = ""
|
||||
|
||||
author = subprocess.check_output(['git', 'log', '-1', '--pretty=format:"%an"', parents.split(' ')[-1]])[1:-1]
|
||||
|
||||
changes.append("{}{}: {} ({})".format(sha, pull_request, body.strip(), author))
|
||||
|
||||
return "\n".join(changes)
|
||||
|
||||
def update_release_commit_sha(release, commit_sha):
|
||||
params = {
|
||||
'target_commitish': commit_sha,
|
||||
}
|
||||
|
||||
response = _github_request('patch', 'repos/{}/releases/{}'.format(repo, release['id']), params)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise exception_from_error("Failed updating commit sha for existing release", response)
|
||||
|
||||
return response.json()
|
||||
|
||||
def update_release(version, build_filepath, commit_sha):
|
||||
try:
|
||||
release = get_rc_release(version)
|
||||
if release:
|
||||
release = update_release_commit_sha(release, commit_sha)
|
||||
else:
|
||||
release = create_release(version, commit_sha)
|
||||
|
||||
print "Using release id: {}".format(release['id'])
|
||||
|
||||
remove_previous_builds(release)
|
||||
response = upload_asset(release, build_filepath)
|
||||
|
||||
changelog = get_changelog(commit_sha)
|
||||
|
||||
response = _github_request('patch', release['url'], {'body': changelog})
|
||||
if response.status_code != 200:
|
||||
raise exception_from_error("Failed updating release description", response)
|
||||
|
||||
except Exception, ex:
|
||||
print ex
|
||||
|
||||
if __name__ == '__main__':
|
||||
commit_sha = sys.argv[1]
|
||||
version = sys.argv[2]
|
||||
filepath = sys.argv[3]
|
||||
|
||||
# TODO: make sure running from git directory & remote = repo
|
||||
update_release(version, filepath, commit_sha)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""
|
||||
Script to test concurrency (multithreading/multiprocess) issues with the workers. Use with caution.
|
||||
"""
|
||||
import json
|
||||
import atfork
|
||||
atfork.monkeypatch_os_fork_functions()
|
||||
import atfork.stdlib_fixer
|
||||
atfork.stdlib_fixer.fix_logging_module()
|
||||
|
||||
import time
|
||||
from redash.data import worker
|
||||
from redash import models, data_manager, redis_connection
|
||||
|
||||
if __name__ == '__main__':
|
||||
models.create_db(True, False)
|
||||
|
||||
print "Creating data source..."
|
||||
data_source = models.DataSource.create(name="Concurrency", type="pg", options="dbname=postgres")
|
||||
|
||||
print "Clear jobs/hashes:"
|
||||
redis_connection.delete("jobs")
|
||||
query_hashes = redis_connection.keys("query_hash_*")
|
||||
if query_hashes:
|
||||
redis_connection.delete(*query_hashes)
|
||||
|
||||
starting_query_results_count = models.QueryResult.select().count()
|
||||
jobs_count = 5000
|
||||
workers_count = 10
|
||||
|
||||
print "Creating jobs..."
|
||||
for i in xrange(jobs_count):
|
||||
query = "SELECT {}".format(i)
|
||||
print "Inserting: {}".format(query)
|
||||
data_manager.add_job(query=query, priority=worker.Job.LOW_PRIORITY,
|
||||
data_source=data_source)
|
||||
|
||||
print "Starting workers..."
|
||||
workers = data_manager.start_workers(workers_count)
|
||||
|
||||
print "Waiting for jobs to be done..."
|
||||
keep_waiting = True
|
||||
while keep_waiting:
|
||||
results_count = models.QueryResult.select().count() - starting_query_results_count
|
||||
print "QueryResults: {}".format(results_count)
|
||||
time.sleep(5)
|
||||
if results_count == jobs_count:
|
||||
print "Yay done..."
|
||||
keep_waiting = False
|
||||
|
||||
data_manager.stop_workers()
|
||||
|
||||
qr_count = 0
|
||||
for qr in models.QueryResult.select():
|
||||
number = int(qr.query.split()[1])
|
||||
data_number = json.loads(qr.data)['rows'][0].values()[0]
|
||||
|
||||
if number != data_number:
|
||||
print "Oops? {} != {} ({})".format(number, data_number, qr.id)
|
||||
qr_count += 1
|
||||
|
||||
print "Verified {} query results.".format(qr_count)
|
||||
|
||||
print "Done."
|
||||
@@ -1,46 +0,0 @@
|
||||
#!python
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
import subprocess
|
||||
|
||||
|
||||
def capture_output(command):
|
||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
|
||||
return proc.stdout.read()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
version = sys.argv[1]
|
||||
filepath = sys.argv[2]
|
||||
filename = filepath.split('/')[-1]
|
||||
github_token = os.environ['GITHUB_TOKEN']
|
||||
auth = (github_token, 'x-oauth-basic')
|
||||
commit_sha = os.environ['CIRCLE_SHA1']
|
||||
|
||||
commit_body = capture_output(["git", "log", "--format=%b", "-n", "1", commit_sha])
|
||||
file_md5_checksum = capture_output(["md5sum", filepath]).split()[0]
|
||||
file_sha256_checksum = capture_output(["sha256sum", filepath]).split()[0]
|
||||
version_body = "%s\n\nMD5: %s\nSHA256: %s" % (commit_body, file_md5_checksum, file_sha256_checksum)
|
||||
|
||||
params = json.dumps({
|
||||
'tag_name': 'v{0}'.format(version),
|
||||
'name': 're:dash v{0}'.format(version),
|
||||
'body': version_body,
|
||||
'target_commitish': commit_sha,
|
||||
'prerelease': True
|
||||
})
|
||||
|
||||
response = requests.post('https://api.github.com/repos/everythingme/redash/releases',
|
||||
data=params,
|
||||
auth=auth)
|
||||
|
||||
upload_url = response.json()['upload_url']
|
||||
upload_url = upload_url.replace('{?name}', '')
|
||||
|
||||
with open(filepath) as file_content:
|
||||
headers = {'Content-Type': 'application/gzip'}
|
||||
response = requests.post(upload_url, file_content, params={'name': filename}, auth=auth,
|
||||
headers=headers, verify=False)
|
||||
|
||||
@@ -7,6 +7,9 @@ machine:
|
||||
2.7.3
|
||||
dependencies:
|
||||
pre:
|
||||
- wget http://downloads.sourceforge.net/project/optipng/OptiPNG/optipng-0.7.5/optipng-0.7.5.tar.gz
|
||||
- tar xvf optipng-0.7.5.tar.gz
|
||||
- cd optipng-0.7.5; ./configure; make; sudo checkinstall -y;
|
||||
- make deps
|
||||
- pip install -r dev_requirements.txt
|
||||
- pip install -r requirements.txt
|
||||
@@ -26,3 +29,7 @@ deployment:
|
||||
notify:
|
||||
webhooks:
|
||||
- url: https://webhooks.gitter.im/e/895d09c3165a0913ac2f
|
||||
general:
|
||||
branches:
|
||||
ignore:
|
||||
- gh-pages
|
||||
|
||||
180
manage.py
180
manage.py
@@ -2,27 +2,35 @@
|
||||
"""
|
||||
CLI to manage redash.
|
||||
"""
|
||||
import datetime
|
||||
from flask.ext.script import Manager, prompt_pass
|
||||
import json
|
||||
|
||||
from flask.ext.script import Manager
|
||||
|
||||
from redash import settings, models, __version__
|
||||
from redash.wsgi import app
|
||||
from redash.import_export import import_manager
|
||||
from redash.cli import users, database, data_sources
|
||||
from redash.monitor import get_status
|
||||
|
||||
manager = Manager(app)
|
||||
database_manager = Manager(help="Manages the database (create/drop tables).")
|
||||
users_manager = Manager(help="Users management commands.")
|
||||
data_sources_manager = Manager(help="Data sources management commands.")
|
||||
manager.add_command("database", database.manager)
|
||||
manager.add_command("users", users.manager)
|
||||
manager.add_command("import", import_manager)
|
||||
manager.add_command("ds", data_sources.manager)
|
||||
|
||||
|
||||
@manager.command
|
||||
def version():
|
||||
"""Displays re:dash version."""
|
||||
print __version__
|
||||
|
||||
@manager.command
|
||||
def status():
|
||||
print json.dumps(get_status(), indent=2)
|
||||
|
||||
@manager.command
|
||||
def runworkers():
|
||||
"""Prints deprecation warning."""
|
||||
"""Start workers (deprecated)."""
|
||||
print "** This command is deprecated. Please use Celery's CLI to control the workers. **"
|
||||
|
||||
|
||||
@@ -31,162 +39,20 @@ def make_shell_context():
|
||||
from redash.models import db
|
||||
return dict(app=app, db=db, models=models)
|
||||
|
||||
|
||||
@manager.command
|
||||
def check_settings():
|
||||
from types import ModuleType
|
||||
|
||||
for name in dir(settings):
|
||||
item = getattr(settings, name)
|
||||
if not callable(item) and not name.startswith("__") and not isinstance(item, ModuleType):
|
||||
print "{} = {}".format(name, item)
|
||||
"""Show the settings as re:dash sees them (useful for debugging)."""
|
||||
for name, item in settings.all_settings().iteritems():
|
||||
print "{} = {}".format(name, item)
|
||||
|
||||
@manager.command
|
||||
def import_events(events_file):
|
||||
# TODO: remove this code past 1/11/2014.
|
||||
import json
|
||||
from collections import Counter
|
||||
def send_test_mail():
|
||||
from redash import mail
|
||||
from flask_mail import Message
|
||||
|
||||
count = Counter()
|
||||
mail.send(Message(subject="Test Message from re:dash", recipients=[settings.MAIL_DEFAULT_SENDER], body="Test message."))
|
||||
|
||||
with open(events_file) as f:
|
||||
for line in f:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
|
||||
object_type = event.get('object_type', None)
|
||||
object_id = event.get('object_id', None)
|
||||
|
||||
if object_id == 'dashboard' and object_type == 'dashboard':
|
||||
count['bad dashboard id'] += 1
|
||||
continue
|
||||
|
||||
models.Event.record(event)
|
||||
|
||||
count['imported'] += 1
|
||||
|
||||
except Exception as ex:
|
||||
print "Failed importing line:"
|
||||
print line
|
||||
print ex.message
|
||||
count[ex.message] += 1
|
||||
count['failed'] += 1
|
||||
|
||||
models.db.close_db(None)
|
||||
|
||||
for k, v in count.iteritems():
|
||||
print k
|
||||
print v
|
||||
|
||||
|
||||
@database_manager.command
|
||||
def create_tables():
|
||||
"""Creates the database tables."""
|
||||
from redash.models import create_db, init_db
|
||||
|
||||
create_db(True, False)
|
||||
init_db()
|
||||
|
||||
@database_manager.command
|
||||
def drop_tables():
|
||||
"""Drop the database tables."""
|
||||
from redash.models import create_db
|
||||
|
||||
create_db(False, True)
|
||||
|
||||
|
||||
@users_manager.option('email', help="User's email")
|
||||
@users_manager.option('name', help="User's full name")
|
||||
@users_manager.option('--admin', dest='is_admin', action="store_true", default=False, help="set user as admin")
|
||||
@users_manager.option('--google', dest='google_auth', action="store_true", default=False, help="user uses Google Auth to login")
|
||||
@users_manager.option('--password', dest='password', default=None, help="Password for users who don't use Google Auth (leave blank for prompt).")
|
||||
@users_manager.option('--groups', dest='groups', default=models.User.DEFAULT_GROUPS, help="Comma seperated list of groups (leave blank for default).")
|
||||
def create(email, name, groups, is_admin=False, google_auth=False, password=None):
|
||||
print "Creating user (%s, %s)..." % (email, name)
|
||||
print "Admin: %r" % is_admin
|
||||
print "Login with Google Auth: %r\n" % google_auth
|
||||
if isinstance(groups, basestring):
|
||||
groups= groups.split(',')
|
||||
groups.remove('') # in case it was empty string
|
||||
|
||||
if is_admin:
|
||||
groups += ['admin']
|
||||
|
||||
user = models.User(email=email, name=name, groups=groups)
|
||||
if not google_auth:
|
||||
password = password or prompt_pass("Password")
|
||||
user.hash_password(password)
|
||||
|
||||
try:
|
||||
user.save()
|
||||
except Exception, e:
|
||||
print "Failed creating user: %s" % e.message
|
||||
|
||||
|
||||
@users_manager.option('email', help="email address of user to delete")
|
||||
def delete(email):
|
||||
deleted_count = models.User.delete().where(models.User.email == email).execute()
|
||||
print "Deleted %d users." % deleted_count
|
||||
|
||||
|
||||
@users_manager.option('password', help="new password for the user")
|
||||
@users_manager.option('email', help="email address of the user to change password for")
|
||||
def password(email, password):
|
||||
try:
|
||||
user = models.User.get_by_email(email)
|
||||
|
||||
user.hash_password(password)
|
||||
user.save()
|
||||
|
||||
print "User updated."
|
||||
except models.User.DoesNotExist:
|
||||
print "User [%s] not found." % email
|
||||
|
||||
|
||||
@users_manager.option('email', help="email address of the user to grant admin to")
|
||||
def grant_admin(email):
|
||||
try:
|
||||
user = models.User.get_by_email(email)
|
||||
|
||||
user.groups.append('admin')
|
||||
user.save()
|
||||
|
||||
print "User updated."
|
||||
except models.User.DoesNotExist:
|
||||
print "User [%s] not found." % email
|
||||
|
||||
|
||||
@data_sources_manager.command
|
||||
def import_from_settings(name=None):
|
||||
"""Import data source from settings (env variables)."""
|
||||
name = name or "Default"
|
||||
data_source = models.DataSource.create(name=name,
|
||||
type=settings.CONNECTION_ADAPTER,
|
||||
options=settings.CONNECTION_STRING)
|
||||
|
||||
print "Imported data source from settings (id={}).".format(data_source.id)
|
||||
|
||||
|
||||
@data_sources_manager.command
|
||||
def list():
|
||||
"""List currently configured data sources"""
|
||||
for ds in models.DataSource.select():
|
||||
print "Name: {}\nType: {}\nOptions: {}".format(ds.name, ds.type, ds.options)
|
||||
|
||||
@data_sources_manager.command
|
||||
def new(name, type, options):
|
||||
"""Create new data source"""
|
||||
# TODO: validate it's a valid type and in the future, validate the options.
|
||||
print "Creating {} data source ({}) with options:\n{}".format(type, name, options)
|
||||
data_source = models.DataSource.create(name=name,
|
||||
type=type,
|
||||
options=options)
|
||||
print "Id: {}".format(data_source.id)
|
||||
|
||||
|
||||
manager.add_command("database", database_manager)
|
||||
manager.add_command("users", users_manager)
|
||||
manager.add_command("import", import_manager)
|
||||
manager.add_command("ds", data_sources_manager)
|
||||
|
||||
if __name__ == '__main__':
|
||||
manager.run()
|
||||
manager.run()
|
||||
|
||||
15
migrations/0001_allow_delete_query.py
Normal file
15
migrations/0001_allow_delete_query.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.add_column('queries', 'is_archived', models.Query.is_archived)
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
21
migrations/0002_fix_timestamp_fields.py
Normal file
21
migrations/0002_fix_timestamp_fields.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from redash.models import db
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
columns = (
|
||||
('activity_log', 'created_at'),
|
||||
('dashboards', 'created_at'),
|
||||
('data_sources', 'created_at'),
|
||||
('events', 'created_at'),
|
||||
('groups', 'created_at'),
|
||||
('queries', 'created_at'),
|
||||
('widgets', 'created_at'),
|
||||
('query_results', 'retrieved_at')
|
||||
)
|
||||
|
||||
with db.database.transaction():
|
||||
for column in columns:
|
||||
db.database.execute_sql("ALTER TABLE {} ALTER COLUMN {} TYPE timestamp with time zone;".format(*column))
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
73
migrations/0003_update_data_source_config.py
Normal file
73
migrations/0003_update_data_source_config.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
|
||||
from redash import query_runner
|
||||
from redash.models import DataSource
|
||||
|
||||
|
||||
def update(data_source):
|
||||
print "[%s] Old options: %s" % (data_source.name, data_source.options)
|
||||
|
||||
if query_runner.validate_configuration(data_source.type, data_source.options):
|
||||
print "[%s] configuration already valid. skipping." % data_source.name
|
||||
return
|
||||
|
||||
if data_source.type == 'pg':
|
||||
values = data_source.options.split(" ")
|
||||
configuration = {}
|
||||
for value in values:
|
||||
k, v = value.split("=", 1)
|
||||
configuration[k] = v
|
||||
if k == 'port':
|
||||
configuration[k] = int(v)
|
||||
|
||||
data_source.options = json.dumps(configuration)
|
||||
|
||||
elif data_source.type == 'mysql':
|
||||
mapping = {
|
||||
'Server': 'host',
|
||||
'User': 'user',
|
||||
'Pwd': 'passwd',
|
||||
'Database': 'db'
|
||||
}
|
||||
|
||||
values = data_source.options.split(";")
|
||||
configuration = {}
|
||||
for value in values:
|
||||
k, v = value.split("=", 1)
|
||||
configuration[mapping[k]] = v
|
||||
data_source.options = json.dumps(configuration)
|
||||
|
||||
elif data_source.type == 'graphite':
|
||||
old_config = json.loads(data_source.options)
|
||||
|
||||
configuration = {
|
||||
"url": old_config["url"]
|
||||
}
|
||||
|
||||
if "verify" in old_config:
|
||||
configuration['verify'] = old_config['verify']
|
||||
|
||||
if "auth" in old_config:
|
||||
configuration['username'], configuration['password'] = old_config["auth"]
|
||||
|
||||
data_source.options = json.dumps(configuration)
|
||||
|
||||
elif data_source.type == 'url':
|
||||
data_source.options = json.dumps({"url": data_source.options})
|
||||
|
||||
elif data_source.type == 'script':
|
||||
data_source.options = json.dumps({"path": data_source.options})
|
||||
|
||||
elif data_source.type == 'mongo':
|
||||
data_source.type = 'mongodb'
|
||||
|
||||
else:
|
||||
print "[%s] No need to convert type of: %s" % (data_source.name, data_source.type)
|
||||
|
||||
print "[%s] New options: %s" % (data_source.name, data_source.options)
|
||||
data_source.save()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for data_source in DataSource.all():
|
||||
update(data_source)
|
||||
12
migrations/0004_allow_null_in_event_user.py
Normal file
12
migrations/0004_allow_null_in_event_user.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.drop_not_null('events', 'user_id')
|
||||
)
|
||||
26
migrations/0005_add_updated_at.py
Normal file
26
migrations/0005_add_updated_at.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.add_column('queries', 'updated_at', models.Query.updated_at),
|
||||
migrator.add_column('dashboards', 'updated_at', models.Dashboard.updated_at),
|
||||
migrator.add_column('widgets', 'updated_at', models.Widget.updated_at),
|
||||
migrator.add_column('users', 'created_at', models.User.created_at),
|
||||
migrator.add_column('users', 'updated_at', models.User.updated_at),
|
||||
migrator.add_column('visualizations', 'created_at', models.Visualization.created_at),
|
||||
migrator.add_column('visualizations', 'updated_at', models.Visualization.updated_at)
|
||||
)
|
||||
|
||||
db.database.execute_sql("UPDATE queries SET updated_at = created_at;")
|
||||
db.database.execute_sql("UPDATE dashboards SET updated_at = created_at;")
|
||||
db.database.execute_sql("UPDATE widgets SET updated_at = created_at;")
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
19
migrations/0006_queries_last_edit_by.py
Normal file
19
migrations/0006_queries_last_edit_by.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.add_column('queries', 'last_modified_by_id', models.Query.last_modified_by)
|
||||
)
|
||||
|
||||
db.database.execute_sql("UPDATE queries SET last_modified_by_id = user_id;")
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
|
||||
23
migrations/0007_add_schedule_to_queries.py
Normal file
23
migrations/0007_add_schedule_to_queries.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.add_column('queries', 'schedule', models.Query.schedule),
|
||||
)
|
||||
|
||||
db.database.execute_sql("UPDATE queries SET schedule = ttl WHERE ttl > 0;")
|
||||
|
||||
migrate(
|
||||
migrator.drop_column('queries', 'ttl')
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
|
||||
20
migrations/0008_make_ds_name_unique.py
Normal file
20
migrations/0008_make_ds_name_unique.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from redash.models import db
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
with db.database.transaction():
|
||||
# Make sure all data sources names are unique.
|
||||
db.database.execute_sql("""
|
||||
UPDATE data_sources
|
||||
SET name = new_names.name
|
||||
FROM (
|
||||
SELECT id, name || ' ' || id as name
|
||||
FROM (SELECT id, name, rank() OVER (PARTITION BY name ORDER BY created_at ASC) FROM data_sources) ds WHERE rank > 1
|
||||
) AS new_names
|
||||
WHERE data_sources.id = new_names.id;
|
||||
""")
|
||||
# Add unique constraint on data_sources.name.
|
||||
db.database.execute_sql("ALTER TABLE data_sources ADD CONSTRAINT unique_name UNIQUE (name);")
|
||||
|
||||
db.close_db(None)
|
||||
27
migrations/0009_add_api_key_to_user.py
Normal file
27
migrations/0009_add_api_key_to_user.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
column = models.User.api_key
|
||||
column.null = True
|
||||
migrate(
|
||||
migrator.add_column('users', 'api_key', models.User.api_key),
|
||||
)
|
||||
|
||||
for user in models.User.select():
|
||||
user.save()
|
||||
|
||||
migrate(
|
||||
migrator.add_not_null('users', 'api_key')
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
|
||||
|
||||
18
migrations/0010_allow_deleting_datasources.py
Normal file
18
migrations/0010_allow_deleting_datasources.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.drop_not_null('queries', 'data_source_id'),
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
|
||||
|
||||
|
||||
8
migrations/0010_create_alerts.py
Normal file
8
migrations/0010_create_alerts.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from redash.models import db, Alert, AlertSubscription
|
||||
|
||||
if __name__ == '__main__':
|
||||
with db.database.transaction():
|
||||
Alert.create_table()
|
||||
AlertSubscription.create_table()
|
||||
|
||||
db.close_db(None)
|
||||
44
migrations/0011_migrate_bigquery_to_json.py
Normal file
44
migrations/0011_migrate_bigquery_to_json.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from base64 import b64encode
|
||||
import json
|
||||
from redash.models import DataSource
|
||||
|
||||
|
||||
def convert_p12_to_pem(p12file):
|
||||
from OpenSSL import crypto
|
||||
with open(p12file, 'rb') as f:
|
||||
p12 = crypto.load_pkcs12(f.read(), "notasecret")
|
||||
|
||||
return crypto.dump_privatekey(crypto.FILETYPE_PEM, p12.get_privatekey())
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
for ds in DataSource.all():
|
||||
|
||||
if ds.type == 'bigquery':
|
||||
options = json.loads(ds.options)
|
||||
|
||||
if 'jsonKeyFile' in options:
|
||||
continue
|
||||
|
||||
new_options = {
|
||||
'projectId': options['projectId'],
|
||||
'jsonKeyFile': b64encode(json.dumps({
|
||||
'client_email': options['serviceAccount'],
|
||||
'private_key': convert_p12_to_pem(options['privateKey'])
|
||||
}))
|
||||
}
|
||||
|
||||
ds.options = json.dumps(new_options)
|
||||
ds.save()
|
||||
elif ds.type == 'google_spreadsheets':
|
||||
options = json.loads(ds.options)
|
||||
if 'jsonKeyFile' in options:
|
||||
continue
|
||||
|
||||
with open(options['credentialsFilePath']) as f:
|
||||
new_options = {
|
||||
'jsonKeyFile': b64encode(f.read())
|
||||
}
|
||||
|
||||
ds.options = json.dumps(new_options)
|
||||
ds.save()
|
||||
@@ -19,6 +19,7 @@
|
||||
"trailing": true,
|
||||
"smarttabs": true,
|
||||
"globals": {
|
||||
"angular": false
|
||||
"angular": false,
|
||||
"_": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,6 @@ module.exports = function (grunt) {
|
||||
// Automatically inject Bower components into the app
|
||||
wiredep: {
|
||||
options: {
|
||||
cwd: '<%= yeoman.app %>'
|
||||
},
|
||||
app: {
|
||||
src: ['<%= yeoman.app %>/index.html'],
|
||||
@@ -321,7 +320,12 @@ module.exports = function (grunt) {
|
||||
src: ['generated/*']
|
||||
}, {
|
||||
expand: true,
|
||||
cwd: 'bower_components/bootstrap/dist',
|
||||
cwd: '<%= yeoman.app %>/bower_components/bootstrap/dist',
|
||||
src: 'fonts/*',
|
||||
dest: '<%= yeoman.dist %>'
|
||||
}, {
|
||||
expand: true,
|
||||
cwd: '<%= yeoman.app %>/bower_components/font-awesome',
|
||||
src: 'fonts/*',
|
||||
dest: '<%= yeoman.dist %>'
|
||||
}]
|
||||
|
||||
Binary file not shown.
@@ -1,228 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata></metadata>
|
||||
<defs>
|
||||
<font id="glyphicons_halflingsregular" horiz-adv-x="1200" >
|
||||
<font-face units-per-em="1200" ascent="960" descent="-240" />
|
||||
<missing-glyph horiz-adv-x="500" />
|
||||
<glyph />
|
||||
<glyph />
|
||||
<glyph unicode=" " />
|
||||
<glyph unicode="*" d="M1100 500h-259l183 -183l-141 -141l-183 183v-259h-200v259l-183 -183l-141 141l183 183h-259v200h259l-183 183l141 141l183 -183v259h200v-259l183 183l141 -141l-183 -183h259v-200z" />
|
||||
<glyph unicode="+" d="M1100 400h-400v-400h-300v400h-400v300h400v400h300v-400h400v-300z" />
|
||||
<glyph unicode=" " />
|
||||
<glyph unicode=" " horiz-adv-x="652" />
|
||||
<glyph unicode=" " horiz-adv-x="1304" />
|
||||
<glyph unicode=" " horiz-adv-x="652" />
|
||||
<glyph unicode=" " horiz-adv-x="1304" />
|
||||
<glyph unicode=" " horiz-adv-x="434" />
|
||||
<glyph unicode=" " horiz-adv-x="326" />
|
||||
<glyph unicode=" " horiz-adv-x="217" />
|
||||
<glyph unicode=" " horiz-adv-x="217" />
|
||||
<glyph unicode=" " horiz-adv-x="163" />
|
||||
<glyph unicode=" " horiz-adv-x="260" />
|
||||
<glyph unicode=" " horiz-adv-x="72" />
|
||||
<glyph unicode=" " horiz-adv-x="260" />
|
||||
<glyph unicode=" " horiz-adv-x="326" />
|
||||
<glyph unicode="€" d="M800 500h-300q9 -74 33 -132t52.5 -91t62 -54.5t59 -29t46.5 -7.5q29 0 66 13t75 37t63.5 67.5t25.5 96.5h174q-31 -172 -128 -278q-107 -117 -274 -117q-205 0 -324 158q-36 46 -69 131.5t-45 205.5h-217l100 100h113q0 47 5 100h-218l100 100h135q37 167 112 257 q117 141 297 141q242 0 354 -189q60 -103 66 -209h-181q0 55 -25.5 99t-63.5 68t-75 36.5t-67 12.5q-24 0 -52.5 -10t-62.5 -32t-65.5 -67t-50.5 -107h379l-100 -100h-300q-6 -46 -6 -100h406z" />
|
||||
<glyph unicode="−" d="M1100 700h-900v-300h900v300z" />
|
||||
<glyph unicode="☁" d="M178 300h750q120 0 205 86t85 208q0 120 -85 206.5t-205 86.5q-46 0 -90 -14q-44 97 -134.5 156.5t-200.5 59.5q-152 0 -260 -107.5t-108 -260.5q0 -25 2 -37q-66 -14 -108.5 -67.5t-42.5 -122.5q0 -80 56.5 -137t135.5 -57z" />
|
||||
<glyph unicode="✉" d="M1200 1100h-1200l600 -603zM300 600l-300 -300v600zM1200 900v-600l-300 300zM800 500l400 -400h-1200l400 400l200 -200z" />
|
||||
<glyph unicode="✏" d="M1101 889l99 92q13 13 13 32.5t-13 33.5l-153 153q-15 13 -33 13t-33 -13l-94 -97zM401 189l614 614l-214 214l-614 -614zM-13 -13l333 112l-223 223z" />
|
||||
<glyph unicode="" horiz-adv-x="500" d="M0 0z" />
|
||||
<glyph unicode="" d="M700 100h300v-100h-800v100h300v550l-500 550h1200l-500 -550v-550z" />
|
||||
<glyph unicode="" d="M1000 934v-521q-64 16 -138 -7q-79 -26 -122.5 -83t-25.5 -111q17 -55 85.5 -75.5t147.5 4.5q70 23 111.5 63.5t41.5 95.5v881q0 10 -7 15.5t-17 2.5l-752 -193q-10 -3 -17 -12.5t-7 -19.5v-689q-64 17 -138 -7q-79 -25 -122.5 -82t-25.5 -112t86 -75.5t147 5.5 q65 21 109 69t44 90v606z" />
|
||||
<glyph unicode="" d="M913 432l300 -300q7 -8 7 -18t-7 -18l-109 -109q-8 -7 -18 -7t-18 7l-300 300q-119 -78 -261 -78q-200 0 -342 142t-142 342t142 342t342 142t342 -142t142 -342q0 -142 -78 -261zM176 693q0 -136 97 -233t234 -97t233.5 96.5t96.5 233.5t-96.5 233.5t-233.5 96.5 t-234 -97t-97 -233z" />
|
||||
<glyph unicode="" d="M649 949q48 69 109.5 105t121.5 38t118.5 -20.5t102.5 -64t71 -100.5t27 -123q0 -57 -33.5 -117.5t-94 -124.5t-126.5 -127.5t-150 -152.5t-146 -174q-62 85 -145.5 174t-149.5 152.5t-126.5 127.5t-94 124.5t-33.5 117.5q0 64 28 123t73 100.5t104.5 64t119 20.5 t120 -38.5t104.5 -104.5z" />
|
||||
<glyph unicode="" d="M791 522l145 -449l-384 275l-382 -275l146 447l-388 280h479l146 400h2l146 -400h472zM168 71l2 1z" />
|
||||
<glyph unicode="" d="M791 522l145 -449l-384 275l-382 -275l146 447l-388 280h479l146 400h2l146 -400h472zM747 331l-74 229l193 140h-235l-77 211l-78 -211h-239l196 -142l-73 -226l192 140zM168 71l2 1z" />
|
||||
<glyph unicode="" d="M1200 143v-143h-1200v143l400 257v100q-37 0 -68.5 74.5t-31.5 125.5v200q0 124 88 212t212 88t212 -88t88 -212v-200q0 -51 -31.5 -125.5t-68.5 -74.5v-100z" />
|
||||
<glyph unicode="" d="M1200 1100v-1100h-1200v1100h1200zM200 1000h-100v-100h100v100zM900 1000h-600v-400h600v400zM1100 1000h-100v-100h100v100zM200 800h-100v-100h100v100zM1100 800h-100v-100h100v100zM200 600h-100v-100h100v100zM1100 600h-100v-100h100v100zM900 500h-600v-400h600 v400zM200 400h-100v-100h100v100zM1100 400h-100v-100h100v100zM200 200h-100v-100h100v100zM1100 200h-100v-100h100v100z" />
|
||||
<glyph unicode="" d="M500 1050v-400q0 -21 -14.5 -35.5t-35.5 -14.5h-400q-21 0 -35.5 14.5t-14.5 35.5v400q0 21 14.5 35.5t35.5 14.5h400q21 0 35.5 -14.5t14.5 -35.5zM1100 1050v-400q0 -21 -14.5 -35.5t-35.5 -14.5h-400q-21 0 -35.5 14.5t-14.5 35.5v400q0 21 14.5 35.5t35.5 14.5h400 q21 0 35.5 -14.5t14.5 -35.5zM500 450v-400q0 -21 -14.5 -35.5t-35.5 -14.5h-400q-21 0 -35.5 14.5t-14.5 35.5v400q0 21 14.5 35.5t35.5 14.5h400q21 0 35.5 -14.5t14.5 -35.5zM1100 450v-400q0 -21 -14.5 -35.5t-35.5 -14.5h-400q-21 0 -35.5 14.5t-14.5 35.5v400 q0 21 14.5 35.5t35.5 14.5h400q21 0 35.5 -14.5t14.5 -35.5z" />
|
||||
<glyph unicode="" d="M300 1050v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-200q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h200q21 0 35.5 -14.5t14.5 -35.5zM700 1050v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-200q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h200 q21 0 35.5 -14.5t14.5 -35.5zM1100 1050v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-200q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h200q21 0 35.5 -14.5t14.5 -35.5zM300 650v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-200q-21 0 -35.5 14.5t-14.5 35.5v200 q0 21 14.5 35.5t35.5 14.5h200q21 0 35.5 -14.5t14.5 -35.5zM700 650v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-200q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h200q21 0 35.5 -14.5t14.5 -35.5zM1100 650v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-200 q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h200q21 0 35.5 -14.5t14.5 -35.5zM300 250v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-200q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h200q21 0 35.5 -14.5t14.5 -35.5zM700 250v-200 q0 -21 -14.5 -35.5t-35.5 -14.5h-200q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h200q21 0 35.5 -14.5t14.5 -35.5zM1100 250v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-200q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h200q21 0 35.5 -14.5 t14.5 -35.5z" />
|
||||
<glyph unicode="" d="M300 1050v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-200q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h200q21 0 35.5 -14.5t14.5 -35.5zM1200 1050v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-700q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h700 q21 0 35.5 -14.5t14.5 -35.5zM300 450v200q0 21 -14.5 35.5t-35.5 14.5h-200q-21 0 -35.5 -14.5t-14.5 -35.5v-200q0 -21 14.5 -35.5t35.5 -14.5h200q21 0 35.5 14.5t14.5 35.5zM1200 650v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-700q-21 0 -35.5 14.5t-14.5 35.5v200 q0 21 14.5 35.5t35.5 14.5h700q21 0 35.5 -14.5t14.5 -35.5zM300 250v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-200q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h200q21 0 35.5 -14.5t14.5 -35.5zM1200 250v-200q0 -21 -14.5 -35.5t-35.5 -14.5h-700 q-21 0 -35.5 14.5t-14.5 35.5v200q0 21 14.5 35.5t35.5 14.5h700q21 0 35.5 -14.5t14.5 -35.5z" />
|
||||
<glyph unicode="" d="M448 34l818 820l-212 212l-607 -607l-206 207l-212 -212z" />
|
||||
<glyph unicode="" d="M882 106l-282 282l-282 -282l-212 212l282 282l-282 282l212 212l282 -282l282 282l212 -212l-282 -282l282 -282z" />
|
||||
<glyph unicode="" d="M913 432l300 -300q7 -8 7 -18t-7 -18l-109 -109q-8 -7 -18 -7t-18 7l-300 300q-119 -78 -261 -78q-200 0 -342 142t-142 342t142 342t342 142t342 -142t142 -342q0 -142 -78 -261zM507 363q137 0 233.5 96.5t96.5 233.5t-96.5 233.5t-233.5 96.5t-234 -97t-97 -233 t97 -233t234 -97zM600 800h100v-200h-100v-100h-200v100h-100v200h100v100h200v-100z" />
|
||||
<glyph unicode="" d="M913 432l300 -299q7 -7 7 -18t-7 -18l-109 -109q-8 -8 -18 -8t-18 8l-300 299q-120 -77 -261 -77q-200 0 -342 142t-142 342t142 342t342 142t342 -142t142 -342q0 -141 -78 -262zM176 694q0 -136 97 -233t234 -97t233.5 97t96.5 233t-96.5 233t-233.5 97t-234 -97 t-97 -233zM300 801v-200h400v200h-400z" />
|
||||
<glyph unicode="" d="M700 750v400q0 21 -14.5 35.5t-35.5 14.5h-100q-21 0 -35.5 -14.5t-14.5 -35.5v-400q0 -21 14.5 -35.5t35.5 -14.5h100q21 0 35.5 14.5t14.5 35.5zM800 975v166q167 -62 272 -210t105 -331q0 -118 -45.5 -224.5t-123 -184t-184 -123t-224.5 -45.5t-224.5 45.5t-184 123 t-123 184t-45.5 224.5q0 183 105 331t272 210v-166q-103 -55 -165 -155t-62 -220q0 -177 125 -302t302 -125t302 125t125 302q0 120 -62 220t-165 155z" />
|
||||
<glyph unicode="" d="M1200 1h-200v1200h200v-1200zM900 1h-200v800h200v-800zM600 1h-200v500h200v-500zM300 301h-200v-300h200v300z" />
|
||||
<glyph unicode="" d="M488 183l38 -151q40 -5 74 -5q27 0 74 5l38 151l6 2q46 13 93 39l5 3l134 -81q56 44 104 105l-80 134l3 5q24 44 39 93l1 6l152 38q5 40 5 74q0 28 -5 73l-152 38l-1 6q-16 51 -39 93l-3 5l80 134q-44 58 -104 105l-134 -81l-5 3q-45 25 -93 39l-6 1l-38 152q-40 5 -74 5 q-27 0 -74 -5l-38 -152l-5 -1q-50 -14 -94 -39l-5 -3l-133 81q-59 -47 -105 -105l80 -134l-3 -5q-25 -47 -38 -93l-2 -6l-151 -38q-6 -48 -6 -73q0 -33 6 -74l151 -38l2 -6q14 -49 38 -93l3 -5l-80 -134q45 -59 105 -105l133 81l5 -3q45 -26 94 -39zM600 815q89 0 152 -63 t63 -151q0 -89 -63 -152t-152 -63t-152 63t-63 152q0 88 63 151t152 63z" />
|
||||
<glyph unicode="" d="M900 1100h275q10 0 17.5 -7.5t7.5 -17.5v-50q0 -11 -7 -18t-18 -7h-1050q-11 0 -18 7t-7 18v50q0 10 7.5 17.5t17.5 7.5h275v100q0 41 29.5 70.5t70.5 29.5h300q41 0 70.5 -29.5t29.5 -70.5v-100zM800 1100v100h-300v-100h300zM200 900h900v-800q0 -41 -29.5 -71 t-70.5 -30h-700q-41 0 -70.5 30t-29.5 71v800zM300 100h100v700h-100v-700zM500 100h100v700h-100v-700zM700 100h100v700h-100v-700zM900 100h100v700h-100v-700z" />
|
||||
<glyph unicode="" d="M1301 601h-200v-600h-300v400h-300v-400h-300v600h-200l656 644z" />
|
||||
<glyph unicode="" d="M600 700h400v-675q0 -11 -7 -18t-18 -7h-850q-11 0 -18 7t-7 18v1150q0 11 7 18t18 7h475v-500zM1000 800h-300v300z" />
|
||||
<glyph unicode="" d="M600 1196q162 0 299 -80t217 -217t80 -299t-80 -299t-217 -217t-299 -80t-299 80t-217 217t-80 299t80 299t217 217t299 80zM600 1014q-171 0 -292.5 -121.5t-121.5 -292.5t121.5 -292.5t292.5 -121.5t292.5 121.5t121.5 292.5t-121.5 292.5t-292.5 121.5zM600 600h200 v-100h-300v400h100v-300z" />
|
||||
<glyph unicode="" d="M721 400h-242l-40 -400h-539l431 1200h209l-21 -300h162l-20 300h208l431 -1200h-538zM712 500l-27 300h-170l-27 -300h224z" />
|
||||
<glyph unicode="" d="M1100 400v-400h-1100v400h490l-290 300h200v500h300v-500h200l-290 -300h490zM988 300h-175v-100h175v100z" />
|
||||
<glyph unicode="" d="M600 1199q122 0 233 -47.5t191 -127.5t127.5 -191t47.5 -233t-47.5 -233t-127.5 -191t-191 -127.5t-233 -47.5t-233 47.5t-191 127.5t-127.5 191t-47.5 233t47.5 233t127.5 191t191 127.5t233 47.5zM600 1012q-170 0 -291 -121t-121 -291t121 -291t291 -121t291 121 t121 291t-121 291t-291 121zM700 600h150l-250 -300l-250 300h150v300h200v-300z" />
|
||||
<glyph unicode="" d="M600 1196q162 0 299 -80t217 -217t80 -299t-80 -299t-217 -217t-299 -80t-299 80t-217 217t-80 299t80 299t217 217t299 80zM600 1014q-171 0 -292.5 -121.5t-121.5 -292.5t121.5 -292.5t292.5 -121.5t292.5 121.5t121.5 292.5t-121.5 292.5t-292.5 121.5zM850 600h-150 v-300h-200v300h-150l250 300z" />
|
||||
<glyph unicode="" d="M0 500l200 700h800q199 -700 200 -700v-475q0 -11 -7 -18t-18 -7h-1150q-11 0 -18 7t-7 18v475zM903 1000h-606l-97 -500h200l50 -200h300l50 200h200z" />
|
||||
<glyph unicode="" d="M600 1196q162 0 299 -80t217 -217t80 -299t-80 -299t-217 -217t-299 -80t-299 80t-217 217t-80 299t80 299t217 217t299 80zM600 1014q-171 0 -292.5 -121.5t-121.5 -292.5q0 -172 121.5 -293t292.5 -121t292.5 121t121.5 293q0 171 -121.5 292.5t-292.5 121.5zM797 598 l-297 -201v401z" />
|
||||
<glyph unicode="" d="M1177 600h-150q0 -177 -125 -302t-302 -125t-302 125t-125 302t125 302t302 125q136 0 246 -81l-146 -146h400v400l-145 -145q-157 122 -355 122q-118 0 -224.5 -45.5t-184 -123t-123 -184t-45.5 -224.5t45.5 -224.5t123 -184t184 -123t224.5 -45.5t224.5 45.5t184 123 t123 184t45.5 224.5z" />
|
||||
<glyph unicode="" d="M700 800l147 147q-112 80 -247 80q-177 0 -302 -125t-125 -302h-150q0 118 45.5 224.5t123 184t184 123t224.5 45.5q198 0 355 -122l145 145v-400h-400zM500 400l-147 -147q112 -80 247 -80q177 0 302 125t125 302h150q0 -118 -45.5 -224.5t-123 -184t-184 -123 t-224.5 -45.5q-198 0 -355 122l-145 -145v400h400z" />
|
||||
<glyph unicode="" d="M100 1200v-1200h1100v1200h-1100zM1100 100h-900v900h900v-900zM400 800h-100v100h100v-100zM1000 800h-500v100h500v-100zM400 600h-100v100h100v-100zM1000 600h-500v100h500v-100zM400 400h-100v100h100v-100zM1000 400h-500v100h500v-100zM400 200h-100v100h100v-100 zM1000 300h-500v-100h500v100z" />
|
||||
<glyph unicode="" d="M200 0h-100v1100h100v-1100zM1100 600v500q-40 -81 -101.5 -115.5t-127.5 -29.5t-138 25t-139.5 40t-125.5 25t-103 -29.5t-65 -115.5v-500q60 60 127.5 84t127.5 17.5t122 -23t119 -30t110 -11t103 42t91 120.5z" />
|
||||
<glyph unicode="" d="M1200 275v300q0 116 -49.5 227t-131 192.5t-192.5 131t-227 49.5t-227 -49.5t-192.5 -131t-131 -192.5t-49.5 -227v-300q0 -11 7 -18t18 -7h50q11 0 18 7t7 18v300q0 127 70.5 231.5t184.5 161.5t245 57t245 -57t184.5 -161.5t70.5 -231.5v-300q0 -11 7 -18t18 -7h50 q11 0 18 7t7 18zM400 480v-460q0 -8 -6 -14t-14 -6h-160q-8 0 -14 6t-6 14v460q0 8 6 14t14 6h160q8 0 14 -6t6 -14zM1000 480v-460q0 -8 -6 -14t-14 -6h-160q-8 0 -14 6t-6 14v460q0 8 6 14t14 6h160q8 0 14 -6t6 -14z" />
|
||||
<glyph unicode="" d="M0 800v-400h300l300 -200v800l-300 -200h-300zM971 600l141 -141l-71 -71l-141 141l-141 -141l-71 71l141 141l-141 141l71 71l141 -141l141 141l71 -71z" />
|
||||
<glyph unicode="" d="M0 800v-400h300l300 -200v800l-300 -200h-300zM700 857l69 53q111 -135 111 -310q0 -169 -106 -302l-67 54q86 110 86 248q0 146 -93 257z" />
|
||||
<glyph unicode="" d="M974 186l6 8q142 178 142 405q0 230 -144 408l-6 8l-83 -64l7 -8q123 -151 123 -344q0 -189 -119 -339l-7 -8zM300 801l300 200v-800l-300 200h-300v400h300zM702 858l69 53q111 -135 111 -310q0 -170 -106 -303l-67 55q86 110 86 248q0 145 -93 257z" />
|
||||
<glyph unicode="" d="M100 700h400v100h100v100h-100v300h-500v-600h100v100zM1200 700v500h-600v-200h100v-300h200v-300h300v200h-200v100h200zM100 1100h300v-300h-300v300zM800 800v300h300v-300h-300zM200 900h100v100h-100v-100zM900 1000h100v-100h-100v100zM300 600h-100v-100h-200 v-500h500v500h-200v100zM900 200v-100h-200v100h-100v100h100v200h-200v100h300v-300h200v-100h-100zM400 400v-300h-300v300h300zM300 200h-100v100h100v-100zM1100 300h100v-100h-100v100zM600 100h100v-100h-100v100zM1200 100v-100h-300v100h300z" />
|
||||
<glyph unicode="" d="M100 1200h-100v-1000h100v1000zM300 200h-100v1000h100v-1000zM700 200h-200v1000h200v-1000zM900 200h-100v1000h100v-1000zM1200 1200v-1000h-200v1000h200zM400 100v-100h-300v100h300zM500 91h100v-91h-100v91zM700 91h100v-91h-100v91zM1100 91v-91h-200v91h200z " />
|
||||
<glyph unicode="" d="M1200 500l-500 -500l-699 700v475q0 10 7.5 17.5t17.5 7.5h474zM320 882q29 29 29 71t-29 71q-30 30 -71.5 30t-71.5 -30q-29 -29 -29 -71t29 -71q30 -30 71.5 -30t71.5 30z" />
|
||||
<glyph unicode="" d="M1201 500l-500 -500l-699 700v475q0 11 7 18t18 7h474zM1501 500l-500 -500l-50 50l450 450l-700 700h100zM320 882q30 29 30 71t-30 71q-29 30 -71 30t-71 -30q-30 -29 -30 -71t30 -71q29 -30 71 -30t71 30z" />
|
||||
<glyph unicode="" d="M1200 1200v-1000l-100 -100v1000h-750l-100 -100h750v-1000h-900v1025l175 175h925z" />
|
||||
<glyph unicode="" d="M947 829l-94 346q-2 11 -10 18t-18 7h-450q-10 0 -18 -7t-10 -18l-94 -346l40 -124h592zM1200 800v-700h-200v200h-800v-200h-200v700h200l100 -200h600l100 200h200zM881 176l38 -152q2 -10 -3.5 -17t-15.5 -7h-600q-10 0 -15.5 7t-3.5 17l38 152q2 10 11.5 17t19.5 7 h500q10 0 19.5 -7t11.5 -17z" />
|
||||
<glyph unicode="" d="M1200 0v66q-34 1 -74 43q-18 19 -33 42t-21 37l-6 13l-385 998h-93l-399 -1006q-24 -48 -52 -75q-12 -12 -33 -25t-36 -20l-15 -7v-66h365v66q-41 0 -72 11t-49 38t1 71l92 234h391l82 -222q16 -45 -5.5 -88.5t-74.5 -43.5v-66h417zM416 521l178 457l46 -140l116 -317 h-340z" />
|
||||
<glyph unicode="" d="M100 1199h471q120 0 213 -88t93 -228q0 -55 -11.5 -101.5t-28 -74t-33.5 -47.5t-28 -28l-12 -7q8 -3 21.5 -9t48 -31.5t60.5 -58t47.5 -91.5t21.5 -129q0 -84 -59 -156.5t-142 -111t-162 -38.5h-500v89q41 7 70.5 32.5t29.5 65.5v827q0 28 -1 39.5t-5.5 26t-15.5 21 t-29 14t-49 14.5v70zM400 1079v-379h139q76 0 130 61.5t54 138.5q0 82 -84 130.5t-239 48.5zM400 200h161q89 0 153 48.5t64 132.5q0 90 -62.5 154.5t-156.5 64.5h-159v-400z" />
|
||||
<glyph unicode="" d="M877 1200l2 -57q-33 -8 -62 -25.5t-46 -37t-29.5 -38t-17.5 -30.5l-5 -12l-128 -825q-10 -52 14 -82t95 -36v-57h-500v57q77 7 134.5 40.5t65.5 80.5l173 849q10 56 -10 74t-91 37q-6 1 -10.5 2.5t-9.5 2.5v57h425z" />
|
||||
<glyph unicode="" d="M1150 1200h150v-300h-50q0 29 -8 48.5t-18.5 30t-33.5 15t-39.5 5.5t-50.5 1h-200v-850l100 -50v-100h-400v100l100 50v850h-200q-34 0 -50.5 -1t-40 -5.5t-33.5 -15t-18.5 -30t-8.5 -48.5h-49v300h150h700zM100 1000v-800h75l-125 -167l-125 167h75v800h-75l125 167 l125 -167h-75z" />
|
||||
<glyph unicode="" d="M950 1201h150v-300h-50q0 29 -8 48.5t-18 30t-33.5 15t-40 5.5t-50.5 1h-200v-650l100 -50v-100h-400v100l100 50v650h-200q-34 0 -50.5 -1t-39.5 -5.5t-33.5 -15t-18.5 -30t-8 -48.5h-50v300h150h700zM200 101h800v75l167 -125l-167 -125v75h-800v-75l-167 125l167 125 v-75z" />
|
||||
<glyph unicode="" d="M700 950v100q0 21 -14.5 35.5t-35.5 14.5h-600q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15h600q21 0 35.5 15t14.5 35zM1100 650v100q0 21 -14.5 35.5t-35.5 14.5h-1000q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15h1000 q21 0 35.5 15t14.5 35zM900 350v100q0 21 -14.5 35.5t-35.5 14.5h-800q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15h800q21 0 35.5 15t14.5 35zM1200 50v100q0 21 -14.5 35.5t-35.5 14.5h-1100q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35 t35.5 -15h1100q21 0 35.5 15t14.5 35z" />
|
||||
<glyph unicode="" d="M1000 950v100q0 21 -14.5 35.5t-35.5 14.5h-700q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15h700q21 0 35.5 15t14.5 35zM1200 650v100q0 21 -14.5 35.5t-35.5 14.5h-1100q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15h1100 q21 0 35.5 15t14.5 35zM1000 350v100q0 21 -14.5 35.5t-35.5 14.5h-700q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15h700q21 0 35.5 15t14.5 35zM1200 50v100q0 21 -14.5 35.5t-35.5 14.5h-1100q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35 t35.5 -15h1100q21 0 35.5 15t14.5 35z" />
|
||||
<glyph unicode="" d="M500 950v100q0 21 14.5 35.5t35.5 14.5h600q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-600q-21 0 -35.5 15t-14.5 35zM100 650v100q0 21 14.5 35.5t35.5 14.5h1000q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-1000q-21 0 -35.5 15 t-14.5 35zM300 350v100q0 21 14.5 35.5t35.5 14.5h800q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-800q-21 0 -35.5 15t-14.5 35zM0 50v100q0 21 14.5 35.5t35.5 14.5h1100q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-1100 q-21 0 -35.5 15t-14.5 35z" />
|
||||
<glyph unicode="" d="M0 950v100q0 21 14.5 35.5t35.5 14.5h1100q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-1100q-21 0 -35.5 15t-14.5 35zM0 650v100q0 21 14.5 35.5t35.5 14.5h1100q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-1100q-21 0 -35.5 15 t-14.5 35zM0 350v100q0 21 14.5 35.5t35.5 14.5h1100q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-1100q-21 0 -35.5 15t-14.5 35zM0 50v100q0 21 14.5 35.5t35.5 14.5h1100q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-1100 q-21 0 -35.5 15t-14.5 35z" />
|
||||
<glyph unicode="" d="M0 950v100q0 21 14.5 35.5t35.5 14.5h100q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-100q-21 0 -35.5 15t-14.5 35zM300 950v100q0 21 14.5 35.5t35.5 14.5h800q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-800q-21 0 -35.5 15 t-14.5 35zM0 650v100q0 21 14.5 35.5t35.5 14.5h100q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-100q-21 0 -35.5 15t-14.5 35zM300 650v100q0 21 14.5 35.5t35.5 14.5h800q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-800 q-21 0 -35.5 15t-14.5 35zM0 350v100q0 21 14.5 35.5t35.5 14.5h100q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-100q-21 0 -35.5 15t-14.5 35zM300 350v100q0 21 14.5 35.5t35.5 14.5h800q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15 h-800q-21 0 -35.5 15t-14.5 35zM0 50v100q0 21 14.5 35.5t35.5 14.5h100q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15h-100q-21 0 -35.5 15t-14.5 35zM300 50v100q0 21 14.5 35.5t35.5 14.5h800q21 0 35.5 -14.5t14.5 -35.5v-100q0 -20 -14.5 -35t-35.5 -15 h-800q-21 0 -35.5 15t-14.5 35z" />
|
||||
<glyph unicode="" d="M400 1100h-100v-1100h100v1100zM700 950v100q0 21 -15 35.5t-35 14.5h-100q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15h100q20 0 35 15t15 35zM1100 650v100q0 21 -15 35.5t-35 14.5h-500q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15 h500q20 0 35 15t15 35zM100 425v75h-201v100h201v75l166 -125zM900 350v100q0 21 -15 35.5t-35 14.5h-300q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15h300q20 0 35 15t15 35zM1200 50v100q0 21 -15 35.5t-35 14.5h-600q-21 0 -35.5 -14.5t-14.5 -35.5 v-100q0 -20 14.5 -35t35.5 -15h600q20 0 35 15t15 35z" />
|
||||
<glyph unicode="" d="M201 950v100q0 21 -15 35.5t-35 14.5h-100q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15h100q20 0 35 15t15 35zM801 1100h100v-1100h-100v1100zM601 650v100q0 21 -15 35.5t-35 14.5h-500q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15 h500q20 0 35 15t15 35zM1101 425v75h200v100h-200v75l-167 -125zM401 350v100q0 21 -15 35.5t-35 14.5h-300q-21 0 -35.5 -14.5t-14.5 -35.5v-100q0 -20 14.5 -35t35.5 -15h300q20 0 35 15t15 35zM701 50v100q0 21 -15 35.5t-35 14.5h-600q-21 0 -35.5 -14.5t-14.5 -35.5 v-100q0 -20 14.5 -35t35.5 -15h600q20 0 35 15t15 35z" />
|
||||
<glyph unicode="" d="M900 925v-650q0 -31 -22 -53t-53 -22h-750q-31 0 -53 22t-22 53v650q0 31 22 53t53 22h750q31 0 53 -22t22 -53zM1200 300l-300 300l300 300v-600z" />
|
||||
<glyph unicode="" d="M1200 1056v-1012q0 -18 -12.5 -31t-31.5 -13h-1112q-18 0 -31 13t-13 31v1012q0 18 13 31t31 13h1112q19 0 31.5 -13t12.5 -31zM1100 1000h-1000v-737l247 182l298 -131l-74 156l293 318l236 -288v500zM476 750q0 -56 -39 -95t-95 -39t-95 39t-39 95t39 95t95 39t95 -39 t39 -95z" />
|
||||
<glyph unicode="" d="M600 1213q123 0 227 -63t164.5 -169.5t60.5 -229.5t-73 -272q-73 -114 -166.5 -237t-150.5 -189l-57 -66q-10 9 -27 26t-66.5 70.5t-96 109t-104 135.5t-100.5 155q-63 139 -63 262q0 124 60.5 231.5t165 172t226.5 64.5zM599 514q107 0 182.5 75.5t75.5 182.5t-75.5 182 t-182.5 75t-182 -75.5t-75 -181.5q0 -107 75.5 -182.5t181.5 -75.5z" />
|
||||
<glyph unicode="" d="M600 1199q122 0 233 -47.5t191 -127.5t127.5 -191t47.5 -233t-47.5 -233t-127.5 -191t-191 -127.5t-233 -47.5t-233 47.5t-191 127.5t-127.5 191t-47.5 233t47.5 233t127.5 191t191 127.5t233 47.5zM600 173v854q-176 0 -301.5 -125t-125.5 -302t125.5 -302t301.5 -125z " />
|
||||
<glyph unicode="" d="M554 1295q21 -71 57.5 -142.5t76 -130.5t83 -118.5t82 -117t70 -116t50 -125.5t18.5 -136q0 -89 -39 -165.5t-102 -126.5t-140 -79.5t-156 -33.5q-114 6 -211.5 53t-161.5 138.5t-64 210.5q0 94 34 186t88.5 172.5t112 159t115 177t87.5 194.5zM455 296q-7 6 -18 17 t-34 48t-33 77q-15 73 -14 143.5t10 122.5l9 51q-92 -110 -119.5 -185t-12.5 -156q14 -82 59.5 -136t136.5 -80z" />
|
||||
<glyph unicode="" d="M1108 902l113 113l-21 85l-92 28l-113 -113zM1100 625v-225q0 -165 -117.5 -282.5t-282.5 -117.5h-300q-165 0 -282.5 117.5t-117.5 282.5v300q0 165 117.5 282.5t282.5 117.5q366 -6 397 -14l-186 -186h-311q-41 0 -70.5 -29.5t-29.5 -70.5v-500q0 -41 29.5 -70.5 t70.5 -29.5h500q41 0 70.5 29.5t29.5 70.5v125zM436 341l161 50l412 412l-114 113l-405 -405z" />
|
||||
<glyph unicode="" d="M1100 453v-53q0 -165 -117.5 -282.5t-282.5 -117.5h-300q-165 0 -282.5 117.5t-117.5 282.5v300q0 165 117.5 282.5t282.5 117.5h261l2 -80q-133 -32 -218 -120h-145q-41 0 -70.5 -29.5t-29.5 -70.5v-500q0 -41 29.5 -70.5t70.5 -29.5h500q41 0 70.5 29.5t29.5 70.5z M813 431l360 324l-359 318v-216q-7 0 -19 -1t-48 -8t-69.5 -18.5t-76.5 -37t-76.5 -59t-62 -88t-39.5 -121.5q30 38 81.5 64t103 35.5t99 14t77.5 3.5l29 -1v-209z" />
|
||||
<glyph unicode="" d="M1100 569v-169q0 -165 -117.5 -282.5t-282.5 -117.5h-300q-165 0 -282.5 117.5t-117.5 282.5v300q0 165 117.5 282.5t282.5 117.5h300q60 0 127 -23l-178 -177h-349q-41 0 -70.5 -29.5t-29.5 -70.5v-500q0 -41 29.5 -70.5t70.5 -29.5h500q41 0 70.5 29.5t29.5 70.5v69z M625 348l566 567l-136 137l-430 -431l-147 147l-136 -136z" />
|
||||
<glyph unicode="" d="M900 303v198h-200v-200h195l-295 -300l-300 300h200v200h-200v-198l-300 300l300 296v-198h200v200h-200l300 300l295 -300h-195v-200h200v198l300 -296z" />
|
||||
<glyph unicode="" d="M900 0l-500 488v-438q0 -21 -14.5 -35.5t-35.5 -14.5h-100q-21 0 -35.5 14.5t-14.5 35.5v1000q0 21 14.5 35.5t35.5 14.5h100q21 0 35.5 -14.5t14.5 -35.5v-437l500 487v-1100z" />
|
||||
<glyph unicode="" d="M1200 0l-500 488v-488l-500 488v-438q0 -21 -14.5 -35.5t-35.5 -14.5h-100q-21 0 -35.5 14.5t-14.5 35.5v1000q0 21 14.5 35.5t35.5 14.5h100q21 0 35.5 -14.5t14.5 -35.5v-437l500 487v-487l500 487v-1100z" />
|
||||
<glyph unicode="" d="M1200 0l-500 488v-488l-564 550l564 550v-487l500 487v-1100z" />
|
||||
<glyph unicode="" d="M1100 550l-900 550v-1100z" />
|
||||
<glyph unicode="" d="M500 150v800q0 21 -14.5 35.5t-35.5 14.5h-200q-21 0 -35.5 -14.5t-14.5 -35.5v-800q0 -21 14.5 -35.5t35.5 -14.5h200q21 0 35.5 14.5t14.5 35.5zM900 150v800q0 21 -14.5 35.5t-35.5 14.5h-200q-21 0 -35.5 -14.5t-14.5 -35.5v-800q0 -21 14.5 -35.5t35.5 -14.5h200 q21 0 35.5 14.5t14.5 35.5z" />
|
||||
<glyph unicode="" d="M1100 150v800q0 21 -14.5 35.5t-35.5 14.5h-800q-21 0 -35.5 -14.5t-14.5 -35.5v-800q0 -20 14.5 -35t35.5 -15h800q21 0 35.5 15t14.5 35z" />
|
||||
<glyph unicode="" d="M500 0v488l-500 -488v1100l500 -487v487l564 -550z" />
|
||||
<glyph unicode="" d="M1050 1100h100q21 0 35.5 -14.5t14.5 -35.5v-1000q0 -21 -14.5 -35.5t-35.5 -14.5h-100q-21 0 -35.5 14.5t-14.5 35.5v438l-500 -488v488l-500 -488v1100l500 -487v487l500 -487v437q0 21 14.5 35.5t35.5 14.5z" />
|
||||
<glyph unicode="" d="M850 1100h100q21 0 35.5 -14.5t14.5 -35.5v-1000q0 -21 -14.5 -35.5t-35.5 -14.5h-100q-21 0 -35.5 14.5t-14.5 35.5v438l-500 -488v1100l500 -487v437q0 21 14.5 35.5t35.5 14.5z" />
|
||||
<glyph unicode="" d="M650 1064l-550 -564h1100zM1200 350v-100q0 -21 -14.5 -35.5t-35.5 -14.5h-1000q-21 0 -35.5 14.5t-14.5 35.5v100q0 21 14.5 35.5t35.5 14.5h1000q21 0 35.5 -14.5t14.5 -35.5z" />
|
||||
<glyph unicode="" d="M777 7l240 240l-353 353l353 353l-240 240l-592 -594z" />
|
||||
<glyph unicode="" d="M513 -46l-241 240l353 353l-353 353l241 240l572 -571l21 -22l-1 -1v-1z" />
|
||||
<glyph unicode="" d="M600 1197q162 0 299.5 -80t217.5 -217.5t80 -299.5t-80 -299.5t-217.5 -217.5t-299.5 -80t-299.5 80t-217.5 217.5t-80 299.5t80 299.5t217.5 217.5t299.5 80zM500 900v-200h-200v-200h200v-200h200v200h200v200h-200v200h-200z" />
|
||||
<glyph unicode="" d="M600 1197q162 0 299.5 -80t217.5 -217.5t80 -299.5t-80 -299.5t-217.5 -217.5t-299.5 -80t-299.5 80t-217.5 217.5t-80 299.5t80 299.5t217.5 217.5t299.5 80zM300 700v-200h600v200h-600z" />
|
||||
<glyph unicode="" d="M600 1197q162 0 299.5 -80t217.5 -217.5t80 -299.5t-80 -299.5t-217.5 -217.5t-299.5 -80t-299.5 80t-217.5 217.5t-80 299.5t80 299.5t217.5 217.5t299.5 80zM247 741l141 -141l-142 -141l213 -213l141 142l141 -142l213 213l-142 141l142 141l-213 212l-141 -141 l-141 142z" />
|
||||
<glyph unicode="" d="M600 1197q162 0 299.5 -80t217.5 -217.5t80 -299.5t-80 -299.5t-217.5 -217.5t-299.5 -80t-299.5 80t-217.5 217.5t-80 299.5t80 299.5t217.5 217.5t299.5 80zM546 623l-102 102l-174 -174l276 -277l411 411l-175 174z" />
|
||||
<glyph unicode="" d="M600 1197q162 0 299.5 -80t217.5 -217.5t80 -299.5t-80 -299.5t-217.5 -217.5t-299.5 -80t-299.5 80t-217.5 217.5t-80 299.5t80 299.5t217.5 217.5t299.5 80zM500 500h200q5 3 14 8t31.5 25.5t39.5 45.5t31 69t14 94q0 51 -17.5 89t-42 58t-58.5 32t-58.5 15t-51.5 3 q-105 0 -172 -56t-67 -183h144q4 0 11.5 -1t11 -1t6.5 3t3 9t1 11t3.5 8.5t3.5 6t5.5 4t6.5 2.5t9 1.5t9 0.5h11.5h12.5q19 0 30 -10t11 -26q0 -22 -4 -28t-27 -22q-5 -1 -12.5 -3t-27 -13.5t-34 -27t-26.5 -46t-11 -68.5zM500 400v-100h200v100h-200z" />
|
||||
<glyph unicode="" d="M600 1197q162 0 299.5 -80t217.5 -217.5t80 -299.5t-80 -299.5t-217.5 -217.5t-299.5 -80t-299.5 80t-217.5 217.5t-80 299.5t80 299.5t217.5 217.5t299.5 80zM500 900v-100h200v100h-200zM400 700v-100h100v-200h-100v-100h400v100h-100v300h-300z" />
|
||||
<glyph unicode="" d="M1200 700v-200h-203q-25 -102 -116.5 -186t-180.5 -117v-197h-200v197q-140 27 -208 102.5t-98 200.5h-194v200h194q15 60 36 104.5t55.5 86t88 69t126.5 40.5v200h200v-200q54 -20 113 -60t112.5 -105.5t71.5 -134.5h203zM700 500v-206q149 48 201 206h-201v200h200 q-25 74 -76 127.5t-124 76.5v-204h-200v203q-75 -24 -130 -77.5t-79 -125.5h209v-200h-210q24 -73 79.5 -127.5t130.5 -78.5v206h200z" />
|
||||
<glyph unicode="" d="M600 1196q162 0 299 -80t217 -217t80 -299t-80 -299t-217 -217t-299 -80t-299 80t-217 217t-80 299t80 299t217 217t299 80zM600 1014q-171 0 -292.5 -121.5t-121.5 -292.5t121.5 -292.5t292.5 -121.5t292.5 121.5t121.5 292.5t-121.5 292.5t-292.5 121.5zM844 735 l-135 -135l135 -135l-109 -109l-135 135l-135 -135l-109 109l135 135l-135 135l109 109l135 -135l135 135z" />
|
||||
<glyph unicode="" d="M600 1196q162 0 299 -80t217 -217t80 -299t-80 -299t-217 -217t-299 -80t-299 80t-217 217t-80 299t80 299t217 217t299 80zM600 1014q-171 0 -292.5 -121.5t-121.5 -292.5t121.5 -292.5t292.5 -121.5t292.5 121.5t121.5 292.5t-121.5 292.5t-292.5 121.5zM896 654 l-346 -345l-228 228l141 141l87 -87l204 205z" />
|
||||
<glyph unicode="" d="M600 1196q162 0 299 -80t217 -217t80 -299t-80 -299t-217 -217t-299 -80t-299 80t-217 217t-80 299t80 299t217 217t299 80zM248 385l568 567q-100 62 -216 62q-171 0 -292.5 -121.5t-121.5 -292.5q0 -115 62 -215zM955 809l-564 -564q97 -59 209 -59q171 0 292.5 121.5 t121.5 292.5q0 112 -59 209z" />
|
||||
<glyph unicode="" d="M1200 400h-600v-301l-600 448l600 453v-300h600v-300z" />
|
||||
<glyph unicode="" d="M600 400h-600v300h600v300l600 -453l-600 -448v301z" />
|
||||
<glyph unicode="" d="M1098 600h-298v-600h-300v600h-296l450 600z" />
|
||||
<glyph unicode="" d="M998 600l-449 -600l-445 600h296v600h300v-600h298z" />
|
||||
<glyph unicode="" d="M600 199v301q-95 -2 -183 -20t-170 -52t-147 -92.5t-100 -135.5q6 132 41 238.5t103.5 193t184 138t271.5 59.5v271l600 -453z" />
|
||||
<glyph unicode="" d="M1200 1200h-400l129 -129l-294 -294l142 -142l294 294l129 -129v400zM565 423l-294 -294l129 -129h-400v400l129 -129l294 294z" />
|
||||
<glyph unicode="" d="M871 730l129 -130h-400v400l129 -129l295 295l142 -141zM200 600h400v-400l-129 130l-295 -295l-142 141l295 295z" />
|
||||
<glyph unicode="" d="M600 1177q118 0 224.5 -45.5t184 -123t123 -184t45.5 -224.5t-45.5 -224.5t-123 -184t-184 -123t-224.5 -45.5t-224.5 45.5t-184 123t-123 184t-45.5 224.5t45.5 224.5t123 184t184 123t224.5 45.5zM686 549l58 302q4 20 -8 34.5t-33 14.5h-207q-20 0 -32 -14.5t-8 -34.5 l58 -302q4 -20 21.5 -34.5t37.5 -14.5h54q20 0 37.5 14.5t21.5 34.5zM700 400h-200v-100h200v100z" />
|
||||
<glyph unicode="" d="M1200 900h-111v6t-1 15t-3 18l-34 172q-11 39 -41.5 63t-69.5 24q-32 0 -61 -17l-239 -144q-22 -13 -40 -35q-19 24 -40 36l-238 144q-33 18 -62 18q-39 0 -69.5 -23t-40.5 -61l-35 -177q-2 -8 -3 -18t-1 -15v-6h-111v-100h100v-200h400v300h200v-300h400v200h100v100z M731 900l202 197q5 -12 12 -32.5t23 -64t25 -72t7 -28.5h-269zM481 900h-281q-3 0 14 48t35 96l18 47zM100 0h400v400h-400v-400zM700 400h400v-400h-400v400z" />
|
||||
<glyph unicode="" d="M0 121l216 193q-9 53 -13 83t-5.5 94t9 113t38.5 114t74 124q47 60 99.5 102.5t103 68t127.5 48t145.5 37.5t184.5 43.5t220 58.5q0 -189 -22 -343t-59 -258t-89 -181.5t-108.5 -120t-122 -68t-125.5 -30t-121.5 -1.5t-107.5 12.5t-87.5 17t-56.5 7.5l-99 -55l-201 -202 v143zM692 611q70 38 118.5 69.5t102 79t99 111.5t86.5 148q22 50 24 60t-6 19q-7 5 -17 5t-26.5 -14.5t-33.5 -39.5q-35 -51 -113.5 -108.5t-139.5 -89.5l-61 -32q-369 -197 -458 -401q-48 -111 -28.5 -117.5t86.5 76.5q55 66 367 234z" />
|
||||
<glyph unicode="" d="M1261 600l-26 -40q-6 -10 -20 -30t-49 -63.5t-74.5 -85.5t-97 -90t-116.5 -83.5t-132.5 -59t-145.5 -23.5t-145.5 23.5t-132.5 59t-116.5 83.5t-97 90t-74.5 85.5t-49 63.5t-20 30l-26 40l26 40q6 10 20 30t49 63.5t74.5 85.5t97 90t116.5 83.5t132.5 59t145.5 23.5 t145.5 -23.5t132.5 -59t116.5 -83.5t97 -90t74.5 -85.5t49 -63.5t20 -30zM600 240q64 0 123.5 20t100.5 45.5t85.5 71.5t66.5 75.5t58 81.5t47 66q-1 1 -28.5 37.5t-42 55t-43.5 53t-57.5 63.5t-58.5 54q49 -74 49 -163q0 -124 -88 -212t-212 -88t-212 88t-88 212 q0 85 46 158q-102 -87 -226 -258q7 -10 40.5 -58t56 -78.5t68 -77.5t87.5 -75t103 -49.5t125 -21.5zM484 762l-107 -106q49 -124 154 -191l105 105q-37 24 -75 72t-57 84z" />
|
||||
<glyph unicode="" d="M906 1200l-314 -1200h-148l37 143q-82 21 -165 71.5t-140 102t-109.5 112t-72 88.5t-29.5 43l-26 40l26 40q6 10 20 30t49 63.5t74.5 85.5t97 90t116.5 83.5t132.5 59t145.5 23.5q61 0 121 -17l37 142h148zM1261 600l-26 -40q-7 -12 -25.5 -38t-63.5 -79.5t-95.5 -102.5 t-124 -100t-146.5 -79l38 145q22 15 44.5 34t46 44t40.5 44t41 50.5t33.5 43.5t33 44t24.5 34q-97 127 -140 175l39 146q67 -54 131.5 -125.5t87.5 -103.5t36 -52zM513 264l37 141q-107 18 -178.5 101.5t-71.5 193.5q0 85 46 158q-102 -87 -226 -258q210 -282 393 -336z M484 762l-107 -106q49 -124 154 -191l47 47l23 87q-30 28 -59 69t-44 68z" />
|
||||
<glyph unicode="" d="M-47 0h1294q37 0 50.5 35.5t-7.5 67.5l-642 1056q-20 33 -48 36t-48 -29l-642 -1066q-21 -32 -7.5 -66t50.5 -34zM700 200v100h-200v-100h-345l445 723l445 -723h-345zM700 700h-200v-100l100 -300l100 300v100z" />
|
||||
<glyph unicode="" d="M800 711l363 -325q15 -14 26 -38.5t11 -44.5v-41q0 -20 -12 -26.5t-29 5.5l-359 249v-263q100 -91 100 -113v-64q0 -21 -13 -29t-32 1l-94 78h-222l-94 -78q-19 -9 -32 -1t-13 29v64q0 22 100 113v263l-359 -249q-17 -12 -29 -5.5t-12 26.5v41q0 20 11 44.5t26 38.5 l363 325v339q0 62 44 106t106 44t106 -44t44 -106v-339z" />
|
||||
<glyph unicode="" d="M941 800l-600 -600h-341v200h259l600 600h241v198l300 -295l-300 -300v197h-159zM381 678l141 142l-181 180h-341v-200h259zM1100 598l300 -295l-300 -300v197h-241l-181 181l141 142l122 -123h159v198z" />
|
||||
<glyph unicode="" d="M100 1100h1000q41 0 70.5 -29.5t29.5 -70.5v-600q0 -41 -29.5 -70.5t-70.5 -29.5h-596l-304 -300v300h-100q-41 0 -70.5 29.5t-29.5 70.5v600q0 41 29.5 70.5t70.5 29.5z" />
|
||||
<glyph unicode="" d="M400 900h-300v300h300v-300zM1100 900h-300v300h300v-300zM1100 800v-200q0 -42 -3 -83t-15 -104t-31.5 -116t-58 -109.5t-89 -96.5t-129 -65.5t-174.5 -25.5t-174.5 25.5t-129 65.5t-89 96.5t-58 109.5t-31.5 116t-15 104t-3 83v200h300v-250q0 -113 6 -145 q17 -92 102 -117q39 -11 92 -11q37 0 66.5 5.5t50 15.5t36 24t24 31.5t14 37.5t7 42t2.5 45t0 47v25v250h300z" />
|
||||
<glyph unicode="" d="M902 184l226 227l-578 579l-580 -579l227 -227l352 353z" />
|
||||
<glyph unicode="" d="M650 218l578 579l-226 227l-353 -353l-352 353l-227 -227z" />
|
||||
<glyph unicode="" d="M1198 400v600h-796l215 -200h381v-400h-198l299 -283l299 283h-200zM-198 700l299 283l300 -283h-203v-400h385l215 -200h-800v600h-196z" />
|
||||
<glyph unicode="" d="M1050 1200h94q20 0 35 -14.5t15 -35.5t-15 -35.5t-35 -14.5h-54l-201 -961q-2 -4 -6 -10.5t-19 -17.5t-33 -11h-31v-50q0 -20 -14.5 -35t-35.5 -15t-35.5 15t-14.5 35v50h-300v-50q0 -20 -14.5 -35t-35.5 -15t-35.5 15t-14.5 35v50h-50q-21 0 -35.5 15t-14.5 35 q0 21 14.5 35.5t35.5 14.5h535l48 200h-633q-32 0 -54.5 21t-27.5 43l-100 475q-5 24 10 42q14 19 39 19h896l38 162q5 17 18.5 27.5t30.5 10.5z" />
|
||||
<glyph unicode="" d="M1200 1000v-100h-1200v100h200q0 41 29.5 70.5t70.5 29.5h300q41 0 70.5 -29.5t29.5 -70.5h500zM0 800h1200v-800h-1200v800z" />
|
||||
<glyph unicode="" d="M201 800l-200 -400v600h200q0 41 29.5 70.5t70.5 29.5h300q41 0 70.5 -29.5t29.5 -70.5h500v-200h-1000zM1501 700l-300 -700h-1200l300 700h1200z" />
|
||||
<glyph unicode="" d="M302 300h198v600h-198l298 300l298 -300h-198v-600h198l-298 -300z" />
|
||||
<glyph unicode="" d="M900 303v197h-600v-197l-300 297l300 298v-198h600v198l300 -298z" />
|
||||
<glyph unicode="" d="M31 400l172 739q5 22 23 41.5t38 19.5h672q19 0 37.5 -22.5t23.5 -45.5l172 -732h-1138zM100 300h1000q41 0 70.5 -29.5t29.5 -70.5v-100q0 -41 -29.5 -70.5t-70.5 -29.5h-1000q-41 0 -70.5 29.5t-29.5 70.5v100q0 41 29.5 70.5t70.5 29.5zM900 200h-100v-100h100v100z M1100 200h-100v-100h100v100z" />
|
||||
<glyph unicode="" d="M1100 200v850q0 21 14.5 35.5t35.5 14.5q20 0 35 -14.5t15 -35.5v-850q0 -20 -15 -35t-35 -15q-21 0 -35.5 15t-14.5 35zM325 800l675 250v-850l-675 200h-38l47 -276q2 -12 -3 -17.5t-11 -6t-21 -0.5h-8h-83q-20 0 -34.5 14t-18.5 35q-56 337 -56 351v250v5 q0 13 0.5 18.5t2.5 13t8 10.5t15 3h200zM-101 600v50q0 24 25 49t50 38l25 13v-250l-11 5.5t-24 14t-30 21.5t-24 27.5t-11 31.5z" />
|
||||
<glyph unicode="" d="M445 1180l-45 -233l-224 78l78 -225l-233 -44l179 -156l-179 -155l233 -45l-78 -224l224 78l45 -233l155 179l155 -179l45 233l224 -78l-78 224l234 45l-180 155l180 156l-234 44l78 225l-224 -78l-45 233l-155 -180z" />
|
||||
<glyph unicode="" d="M700 1200h-50q-27 0 -51 -20t-38 -48l-96 -198l-145 -196q-20 -26 -20 -63v-400q0 -75 100 -75h61q123 -100 139 -100h250q46 0 83 57l238 344q29 31 29 74v100q0 44 -30.5 84.5t-69.5 40.5h-328q28 118 28 125v150q0 44 -30.5 84.5t-69.5 40.5zM700 925l-50 -225h450 v-125l-250 -375h-214l-136 100h-100v375l150 212l100 213h50v-175zM0 800v-600h200v600h-200z" />
|
||||
<glyph unicode="" d="M700 0h-50q-27 0 -51 20t-38 48l-96 198l-145 196q-20 26 -20 63v400q0 75 100 75h61q123 100 139 100h250q46 0 83 -57l238 -344q29 -31 29 -74v-100q0 -44 -30.5 -84.5t-69.5 -40.5h-328q28 -118 28 -125v-150q0 -44 -30.5 -84.5t-69.5 -40.5zM200 400h-200v600h200 v-600zM700 275l-50 225h450v125l-250 375h-214l-136 -100h-100v-375l150 -212l100 -213h50v175z" />
|
||||
<glyph unicode="" d="M364 873l362 230q14 6 25 6q17 0 29 -12l109 -112q14 -14 14 -34q0 -18 -11 -32l-85 -121h302q85 0 138.5 -38t53.5 -110t-54.5 -111t-138.5 -39h-107l-130 -339q-7 -22 -20.5 -41.5t-28.5 -19.5h-341q-7 0 -90 81t-83 94v525q0 17 14 35.5t28 28.5zM408 792v-503 l100 -89h293l131 339q6 21 19.5 41t28.5 20h203q16 0 25 15t9 36q0 20 -9 34.5t-25 14.5h-457h-6.5h-7.5t-6.5 0.5t-6 1t-5 1.5t-5.5 2.5t-4 4t-4 5.5q-5 12 -5 20q0 14 10 27l147 183l-86 83zM208 200h-200v600h200v-600z" />
|
||||
<glyph unicode="" d="M475 1104l365 -230q7 -4 16.5 -10.5t26 -26t16.5 -36.5v-526q0 -13 -85.5 -93.5t-93.5 -80.5h-342q-15 0 -28.5 20t-19.5 41l-131 339h-106q-84 0 -139 39t-55 111t54 110t139 37h302l-85 121q-11 16 -11 32q0 21 14 34l109 113q13 12 29 12q11 0 25 -6zM370 946 l145 -184q10 -11 10 -26q0 -11 -5 -20q-1 -3 -3.5 -5.5l-4 -4t-5 -2.5t-5.5 -1.5t-6.5 -1t-6.5 -0.5h-7.5h-6.5h-476v-100h222q15 0 28.5 -20.5t19.5 -40.5l131 -339h293l106 89v502l-342 237zM1199 201h-200v600h200v-600z" />
|
||||
<glyph unicode="" d="M1100 473v342q0 15 -20 28.5t-41 19.5l-339 131v106q0 84 -39 139t-111 55t-110 -53.5t-38 -138.5v-302l-121 84q-15 12 -33.5 11.5t-32.5 -13.5l-112 -110q-22 -22 -6 -53l230 -363q4 -6 10.5 -15.5t26 -25t36.5 -15.5h525q13 0 94 83t81 90zM911 400h-503l-236 339 l83 86l183 -146q22 -18 47 -5q3 1 5.5 3.5l4 4t2.5 5t1.5 5.5t1 6.5t0.5 6v7.5v7v456q0 22 25 31t50 -0.5t25 -30.5v-202q0 -16 20 -29.5t41 -19.5l339 -130v-294zM1000 200v-200h-600v200h600z" />
|
||||
<glyph unicode="" d="M305 1104v200h600v-200h-600zM605 310l339 131q20 6 40.5 19.5t20.5 28.5v342q0 7 -81 90t-94 83h-525q-17 0 -35.5 -14t-28.5 -28l-10 -15l-230 -362q-15 -31 7 -53l112 -110q13 -13 32 -13.5t34 10.5l121 85l-1 -302q0 -84 38.5 -138t110.5 -54t111 55t39 139v106z M905 804v-294l-340 -130q-20 -6 -40 -20t-20 -29v-202q0 -22 -25 -31t-50 0t-25 31v456v14.5t-1.5 11.5t-5 12t-9.5 7q-24 13 -46 -5l-184 -146l-83 86l237 339h503z" />
|
||||
<glyph unicode="" d="M603 1195q162 0 299.5 -80t217.5 -218t80 -300t-80 -299.5t-217.5 -217.5t-299.5 -80t-300 80t-218 217.5t-80 299.5q0 122 47.5 232.5t127.5 190.5t190.5 127.5t232.5 47.5zM598 701h-298v-201h300l-2 -194l402 294l-402 298v-197z" />
|
||||
<glyph unicode="" d="M597 1195q122 0 232.5 -47.5t190.5 -127.5t127.5 -190.5t47.5 -232.5q0 -162 -80 -299.5t-218 -217.5t-300 -80t-299.5 80t-217.5 217.5t-80 299.5q0 122 47.5 232.5t127.5 190.5t190.5 127.5t231.5 47.5zM200 600l400 -294v194h302v201h-300v197z" />
|
||||
<glyph unicode="" d="M603 1195q121 0 231.5 -47.5t190.5 -127.5t127.5 -190.5t47.5 -232.5q0 -162 -80 -299.5t-217.5 -217.5t-299.5 -80t-300 80t-218 217.5t-80 299.5q0 122 47.5 232.5t127.5 190.5t190.5 127.5t232.5 47.5zM300 600h200v-300h200v300h200l-300 400z" />
|
||||
<glyph unicode="" d="M603 1195q121 0 231.5 -47.5t190.5 -127.5t127.5 -190.5t47.5 -232.5q0 -162 -80 -299.5t-217.5 -217.5t-299.5 -80t-300 80t-218 217.5t-80 299.5q0 122 47.5 232.5t127.5 190.5t190.5 127.5t232.5 47.5zM500 900v-300h-200l300 -400l300 400h-200v300h-200z" />
|
||||
<glyph unicode="" d="M603 1195q121 0 231.5 -47.5t190.5 -127.5t127.5 -190.5t47.5 -232.5q0 -162 -80 -299.5t-217.5 -217.5t-299.5 -80t-300 80t-218 217.5t-80 299.5q0 122 47.5 232.5t127.5 190.5t190.5 127.5t232.5 47.5zM627 1101q-15 -12 -36.5 -21t-34.5 -12t-44 -8t-39 -6 q-15 -3 -45.5 0.5t-45.5 -2.5q-21 -7 -52 -26.5t-34 -34.5q-3 -11 6.5 -22.5t8.5 -18.5q-3 -34 -27.5 -90.5t-29.5 -79.5q-8 -33 5.5 -92.5t7.5 -87.5q0 -9 17 -44t16 -60q12 0 23 -5.5t23 -15t20 -13.5q24 -12 108 -42q22 -8 53 -31.5t59.5 -38.5t57.5 -11q8 -18 -15 -55 t-20 -57q42 -71 87 -80q0 -6 -3 -15.5t-3.5 -14.5t4.5 -17q102 -2 221 112q30 29 47 47t34.5 49t20.5 62q-14 9 -37 9.5t-36 7.5q-14 7 -49 15t-52 19q-9 0 -39.5 -0.5t-46.5 -1.5t-39 -6.5t-39 -16.5q-50 -35 -66 -12q-4 2 -3.5 25.5t0.5 25.5q-6 13 -26.5 17t-24.5 7 q2 22 -2 41t-16.5 28t-38.5 -20q-23 -25 -42 4q-19 28 -8 58q6 16 22 22q6 -1 26 -1.5t33.5 -4t19.5 -13.5q12 -19 32 -37.5t34 -27.5l14 -8q0 3 9.5 39.5t5.5 57.5q-4 23 14.5 44.5t22.5 31.5q5 14 10 35t8.5 31t15.5 22.5t34 21.5q-6 18 10 37q8 0 23.5 -1.5t24.5 -1.5 t20.5 4.5t20.5 15.5q-10 23 -30.5 42.5t-38 30t-49 26.5t-43.5 23q11 41 1 44q31 -13 58.5 -14.5t39.5 3.5l11 4q6 36 -17 53.5t-64 28.5t-56 23q-19 -3 -37 0zM613 994q0 -18 8 -42.5t16.5 -44t9.5 -23.5q-9 2 -31 5t-36 5t-32 8t-30 14q3 12 16 30t16 25q10 -10 18.5 -10 t14 6t14.5 14.5t16 12.5z" />
|
||||
<glyph unicode="" horiz-adv-x="1220" d="M100 1196h1000q41 0 70.5 -29.5t29.5 -70.5v-100q0 -41 -29.5 -70.5t-70.5 -29.5h-1000q-41 0 -70.5 29.5t-29.5 70.5v100q0 41 29.5 70.5t70.5 29.5zM1100 1096h-200v-100h200v100zM100 796h1000q41 0 70.5 -29.5t29.5 -70.5v-100q0 -41 -29.5 -70.5t-70.5 -29.5h-1000 q-41 0 -70.5 29.5t-29.5 70.5v100q0 41 29.5 70.5t70.5 29.5zM1100 696h-500v-100h500v100zM100 396h1000q41 0 70.5 -29.5t29.5 -70.5v-100q0 -41 -29.5 -70.5t-70.5 -29.5h-1000q-41 0 -70.5 29.5t-29.5 70.5v100q0 41 29.5 70.5t70.5 29.5zM1100 296h-300v-100h300v100z " />
|
||||
<glyph unicode="" d="M1100 1200v-100h-1000v100h1000zM150 1000h900l-350 -500v-300l-200 -200v500z" />
|
||||
<glyph unicode="" d="M329 729l142 142l-200 200l129 129h-400v-400l129 129zM1200 1200v-400l-129 129l-200 -200l-142 142l200 200l-129 129h400zM271 129l129 -129h-400v400l129 -129l200 200l142 -142zM1071 271l129 129v-400h-400l129 129l-200 200l142 142z" />
|
||||
<glyph unicode="" d="M596 1192q162 0 299 -80t217 -217t80 -299t-80 -299t-217 -217t-299 -80t-299 80t-217 217t-80 299t80 299t217 217t299 80zM596 1010q-171 0 -292.5 -121.5t-121.5 -292.5q0 -172 121.5 -293t292.5 -121t292.5 121t121.5 293q0 171 -121.5 292.5t-292.5 121.5zM455 905 q22 0 38 -16t16 -39t-16 -39t-38 -16q-23 0 -39 16.5t-16 38.5t16 38.5t39 16.5zM708 821l1 1q-9 14 -9 28q0 22 16 38.5t39 16.5q22 0 38 -16t16 -39t-16 -39t-38 -16q-14 0 -29 10l-55 -145q17 -22 17 -51q0 -36 -25.5 -61.5t-61.5 -25.5t-61.5 25.5t-25.5 61.5 q0 32 20.5 56.5t51.5 29.5zM855 709q23 0 38.5 -15.5t15.5 -38.5t-16 -39t-38 -16q-23 0 -39 16t-16 39q0 22 16 38t39 16zM345 709q23 0 39 -16t16 -38q0 -23 -16 -39t-39 -16q-22 0 -38 16t-16 39t15.5 38.5t38.5 15.5z" />
|
||||
<glyph unicode="" d="M649 54l-16 22q-90 125 -293 323q-71 70 -104.5 105.5t-77 89.5t-61 99t-17.5 91q0 131 98.5 229.5t230.5 98.5q143 0 241 -129q103 129 246 129q129 0 226 -98.5t97 -229.5q0 -46 -17.5 -91t-61 -99t-77 -89.5t-104.5 -105.5q-203 -198 -293 -323zM844 524l12 12 q64 62 97.5 97t64.5 79t31 72q0 71 -48 119t-105 48q-74 0 -132 -82l-118 -171l-114 174q-51 79 -123 79q-60 0 -109.5 -49t-49.5 -118q0 -27 30.5 -70t61.5 -75.5t95 -94.5l22 -22q93 -90 190 -201q82 92 195 203z" />
|
||||
<glyph unicode="" d="M476 406l19 -17l105 105l-212 212l389 389l247 -247l-95 -96l18 -18q46 -46 77 -99l29 29q35 35 62.5 88t27.5 96q0 93 -66 159l-141 141q-66 66 -159 66q-95 0 -159 -66l-283 -283q-66 -64 -66 -159q0 -93 66 -159zM123 193l141 -141q66 -66 159 -66q95 0 159 66 l283 283q66 66 66 159t-66 159l-141 141q-12 12 -19 17l-105 -105l212 -212l-389 -389l-247 248l95 95l-18 18q-46 45 -75 101l-55 -55q-66 -66 -66 -159q0 -94 66 -160z" />
|
||||
<glyph unicode="" d="M200 100v953q0 21 30 46t81 48t129 38t163 15t162 -15t127 -38t79 -48t29 -46v-953q0 -41 -29.5 -70.5t-70.5 -29.5h-600q-41 0 -70.5 29.5t-29.5 70.5zM900 1000h-600v-700h600v700zM600 46q43 0 73.5 30.5t30.5 73.5t-30.5 73.5t-73.5 30.5t-73.5 -30.5t-30.5 -73.5 t30.5 -73.5t73.5 -30.5z" />
|
||||
<glyph unicode="" d="M700 1029v-307l64 -14q34 -7 64 -16.5t70 -31.5t67.5 -52t47.5 -80.5t20 -112.5q0 -139 -89 -224t-244 -96v-77h-100v78q-152 17 -237 104q-40 40 -52.5 93.5t-15.5 139.5h139q5 -77 48.5 -126.5t117.5 -64.5v335l-27 7q-46 14 -79 26.5t-72 36t-62.5 52t-40 72.5 t-16.5 99q0 92 44 159.5t109 101t144 40.5v78h100v-79q38 -4 72.5 -13.5t75.5 -31.5t71 -53.5t51.5 -84t24.5 -118.5h-159q-8 72 -35 109.5t-101 50.5zM600 755v274q-61 -8 -97.5 -37.5t-36.5 -102.5q0 -29 8 -51t16.5 -34t29.5 -22.5t31 -13.5t38 -10q7 -2 11 -3zM700 548 v-311q170 18 170 151q0 64 -44 99.5t-126 60.5z" />
|
||||
<glyph unicode="" d="M866 300l50 -147q-41 -25 -80.5 -36.5t-59 -13t-61.5 -1.5q-23 0 -128 33t-155 29q-39 -4 -82 -17t-66 -25l-24 -11l-55 145l16.5 11t15.5 10t13.5 9.5t14.5 12t14.5 14t17.5 18.5q48 55 54 126.5t-30 142.5h-221v100h166q-24 49 -44 104q-10 26 -14.5 55.5t-3 72.5 t25 90t68.5 87q97 88 263 88q129 0 230 -89t101 -208h-153q0 52 -34 89.5t-74 51.5t-76 14q-37 0 -79 -14.5t-62 -35.5q-41 -44 -41 -101q0 -11 2.5 -24.5t5.5 -24t9.5 -26.5t10.5 -25t14 -27.5t14 -25.5t15.5 -27t13.5 -24h242v-100h-197q8 -50 -2.5 -115t-31.5 -94 q-41 -59 -99 -113q35 11 84 18t70 7q32 1 102 -16t104 -17q76 0 136 30z" />
|
||||
<glyph unicode="" d="M300 0l298 300h-198v900h-200v-900h-198zM900 1200l298 -300h-198v-900h-200v900h-198z" />
|
||||
<glyph unicode="" d="M400 300h198l-298 -300l-298 300h198v900h200v-900zM1000 1200v-500h-100v100h-100v-100h-100v500h300zM901 1100h-100v-200h100v200zM700 500h300v-200h-99v-100h-100v100h99v100h-200v100zM800 100h200v-100h-300v200h100v-100z" />
|
||||
<glyph unicode="" d="M400 300h198l-298 -300l-298 300h198v900h200v-900zM1000 1200v-200h-99v-100h-100v100h99v100h-200v100h300zM800 800h200v-100h-300v200h100v-100zM700 500h300v-500h-100v100h-100v-100h-100v500zM801 200h100v200h-100v-200z" />
|
||||
<glyph unicode="" d="M300 0l298 300h-198v900h-200v-900h-198zM900 1100h-100v100h200v-500h-100v400zM1100 500v-500h-100v100h-200v400h300zM1001 400h-100v-200h100v200z" />
|
||||
<glyph unicode="" d="M300 0l298 300h-198v900h-200v-900h-198zM1100 1200v-500h-100v100h-200v400h300zM1001 1100h-100v-200h100v200zM900 400h-100v100h200v-500h-100v400z" />
|
||||
<glyph unicode="" d="M300 0l298 300h-198v900h-200v-900h-198zM900 1000h-200v200h200v-200zM1000 700h-300v200h300v-200zM1100 400h-400v200h400v-200zM1200 100h-500v200h500v-200z" />
|
||||
<glyph unicode="" d="M300 0l298 300h-198v900h-200v-900h-198zM1200 1000h-500v200h500v-200zM1100 700h-400v200h400v-200zM1000 400h-300v200h300v-200zM900 100h-200v200h200v-200z" />
|
||||
<glyph unicode="" d="M400 1100h300q162 0 281 -118.5t119 -281.5v-300q0 -165 -118.5 -282.5t-281.5 -117.5h-300q-165 0 -282.5 117.5t-117.5 282.5v300q0 165 117.5 282.5t282.5 117.5zM800 900h-500q-41 0 -70.5 -29.5t-29.5 -70.5v-500q0 -41 29.5 -70.5t70.5 -29.5h500q41 0 70.5 29.5 t29.5 70.5v500q0 41 -29.5 70.5t-70.5 29.5z" />
|
||||
<glyph unicode="" d="M700 0h-300q-163 0 -281.5 117.5t-118.5 282.5v300q0 163 119 281.5t281 118.5h300q165 0 282.5 -117.5t117.5 -282.5v-300q0 -165 -117.5 -282.5t-282.5 -117.5zM800 900h-500q-41 0 -70.5 -29.5t-29.5 -70.5v-500q0 -41 29.5 -70.5t70.5 -29.5h500q41 0 70.5 29.5 t29.5 70.5v500q0 41 -29.5 70.5t-70.5 29.5zM400 800v-500l333 250z" />
|
||||
<glyph unicode="" d="M0 400v300q0 163 117.5 281.5t282.5 118.5h300q163 0 281.5 -119t118.5 -281v-300q0 -165 -117.5 -282.5t-282.5 -117.5h-300q-165 0 -282.5 117.5t-117.5 282.5zM900 300v500q0 41 -29.5 70.5t-70.5 29.5h-500q-41 0 -70.5 -29.5t-29.5 -70.5v-500q0 -41 29.5 -70.5 t70.5 -29.5h500q41 0 70.5 29.5t29.5 70.5zM800 700h-500l250 -333z" />
|
||||
<glyph unicode="" d="M1100 700v-300q0 -162 -118.5 -281t-281.5 -119h-300q-165 0 -282.5 118.5t-117.5 281.5v300q0 165 117.5 282.5t282.5 117.5h300q165 0 282.5 -117.5t117.5 -282.5zM900 300v500q0 41 -29.5 70.5t-70.5 29.5h-500q-41 0 -70.5 -29.5t-29.5 -70.5v-500q0 -41 29.5 -70.5 t70.5 -29.5h500q41 0 70.5 29.5t29.5 70.5zM550 733l-250 -333h500z" />
|
||||
<glyph unicode="" d="M500 1100h400q165 0 282.5 -117.5t117.5 -282.5v-300q0 -165 -117.5 -282.5t-282.5 -117.5h-400v200h500q41 0 70.5 29.5t29.5 70.5v500q0 41 -29.5 70.5t-70.5 29.5h-500v200zM700 550l-400 -350v200h-300v300h300v200z" />
|
||||
<glyph unicode="" d="M403 2l9 -1q13 0 26 16l538 630q15 19 6 36q-8 18 -32 16h-300q1 4 78 219.5t79 227.5q2 17 -6 27l-8 8h-9q-16 0 -25 -15q-4 -5 -98.5 -111.5t-228 -257t-209.5 -238.5q-17 -19 -7 -40q10 -19 32 -19h302q-155 -438 -160 -458q-5 -21 4 -32z" />
|
||||
<glyph unicode="" d="M800 200h-500q-41 0 -70.5 29.5t-29.5 70.5v500q0 41 29.5 70.5t70.5 29.5h500v185q-14 4 -114 7.5t-193 5.5l-93 2q-165 0 -282.5 -117.5t-117.5 -282.5v-300q0 -165 117.5 -282.5t282.5 -117.5h300q47 0 100 15v185zM900 200v200h-300v300h300v200l400 -350z" />
|
||||
<glyph unicode="" d="M1200 700l-149 149l-342 -353l-213 213l353 342l-149 149h500v-500zM1022 571l-122 -123v-148q0 -41 -29.5 -70.5t-70.5 -29.5h-500q-41 0 -70.5 29.5t-29.5 70.5v500q0 41 29.5 70.5t70.5 29.5h156l118 122l-74 78h-100q-165 0 -282.5 -117.5t-117.5 -282.5v-300 q0 -165 117.5 -282.5t282.5 -117.5h300q163 0 281.5 117.5t118.5 282.5v98z" />
|
||||
<glyph unicode="" d="M600 1196q162 0 299 -80t217 -217t80 -299t-80 -299t-217 -217t-299 -80t-299 80t-217 217t-80 299t80 299t217 217t299 80zM600 1014q-171 0 -292.5 -121.5t-121.5 -292.5t121.5 -292.5t292.5 -121.5t292.5 121.5t121.5 292.5t-121.5 292.5t-292.5 121.5zM600 794 q80 0 137 -57t57 -137t-57 -137t-137 -57t-137 57t-57 137t57 137t137 57z" />
|
||||
<glyph unicode="" d="M700 800v400h-300v-400h-300l445 -500l450 500h-295zM25 300h1048q11 0 19 -7.5t8 -17.5v-275h-1100v275q0 11 7 18t18 7zM1000 200h-100v-50h100v50z" />
|
||||
<glyph unicode="" d="M400 700v-300h300v300h295l-445 500l-450 -500h300zM25 300h1048q11 0 19 -7.5t8 -17.5v-275h-1100v275q0 11 7 18t18 7zM1000 200h-100v-50h100v50z" />
|
||||
<glyph unicode="" d="M405 400l596 596l-154 155l-442 -442l-150 151l-155 -155zM25 300h1048q11 0 19 -7.5t8 -17.5v-275h-1100v275q0 11 7 18t18 7zM1000 200h-100v-50h100v50z" />
|
||||
<glyph unicode="" d="M409 1103l-97 97l-212 -212l97 -98zM650 861l-149 149l-212 -212l149 -149l-238 -248h700v699zM25 300h1048q11 0 19 -7.5t8 -17.5v-275h-1100v275q0 11 7 18t18 7zM1000 200h-100v-50h100v50z" />
|
||||
<glyph unicode="" d="M539 950l-149 -149l212 -212l149 148l248 -237v700h-699zM297 709l-97 -97l212 -212l98 97zM25 300h1048q11 0 19 -7.5t8 -17.5v-275h-1100v275q0 11 7 18t18 7zM1000 200h-100v-50h100v50z" />
|
||||
<glyph unicode="" d="M1200 1199v-1079l-475 272l-310 -393v416h-392zM1166 1148l-672 -712v-226z" />
|
||||
<glyph unicode="" d="M1100 1000v-850q0 -21 -15 -35.5t-35 -14.5h-150v400h-700v-400h-150q-21 0 -35.5 14.5t-14.5 35.5v1000q0 20 14.5 35t35.5 15h250v-300h500v300h100zM700 1200h-100v-200h100v200z" />
|
||||
<glyph unicode="" d="M578 500h-378v-400h-150q-21 0 -35.5 14.5t-14.5 35.5v1000q0 20 14.5 35t35.5 15h250v-300h500v300h100l200 -200v-218l-276 -275l-120 120zM700 1200h-100v-200h100v200zM1300 538l-475 -476l-244 244l123 123l120 -120l353 352z" />
|
||||
<glyph unicode="" d="M529 500h-329v-400h-150q-21 0 -35.5 14.5t-14.5 35.5v1000q0 20 14.5 35t35.5 15h250v-300h500v300h100l200 -200v-269l-103 -103l-170 170zM700 1200h-100v-200h100v200zM1167 6l-170 170l-170 -170l-127 127l170 170l-170 170l127 127l170 -170l170 170l127 -128 l-170 -169l170 -170z" />
|
||||
<glyph unicode="" d="M700 500h-500v-400h-150q-21 0 -35.5 14.5t-14.5 35.5v1000q0 20 14.5 35t35.5 15h250v-300h500v300h100l200 -200v-300h-400v-200zM700 1000h-100v200h100v-200zM1000 600h-200v-300h-200l300 -300l300 300h-200v300z" />
|
||||
<glyph unicode="" d="M602 500h-402v-400h-150q-21 0 -35.5 14.5t-14.5 35.5v1000q0 20 14.5 35t35.5 15h250v-300h500v300h100l200 -200v-402l-200 200zM700 1000h-100v200h100v-200zM1000 300h200l-300 300l-300 -300h200v-300h200v300z" />
|
||||
<glyph unicode="" d="M1200 900v150q0 21 -14.5 35.5t-35.5 14.5h-1100q-21 0 -35.5 -14.5t-14.5 -35.5v-150h1200zM0 800v-550q0 -21 14.5 -35.5t35.5 -14.5h1100q21 0 35.5 14.5t14.5 35.5v550h-1200zM100 500h400v-200h-400v200z" />
|
||||
<glyph unicode="" d="M500 1000h400v198l300 -298l-300 -298v198h-400v200zM100 800v200h100v-200h-100zM400 800h-100v200h100v-200zM700 300h-400v-198l-300 298l300 298v-198h400v-200zM800 500h100v-200h-100v200zM1000 500v-200h100v200h-100z" />
|
||||
<glyph unicode="" d="M1200 50v1106q0 31 -18 40.5t-44 -7.5l-276 -117q-25 -16 -43.5 -50.5t-18.5 -65.5v-359q0 -29 10.5 -55.5t25 -43t29 -28.5t25.5 -18l10 -5v-397q0 -21 14.5 -35.5t35.5 -14.5h200q21 0 35.5 14.5t14.5 35.5zM550 1200l50 -100v-400l-100 -203v-447q0 -21 -14.5 -35.5 t-35.5 -14.5h-200q-21 0 -35.5 14.5t-14.5 35.5v447l-100 203v400l50 100l50 -100v-300h100v300l50 100l50 -100v-300h100v300z" />
|
||||
<glyph unicode="" d="M1100 106v888q0 22 25 34.5t50 13.5l25 2v56h-400v-56q75 0 87.5 -6t12.5 -44v-394h-500v394q0 38 12.5 44t87.5 6v56h-400v-56q4 0 11 -0.5t24 -3t30 -7t24 -15t11 -24.5v-888q0 -22 -25 -34.5t-50 -13.5l-25 -2v-56h400v56q-75 0 -87.5 6t-12.5 44v394h500v-394 q0 -38 -12.5 -44t-87.5 -6v-56h400v56q-4 0 -11 0.5t-24 3t-30 7t-24 15t-11 24.5z" />
|
||||
<glyph unicode="" d="M675 1000l-100 100h-375l-100 -100h400l200 -200v-98l295 98h105v200h-425zM500 300v500q0 41 -29.5 70.5t-70.5 29.5h-300q-41 0 -70.5 -29.5t-29.5 -70.5v-500q0 -41 29.5 -70.5t70.5 -29.5h300q41 0 70.5 29.5t29.5 70.5zM100 800h300v-200h-300v200zM700 565l400 133 v-163l-400 -133v163zM100 500h300v-200h-300v200zM805 300l295 98v-298h-425l-100 -100h-375l-100 100h400l200 200h105z" />
|
||||
<glyph unicode="" d="M179 1169l-162 -162q-1 -11 -0.5 -32.5t16 -90t46.5 -140t104 -177.5t175 -208q103 -103 207.5 -176t180 -103.5t137 -47t92.5 -16.5l31 1l163 162q16 17 13 40.5t-22 37.5l-192 136q-19 14 -45 12t-42 -19l-119 -118q-143 103 -267 227q-126 126 -227 268l118 118 q17 17 20 41.5t-11 44.5l-139 194q-14 19 -36.5 22t-40.5 -14z" />
|
||||
<glyph unicode="" d="M1200 712v200q-6 8 -19 20.5t-63 45t-112 57t-171 45t-235 20.5q-92 0 -175 -10.5t-141.5 -27t-108.5 -36.5t-81.5 -40t-53.5 -36.5t-31 -27.5l-9 -10v-200q0 -21 14.5 -33.5t34.5 -8.5l202 33q20 4 34.5 21t14.5 38v146q141 24 300 24t300 -24v-146q0 -21 14.5 -38 t34.5 -21l202 -33q20 -4 34.5 8.5t14.5 33.5zM800 650l365 -303q14 -14 24.5 -39.5t10.5 -45.5v-212q0 -21 -15 -35.5t-35 -14.5h-1100q-21 0 -35.5 14.5t-14.5 35.5v212q0 20 10.5 45.5t24.5 39.5l365 303v50q0 4 1 10.5t12 22.5t30 28.5t60 23t97 10.5t97 -10t60 -23.5 t30 -27.5t12 -24l1 -10v-50z" />
|
||||
<glyph unicode="" d="M175 200h950l-125 150v250l100 100v400h-100v-200h-100v200h-200v-200h-100v200h-200v-200h-100v200h-100v-400l100 -100v-250zM1200 100v-100h-1100v100h1100z" />
|
||||
<glyph unicode="" d="M600 1100h100q41 0 70.5 -29.5t29.5 -70.5v-1000h-300v1000q0 41 29.5 70.5t70.5 29.5zM1000 800h100q41 0 70.5 -29.5t29.5 -70.5v-700h-300v700q0 41 29.5 70.5t70.5 29.5zM400 0v400q0 41 -29.5 70.5t-70.5 29.5h-100q-41 0 -70.5 -29.5t-29.5 -70.5v-400h300z" />
|
||||
<glyph unicode="" d="M1200 800v-500q0 -124 -88 -212t-212 -88h-700q-124 0 -212 88t-88 212v500q0 124 88 212t212 88h700q124 0 212 -88t88 -212zM1000 900h-900v-700h900v700zM200 800v-300h200v-100h-200v-100h300v300h-200v100h200v100h-300zM800 800h-200v-500h200v100h100v300h-100 v100zM800 700v-300h-100v300h100z" />
|
||||
<glyph unicode="" d="M1200 800v-500q0 -124 -88 -212t-212 -88h-700q-124 0 -212 88t-88 212v500q0 124 88 212t212 88h700q124 0 212 -88t88 -212zM1000 900h-900v-700h900v700zM400 600h-100v200h-100v-500h100v200h100v-200h100v500h-100v-200zM800 800h-200v-500h200v100h100v300h-100 v100zM800 700v-300h-100v300h100z" />
|
||||
<glyph unicode="" d="M1200 800v-500q0 -124 -88 -212t-212 -88h-700q-124 0 -212 88t-88 212v500q0 124 88 212t212 88h700q124 0 212 -88t88 -212zM1000 900h-900v-700h900v700zM200 800v-500h300v100h-200v300h200v100h-300zM600 800v-500h300v100h-200v300h200v100h-300z" />
|
||||
<glyph unicode="" d="M1200 800v-500q0 -124 -88 -212t-212 -88h-700q-124 0 -212 88t-88 212v500q0 124 88 212t212 88h700q124 0 212 -88t88 -212zM1000 900h-900v-700h900v700zM500 700l-300 -150l300 -150v300zM600 400l300 150l-300 150v-300z" />
|
||||
<glyph unicode="" d="M1200 800v-500q0 -124 -88 -212t-212 -88h-700q-124 0 -212 88t-88 212v500q0 124 88 212t212 88h700q124 0 212 -88t88 -212zM1000 900h-900v-700h900v700zM900 800v-500h-700v500h700zM300 400h130q41 0 68 42t27 107t-28.5 108t-66.5 43h-130v-300zM800 700h-130 q-38 0 -66.5 -43t-28.5 -108t27 -107t68 -42h130v300z" />
|
||||
<glyph unicode="" d="M1200 800v-500q0 -124 -88 -212t-212 -88h-700q-124 0 -212 88t-88 212v500q0 124 88 212t212 88h700q124 0 212 -88t88 -212zM1000 900h-900v-700h900v700zM200 800v-300h200v-100h-200v-100h300v300h-200v100h200v100h-300zM800 300h100v500h-200v-100h100v-400z M601 300h100v100h-100v-100z" />
|
||||
<glyph unicode="" d="M1200 800v-500q0 -124 -88 -212t-212 -88h-700q-124 0 -212 88t-88 212v500q0 124 88 212t212 88h700q124 0 212 -88t88 -212zM1000 900h-900v-700h900v700zM300 700v100h-100v-500h300v400h-200zM800 300h100v500h-200v-100h100v-400zM401 400h-100v200h100v-200z M601 300h100v100h-100v-100z" />
|
||||
<glyph unicode="" d="M200 1100h700q124 0 212 -88t88 -212v-500q0 -124 -88 -212t-212 -88h-700q-124 0 -212 88t-88 212v500q0 124 88 212t212 88zM1000 900h-900v-700h900v700zM400 700h-200v100h300v-300h-99v-100h-100v100h99v200zM800 700h-100v100h200v-500h-100v400zM201 400h100v-100 h-100v100zM701 300h-100v100h100v-100z" />
|
||||
<glyph unicode="" d="M600 1196q162 0 299 -80t217 -217t80 -299t-80 -299t-217 -217t-299 -80t-299 80t-217 217t-80 299t80 299t217 217t299 80zM600 1014q-171 0 -292.5 -121.5t-121.5 -292.5t121.5 -292.5t292.5 -121.5t292.5 121.5t121.5 292.5t-121.5 292.5t-292.5 121.5zM800 700h-300 v-200h300v-100h-300l-100 100v200l100 100h300v-100z" />
|
||||
<glyph unicode="" d="M596 1196q162 0 299 -80t217 -217t80 -299t-80 -299t-217 -217t-299 -80t-299 80t-217 217t-80 299t80 299t217 217t299 80zM596 1014q-171 0 -292.5 -121.5t-121.5 -292.5t121.5 -292.5t292.5 -121.5t292.5 121.5t121.5 292.5t-121.5 292.5t-292.5 121.5zM800 700v-100 h-100v100h-200v-100h200v-100h-200v-100h-100v400h300zM800 400h-100v100h100v-100z" />
|
||||
<glyph unicode="" d="M800 300h128q120 0 205 86t85 208q0 120 -85 206.5t-205 86.5q-46 0 -90 -14q-44 97 -134.5 156.5t-200.5 59.5q-152 0 -260 -107.5t-108 -260.5q0 -25 2 -37q-66 -14 -108.5 -67.5t-42.5 -122.5q0 -80 56.5 -137t135.5 -57h222v300h400v-300zM700 200h200l-300 -300 l-300 300h200v300h200v-300z" />
|
||||
<glyph unicode="" d="M600 714l403 -403q94 26 154.5 104t60.5 178q0 121 -85 207.5t-205 86.5q-46 0 -90 -14q-44 97 -134.5 156.5t-200.5 59.5q-152 0 -260 -107.5t-108 -260.5q0 -25 2 -37q-66 -14 -108.5 -67.5t-42.5 -122.5q0 -80 56.5 -137t135.5 -57h8zM700 -100h-200v300h-200l300 300 l300 -300h-200v-300z" />
|
||||
<glyph unicode="" d="M700 200h400l-270 300h170l-270 300h170l-300 333l-300 -333h170l-270 -300h170l-270 -300h400v-155l-75 -45h350l-75 45v155z" />
|
||||
<glyph unicode="" d="M700 45v306q46 -30 100 -30q74 0 126.5 52.5t52.5 126.5q0 24 -9 55q50 32 79.5 83t29.5 112q0 90 -61.5 155.5t-150.5 71.5q-26 89 -99.5 145.5t-167.5 56.5q-116 0 -197.5 -81.5t-81.5 -197.5q0 -4 1 -12t1 -11q-14 2 -23 2q-74 0 -126.5 -52.5t-52.5 -126.5 q0 -53 28.5 -97t75.5 -65q-4 -16 -4 -38q0 -74 52.5 -126.5t126.5 -52.5q56 0 100 30v-306l-75 -45h350z" />
|
||||
<glyph unicode="💼" d="M800 1000h300q41 0 70.5 -29.5t29.5 -70.5v-400h-500v100h-200v-100h-500v400q0 41 29.5 70.5t70.5 29.5h300v100q0 41 29.5 70.5t70.5 29.5h200q41 0 70.5 -29.5t29.5 -70.5v-100zM500 1000h200v100h-200v-100zM1200 400v-200q0 -41 -29.5 -70.5t-70.5 -29.5h-1000 q-41 0 -70.5 29.5t-29.5 70.5v200h1200z" />
|
||||
<glyph unicode="📅" d="M1100 900v150q0 21 -14.5 35.5t-35.5 14.5h-150v100h-100v-100h-500v100h-100v-100h-150q-21 0 -35.5 -14.5t-14.5 -35.5v-150h1100zM0 800v-750q0 -20 14.5 -35t35.5 -15h1000q21 0 35.5 15t14.5 35v750h-1100zM100 600h100v-100h-100v100zM300 600h100v-100h-100v100z M500 600h100v-100h-100v100zM700 600h100v-100h-100v100zM900 600h100v-100h-100v100zM100 400h100v-100h-100v100zM300 400h100v-100h-100v100zM500 400h100v-100h-100v100zM700 400h100v-100h-100v100zM900 400h100v-100h-100v100zM100 200h100v-100h-100v100zM300 200 h100v-100h-100v100zM500 200h100v-100h-100v100zM700 200h100v-100h-100v100zM900 200h100v-100h-100v100z" />
|
||||
<glyph unicode="📌" d="M902 1185l283 -282q15 -15 15 -36t-15 -35q-14 -15 -35 -15t-35 15l-36 35l-279 -267v-300l-212 210l-208 -207l-380 -303l303 380l207 208l-210 212h300l267 279l-35 36q-15 14 -15 35t15 35q14 15 35 15t35 -15z" />
|
||||
<glyph unicode="📎" d="M518 119l69 -60l517 511q67 67 95 157t11 183q-16 87 -67 154t-130 103q-69 33 -152 33q-107 0 -197 -55q-40 -24 -111 -95l-512 -512q-68 -68 -81 -163t35 -173q35 -57 94 -89t129 -32q63 0 119 28q33 16 65 40.5t52.5 45.5t59.5 64q40 44 57 61l394 394q35 35 47 84 t-3 96q-27 87 -117 104q-20 2 -29 2q-46 0 -79.5 -17t-67.5 -51l-388 -396l-7 -7l69 -67l377 373q20 22 39 38q23 23 50 23q38 0 53 -36q16 -39 -20 -75l-547 -547q-52 -52 -125 -52q-55 0 -100 33t-54 96q-5 35 2.5 66t31.5 63t42 50t56 54q24 21 44 41l348 348 q52 52 82.5 79.5t84 54t107.5 26.5q25 0 48 -4q95 -17 154 -94.5t51 -175.5q-7 -101 -98 -192l-252 -249l-253 -256z" />
|
||||
<glyph unicode="📷" d="M1200 200v600q0 41 -29.5 70.5t-70.5 29.5h-150q-4 8 -11.5 21.5t-33 48t-53 61t-69 48t-83.5 21.5h-200q-41 0 -82 -20.5t-70 -50t-52 -59t-34 -50.5l-12 -20h-150q-41 0 -70.5 -29.5t-29.5 -70.5v-600q0 -41 29.5 -70.5t70.5 -29.5h1000q41 0 70.5 29.5t29.5 70.5z M1000 700h-100v100h100v-100zM844 500q0 -100 -72 -172t-172 -72t-172 72t-72 172t72 172t172 72t172 -72t72 -172zM706 500q0 44 -31 75t-75 31t-75 -31t-31 -75t31 -75t75 -31t75 31t31 75z" />
|
||||
<glyph unicode="🔒" d="M900 800h100q41 0 70.5 -29.5t29.5 -70.5v-600q0 -41 -29.5 -70.5t-70.5 -29.5h-900q-41 0 -70.5 29.5t-29.5 70.5v600q0 41 29.5 70.5t70.5 29.5h100v200q0 82 59 141t141 59h300q82 0 141 -59t59 -141v-200zM400 800h300v150q0 21 -14.5 35.5t-35.5 14.5h-200 q-21 0 -35.5 -14.5t-14.5 -35.5v-150z" />
|
||||
<glyph unicode="🔔" d="M1062 400h17q20 0 33.5 -14.5t13.5 -35.5q0 -20 -13 -40t-31 -27q-22 -9 -63 -23t-167.5 -37t-251.5 -23t-245.5 20.5t-178.5 41.5l-58 20q-18 7 -31 27.5t-13 40.5q0 21 13.5 35.5t33.5 14.5h17l118 173l63 327q15 77 76 140t144 83l-18 32q-6 19 3 32t29 13h94 q20 0 29 -10.5t3 -29.5l-18 -37q83 -19 144 -82.5t76 -140.5l63 -327zM600 104q-54 0 -103 6q12 -49 40 -79.5t63 -30.5t63 30.5t39 79.5q-48 -6 -102 -6z" />
|
||||
<glyph unicode="🔖" d="M200 0l450 444l450 -443v1150q0 20 -14.5 35t-35.5 15h-800q-21 0 -35.5 -15t-14.5 -35v-1151z" />
|
||||
<glyph unicode="🔥" d="M400 755q2 -12 8 -41.5t8 -43t6 -39.5t3.5 -39.5t-1 -33.5t-6 -31.5t-13.5 -24t-21 -20.5t-31 -12q-38 -10 -67 13t-40.5 61.5t-15 81.5t10.5 75q-52 -46 -83.5 -101t-39 -107t-7.5 -85t5 -63q9 -56 44 -119.5t105 -108.5q31 -21 64 -16t62 23.5t57 49.5t48 61.5t35 60.5 q32 66 39 184.5t-13 157.5q79 -80 122 -164t26 -184q-5 -33 -20.5 -69.5t-37.5 -80.5q-10 -19 -14.5 -29t-12 -26t-9 -23.5t-3 -19t2.5 -15.5t11 -9.5t19.5 -5t30.5 2.5t42 8q57 20 91 34t87.5 44.5t87 64t65.5 88.5t47 122q38 172 -44.5 341.5t-246.5 278.5q22 -44 43 -129 q39 -159 -32 -154q-15 2 -33 9q-79 33 -120.5 100t-44 175.5t48.5 257.5q-13 -8 -34 -23.5t-72.5 -66.5t-88.5 -105.5t-60 -138t-8 -166.5z" />
|
||||
<glyph unicode="🔧" d="M948 778l251 126q13 -175 -151 -267q-123 -70 -253 -23l-596 -596q-15 -16 -36.5 -16t-36.5 16l-111 110q-15 15 -15 36.5t15 37.5l600 599q-33 101 6 201.5t135 154.5q164 92 306 -9l-259 -138z" />
|
||||
</font>
|
||||
</defs></svg>
|
||||
|
Before Width: | Height: | Size: 62 KiB |
Binary file not shown.
Binary file not shown.
BIN
rd_ui/app/images/favicon-16x16.png
Executable file
BIN
rd_ui/app/images/favicon-16x16.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
rd_ui/app/images/favicon-32x32.png
Executable file
BIN
rd_ui/app/images/favicon-32x32.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
rd_ui/app/images/favicon-96x96.png
Executable file
BIN
rd_ui/app/images/favicon-96x96.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
rd_ui/app/images/redash_icon_small.png
Normal file
BIN
rd_ui/app/images/redash_icon_small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -17,8 +17,16 @@
|
||||
<link rel="stylesheet" href="/bower_components/select2/select2.css">
|
||||
<link rel="stylesheet" href="/bower_components/angular-ui-select/dist/select.css">
|
||||
<link rel="stylesheet" href="/bower_components/pace/themes/pace-theme-minimal.css">
|
||||
<link rel="stylesheet" href="/bower_components/font-awesome/css/font-awesome.css">
|
||||
<link rel="stylesheet" href="/bower_components/codemirror/addon/hint/show-hint.css">
|
||||
<link rel="stylesheet" href="/bower_components/leaflet/dist/leaflet.css">
|
||||
<link rel="stylesheet" href="/styles/redash.css">
|
||||
<!-- endbuild -->
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div growl></div>
|
||||
@@ -32,15 +40,15 @@
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/"><strong>{{name}}</strong></a>
|
||||
<a class="navbar-brand" href="/"><img src="/images/redash_icon_small.png"/></a>
|
||||
</div>
|
||||
{% raw %}
|
||||
<div class="collapse navbar-collapse navbar-ex1-collapse">
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="active" ng-show="pageTitle"><a class="page-title" ng-bind="pageTitle"></a></li>
|
||||
<li class="dropdown" ng-show="groupedDashboards.length > 0 || otherDashboards.length > 0 || currentUser.hasPermission('create_dashboard')">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown"><span class="glyphicon glyphicon-th-large"></span> <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="dropdown" ng-show="groupedDashboards.length > 0 || otherDashboards.length > 0 || currentUser.hasPermission('create_dashboard')" dropdown>
|
||||
<a href="#" class="dropdown-toggle" dropdown-toggle><span class="glyphicon glyphicon-th-large"></span> <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu" dropdown-menu>
|
||||
<span ng-repeat="(name, group) in groupedDashboards">
|
||||
<li class="dropdown-submenu">
|
||||
<a href="#" ng-bind="name"></a>
|
||||
@@ -58,13 +66,19 @@
|
||||
<li><a data-toggle="modal" href="#new_dashboard_dialog" ng-show="currentUser.hasPermission('create_dashboard')">New Dashboard</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown" ng-show="currentUser.hasPermission('view_query')">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Queries <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="dropdown" ng-show="currentUser.hasPermission('view_query')" dropdown>
|
||||
<a href="#" class="dropdown-toggle" dropdown-toggle>Queries <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu" dropdown-menu>
|
||||
<li ng-show="currentUser.hasPermission('create_query')"><a href="/queries/new">New Query</a></li>
|
||||
<li><a href="/queries">Queries</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/alerts">Alerts</a>
|
||||
</li>
|
||||
<li ng-show="currentUser.hasPermission('admin')">
|
||||
<a href="/data_sources">Data Sources</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="navbar-form navbar-left" role="search" ng-submit="searchQueries()">
|
||||
<div class="form-group">
|
||||
@@ -104,9 +118,11 @@
|
||||
<script src="/bower_components/codemirror/lib/codemirror.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/edit/matchbrackets.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/edit/closebrackets.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/hint/show-hint.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/hint/anyword-hint.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/sql/sql.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/python/python.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/javascript/javascript.js"></script>
|
||||
<script src="/bower_components/angular-ui-codemirror/ui-codemirror.js"></script>
|
||||
<script src="/bower_components/highcharts/highcharts.js"></script>
|
||||
<script src="/bower_components/highcharts/modules/exporting.js"></script>
|
||||
<script src="/bower_components/gridster/dist/jquery.gridster.js"></script>
|
||||
@@ -120,11 +136,17 @@
|
||||
<script src="/bower_components/angular-ui-select/dist/select.js"></script>
|
||||
<script src="/bower_components/underscore.string/lib/underscore.string.js"></script>
|
||||
<script src="/bower_components/marked/lib/marked.js"></script>
|
||||
<script src="/bower_components/angular-base64-upload/dist/angular-base64-upload.js"></script>
|
||||
<script src="/scripts/ng_highchart.js"></script>
|
||||
<script src="/scripts/ng_smart_table.js"></script>
|
||||
<script src="/scripts/ui-bootstrap-tpls-0.5.0.min.js"></script>
|
||||
<script src="/bower_components/angular-ui-bootstrap-bower/ui-bootstrap-tpls.js"></script>
|
||||
<script src="/bower_components/bucky/bucky.js"></script>
|
||||
<script src="/bower_components/pace/pace.js"></script>
|
||||
<script src="/bower_components/mustache/mustache.js"></script>
|
||||
<script src="/bower_components/canvg/rgbcolor.js"></script>
|
||||
<script src="/bower_components/canvg/StackBlur.js"></script>
|
||||
<script src="/bower_components/canvg/canvg.js"></script>
|
||||
<script src="/bower_components/leaflet/dist/leaflet.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<!-- build:js({.tmp,app}) /scripts/scripts.js -->
|
||||
@@ -136,18 +158,22 @@
|
||||
<script src="/scripts/controllers/controllers.js"></script>
|
||||
<script src="/scripts/controllers/dashboard.js"></script>
|
||||
<script src="/scripts/controllers/admin_controllers.js"></script>
|
||||
<script src="/scripts/controllers/data_sources.js"></script>
|
||||
<script src="/scripts/controllers/query_view.js"></script>
|
||||
<script src="/scripts/controllers/query_source.js"></script>
|
||||
<script src="/scripts/visualizations/base.js"></script>
|
||||
<script src="/scripts/visualizations/chart.js"></script>
|
||||
<script src="/scripts/visualizations/cohort.js"></script>
|
||||
<script src="/scripts/visualizations/map.js"></script>
|
||||
<script src="/scripts/visualizations/counter.js"></script>
|
||||
<script src="/scripts/visualizations/table.js"></script>
|
||||
<script src="/scripts/visualizations/pivot.js"></script>
|
||||
<script src="/scripts/directives/directives.js"></script>
|
||||
<script src="/scripts/directives/query_directives.js"></script>
|
||||
<script src="/scripts/directives/data_source_directives.js"></script>
|
||||
<script src="/scripts/directives/dashboard_directives.js"></script>
|
||||
<script src="/scripts/filters.js"></script>
|
||||
<script src="/scripts/controllers/alerts.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<script>
|
||||
@@ -162,7 +188,7 @@
|
||||
|
||||
currentUser.hasPermission = function(permission) {
|
||||
return this.permissions.indexOf(permission) != -1;
|
||||
}
|
||||
};
|
||||
|
||||
{{ analytics|safe }}
|
||||
</script>
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
<link rel="stylesheet" href="/styles/redash.css">
|
||||
<link rel="stylesheet" href="/styles/login.css">
|
||||
<!-- endbuild -->
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -26,13 +30,20 @@
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/"><strong>{{name}}</strong></a>
|
||||
<a class="navbar-brand" href="/"><img src="/images/redash_icon_small.png"/></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-warning" role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="main">
|
||||
{% if show_google_openid %}
|
||||
@@ -48,6 +59,19 @@
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if show_saml_login %}
|
||||
|
||||
<div class="row">
|
||||
<a href="/saml/login">SAML Login</a>
|
||||
</div>
|
||||
|
||||
<div class="login-or">
|
||||
<hr class="hr-or">
|
||||
<span class="span-or">or</span>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
<form role="form" method="post" name="login">
|
||||
<div class="form-group">
|
||||
<label for="inputUsernameEmail">Username or email</label>
|
||||
|
||||
@@ -6,7 +6,6 @@ angular.module('redash', [
|
||||
'redash.services',
|
||||
'redash.renderers',
|
||||
'redash.visualization',
|
||||
'ui.codemirror',
|
||||
'highchart',
|
||||
'ui.select2',
|
||||
'angular-growl',
|
||||
@@ -15,7 +14,8 @@ angular.module('redash', [
|
||||
'smartTable.table',
|
||||
'ngResource',
|
||||
'ngRoute',
|
||||
'ui.select'
|
||||
'ui.select',
|
||||
'naif.base64'
|
||||
]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider',
|
||||
function ($routeProvider, $locationProvider, $compileProvider, growlProvider) {
|
||||
if (featureFlags.clientSideMetrics) {
|
||||
@@ -81,15 +81,33 @@ angular.module('redash', [
|
||||
templateUrl: '/views/admin_status.html',
|
||||
controller: 'AdminStatusCtrl'
|
||||
});
|
||||
$routeProvider.when('/admin/workers', {
|
||||
templateUrl: '/views/admin_workers.html',
|
||||
controller: 'AdminWorkersCtrl'
|
||||
|
||||
$routeProvider.when('/alerts', {
|
||||
templateUrl: '/views/alerts/list.html',
|
||||
controller: 'AlertsCtrl'
|
||||
});
|
||||
$routeProvider.when('/alerts/:alertId', {
|
||||
templateUrl: '/views/alerts/edit.html',
|
||||
controller: 'AlertCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/data_sources/:dataSourceId', {
|
||||
templateUrl: '/views/data_sources/edit.html',
|
||||
controller: 'DataSourceCtrl'
|
||||
});
|
||||
$routeProvider.when('/data_sources', {
|
||||
templateUrl: '/views/data_sources/list.html',
|
||||
controller: 'DataSourcesCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/', {
|
||||
templateUrl: '/views/index.html',
|
||||
controller: 'IndexCtrl'
|
||||
});
|
||||
$routeProvider.when('/personal', {
|
||||
templateUrl: '/views/personal.html',
|
||||
controller: 'PersonalIndexCtrl'
|
||||
});
|
||||
$routeProvider.otherwise({
|
||||
redirectTo: '/'
|
||||
});
|
||||
|
||||
@@ -16,16 +16,9 @@
|
||||
$timeout(refresh, 59 * 1000);
|
||||
};
|
||||
|
||||
$scope.flowerUrl = featureFlags.flowerUrl;
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
var AdminWorkersCtrl = function ($scope, $sce) {
|
||||
$scope.flowerUrl = $sce.trustAsResourceUrl(featureFlags.flowerUrl);
|
||||
};
|
||||
|
||||
angular.module('redash.admin_controllers', [])
|
||||
.controller('AdminStatusCtrl', ['$scope', 'Events', '$http', '$timeout', AdminStatusCtrl])
|
||||
.controller('AdminWorkersCtrl', ['$scope', '$sce', AdminWorkersCtrl])
|
||||
})();
|
||||
|
||||
174
rd_ui/app/scripts/controllers/alerts.js
Normal file
174
rd_ui/app/scripts/controllers/alerts.js
Normal file
@@ -0,0 +1,174 @@
|
||||
(function() {
|
||||
|
||||
var AlertsCtrl = function($scope, Events, Alert) {
|
||||
Events.record(currentUser, "view", "page", "alerts");
|
||||
$scope.$parent.pageTitle = "Alerts";
|
||||
|
||||
$scope.alerts = []
|
||||
Alert.query(function(alerts) {
|
||||
var stateClass = {
|
||||
'ok': 'label label-success',
|
||||
'triggered': 'label label-danger',
|
||||
'unknown': 'label label-warning'
|
||||
};
|
||||
_.each(alerts, function(alert) {
|
||||
alert.class = stateClass[alert.state];
|
||||
})
|
||||
$scope.alerts = alerts;
|
||||
|
||||
});
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 50,
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplate": '<a href="/alerts/{{dataRow.id}}">{{dataRow.name}}</a> (<a href="/queries/{{dataRow.query.id}}">query</a>)'
|
||||
},
|
||||
{
|
||||
'label': 'Created By',
|
||||
'map': 'user.name'
|
||||
},
|
||||
{
|
||||
'label': 'State',
|
||||
'cellTemplate': '<span ng-class="dataRow.class">{{dataRow.state | uppercase}}</span> since <span am-time-ago="dataRow.updated_at"></span>'
|
||||
},
|
||||
{
|
||||
'label': 'Created At',
|
||||
'cellTemplate': '<span am-time-ago="dataRow.created_at"></span>'
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert) {
|
||||
$scope.$parent.pageTitle = "Alerts";
|
||||
|
||||
$scope.alertId = $routeParams.alertId;
|
||||
if ($scope.alertId === "new") {
|
||||
Events.record(currentUser, 'view', 'page', 'alerts/new');
|
||||
} else {
|
||||
Events.record(currentUser, 'view', 'alert', $scope.alertId);
|
||||
}
|
||||
|
||||
$scope.onQuerySelected = function(item) {
|
||||
$scope.selectedQuery = item;
|
||||
item.getQueryResultPromise().then(function(result) {
|
||||
$scope.queryResult = result;
|
||||
$scope.alert.options.column = result.getColumnNames()[0];
|
||||
});
|
||||
};
|
||||
|
||||
if ($scope.alertId === "new") {
|
||||
$scope.alert = new Alert({options: {}});
|
||||
} else {
|
||||
$scope.alert = Alert.get({id: $scope.alertId}, function(alert) {
|
||||
$scope.onQuerySelected(new Query($scope.alert.query));
|
||||
});
|
||||
}
|
||||
|
||||
$scope.ops = ['greater than', 'less than', 'equals'];
|
||||
$scope.selectedQuery = null;
|
||||
|
||||
$scope.getDefaultName = function() {
|
||||
if (!$scope.alert.query) {
|
||||
return undefined;
|
||||
}
|
||||
return _.template("<%= query.name %>: <%= options.column %> <%= options.op %> <%= options.value %>", $scope.alert);
|
||||
};
|
||||
|
||||
$scope.searchQueries = function (term) {
|
||||
if (!term || term.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
Query.search({q: term}, function(results) {
|
||||
$scope.queries = results;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.saveChanges = function() {
|
||||
if ($scope.alert.name === undefined || $scope.alert.name === '') {
|
||||
$scope.alert.name = $scope.getDefaultName();
|
||||
}
|
||||
|
||||
$scope.alert.$save(function(alert) {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
if ($scope.alertId === "new") {
|
||||
$location.path('/alerts/' + alert.id).replace();
|
||||
}
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving alert.");
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
angular.module('redash.directives').directive('alertSubscribers', ['AlertSubscription', function (AlertSubscription) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
templateUrl: '/views/alerts/subscribers.html',
|
||||
scope: {
|
||||
'alertId': '='
|
||||
},
|
||||
controller: function ($scope) {
|
||||
$scope.subscribers = AlertSubscription.query({alertId: $scope.alertId});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
angular.module('redash.directives').directive('subscribeButton', ['AlertSubscription', 'growl', function (AlertSubscription, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
template: '<button class="btn btn-default btn-xs" ng-click="toggleSubscription()"><i ng-class="class"></i></button>',
|
||||
controller: function ($scope) {
|
||||
var updateClass = function() {
|
||||
if ($scope.subscription) {
|
||||
$scope.class = "fa fa-eye-slash";
|
||||
} else {
|
||||
$scope.class = "fa fa-eye";
|
||||
}
|
||||
}
|
||||
|
||||
$scope.subscribers.$promise.then(function() {
|
||||
$scope.subscription = _.find($scope.subscribers, function(subscription) {
|
||||
return (subscription.user.email == currentUser.email);
|
||||
});
|
||||
|
||||
updateClass();
|
||||
});
|
||||
|
||||
$scope.toggleSubscription = function() {
|
||||
if ($scope.subscription) {
|
||||
$scope.subscription.$delete(function() {
|
||||
$scope.subscribers = _.without($scope.subscribers, $scope.subscription);
|
||||
$scope.subscription = undefined;
|
||||
updateClass();
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving subscription.");
|
||||
});
|
||||
} else {
|
||||
$scope.subscription = new AlertSubscription({alert_id: $scope.alertId});
|
||||
$scope.subscription.$save(function() {
|
||||
$scope.subscribers.push($scope.subscription);
|
||||
updateClass();
|
||||
}, function() {
|
||||
growl.addErrorMessage("Unsubscription failed.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('AlertsCtrl', ['$scope', 'Events', 'Alert', AlertsCtrl])
|
||||
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', AlertCtrl])
|
||||
|
||||
})();
|
||||
@@ -1,4 +1,11 @@
|
||||
(function () {
|
||||
var dateFormatter = function (value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return value.toDate().toLocaleString();
|
||||
};
|
||||
|
||||
var QuerySearchCtrl = function($scope, $location, $filter, Events, Query) {
|
||||
$scope.$parent.pageTitle = "Queries Search";
|
||||
|
||||
@@ -8,11 +15,6 @@
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
var dateFormatter = function (value) {
|
||||
if (!value) return "-";
|
||||
return value.format("DD/MM/YY HH:mm");
|
||||
}
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
@@ -30,9 +32,9 @@
|
||||
},
|
||||
{
|
||||
'label': 'Update Schedule',
|
||||
'map': 'ttl',
|
||||
'map': 'schedule',
|
||||
'formatFunction': function (value) {
|
||||
return $filter('refreshRateHumanize')(value);
|
||||
return $filter('scheduleHumanize')(value);
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -70,11 +72,6 @@
|
||||
$scope.allQueries = [];
|
||||
$scope.queries = [];
|
||||
|
||||
var dateFormatter = function (value) {
|
||||
if (!value) return "-";
|
||||
return value.format("DD/MM/YY HH:mm");
|
||||
}
|
||||
|
||||
var filterQueries = function () {
|
||||
$scope.queries = _.filter($scope.allQueries, function (query) {
|
||||
if (!$scope.selectedTab) {
|
||||
@@ -130,9 +127,9 @@
|
||||
},
|
||||
{
|
||||
'label': 'Update Schedule',
|
||||
'map': 'ttl',
|
||||
'map': 'schedule',
|
||||
'formatFunction': function (value) {
|
||||
return $filter('refreshRateHumanize')(value);
|
||||
return $filter('scheduleHumanize')(value);
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -192,7 +189,7 @@
|
||||
$(window).click(function () {
|
||||
notifications.getPermissions();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var IndexCtrl = function ($scope, Events, Dashboard) {
|
||||
Events.record(currentUser, "view", "page", "homepage");
|
||||
@@ -206,11 +203,29 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var PersonalIndexCtrl = function ($scope, Events, Dashboard, Query) {
|
||||
Events.record(currentUser, "view", "page", "personal_homepage");
|
||||
$scope.$parent.pageTitle = "Home";
|
||||
|
||||
$scope.recentQueries = Query.recent();
|
||||
$scope.recentDashboards = Dashboard.recent();
|
||||
|
||||
$scope.archiveDashboard = function (dashboard) {
|
||||
if (confirm('Are you sure you want to delete "' + dashboard.name + '" dashboard?')) {
|
||||
Events.record(currentUser, "archive", "dashboard", dashboard.id);
|
||||
dashboard.$delete(function () {
|
||||
$scope.$parent.reloadDashboards();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
angular.module('redash.controllers', [])
|
||||
.controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl])
|
||||
.controller('IndexCtrl', ['$scope', 'Events', 'Dashboard', IndexCtrl])
|
||||
.controller('PersonalIndexCtrl', ['$scope', 'Events', 'Dashboard', 'Query', PersonalIndexCtrl])
|
||||
.controller('MainCtrl', ['$scope', '$location', 'Dashboard', 'notifications', MainCtrl])
|
||||
.controller('QuerySearchCtrl', ['$scope', '$location', '$filter', 'Events', 'Query', QuerySearchCtrl]);
|
||||
})();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
(function() {
|
||||
var DashboardCtrl = function($scope, Events, Widget, $routeParams, $http, $timeout, $q, Dashboard) {
|
||||
var DashboardCtrl = function($scope, Events, Widget, $routeParams, $location, $http, $timeout, $q, Dashboard) {
|
||||
$scope.refreshEnabled = false;
|
||||
$scope.refreshRate = 60;
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
return _.map(row, function (widget) {
|
||||
var w = new Widget(widget);
|
||||
|
||||
if (w.visualization && dashboard.dashboard_filters_enabled) {
|
||||
promises.push(w.getQuery().getQueryResultPromise());
|
||||
if (w.visualization) {
|
||||
promises.push(w.getQuery().getQueryResult().toPromise());
|
||||
}
|
||||
|
||||
return w;
|
||||
@@ -27,27 +27,35 @@
|
||||
var filters = {};
|
||||
_.each(queryResults, function(queryResult) {
|
||||
var queryFilters = queryResult.getFilters();
|
||||
_.each(queryFilters, function (filter) {
|
||||
if (!_.has(filters, filter.name)) {
|
||||
// TODO: first object should be a copy, otherwise one of the chart filters behaves different than the others.
|
||||
_.each(queryFilters, function (queryFilter) {
|
||||
var hasQueryStringValue = _.has($location.search(), queryFilter.name);
|
||||
|
||||
if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) {
|
||||
// If dashboard filters not enabled, or no query string value given, skip filters linking.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_.has(filters, queryFilter.name)) {
|
||||
var filter = _.extend({}, queryFilter);
|
||||
filters[filter.name] = filter;
|
||||
filters[filter.name].originFilters = [];
|
||||
if (hasQueryStringValue) {
|
||||
filter.current = $location.search()[filter.name];
|
||||
}
|
||||
|
||||
$scope.$watch(function () { return filter.current }, function (value) {
|
||||
_.each(filter.originFilters, function (originFilter) {
|
||||
originFilter.current = value;
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: merge values.
|
||||
filters[filter.name].originFilters.push(filter);
|
||||
filters[queryFilter.name].originFilters.push(queryFilter);
|
||||
});
|
||||
});
|
||||
|
||||
if (dashboard.dashboard_filters_enabled) {
|
||||
$scope.filters = _.values(filters);
|
||||
}
|
||||
$scope.filters = _.values(filters);
|
||||
});
|
||||
|
||||
|
||||
@@ -74,7 +82,7 @@
|
||||
_.each(row, function(widget, i) {
|
||||
var newWidget = newWidgets[widget.id];
|
||||
if (newWidget && newWidget[0].visualization.query.latest_query_data_id != widget.visualization.query.latest_query_data_id) {
|
||||
row[i] = newWidget[0];
|
||||
row[i] = new Widget(newWidget[0]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -83,8 +91,8 @@
|
||||
});
|
||||
|
||||
}, $scope.refreshRate);
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$scope.triggerRefresh = function() {
|
||||
$scope.refreshEnabled = !$scope.refreshEnabled;
|
||||
@@ -92,9 +100,13 @@
|
||||
Events.record(currentUser, "autorefresh", "dashboard", dashboard.id, {'enable': $scope.refreshEnabled});
|
||||
|
||||
if ($scope.refreshEnabled) {
|
||||
var refreshRate = _.min(_.flatten($scope.dashboard.widgets), function(widget) {
|
||||
return widget.visualization.query.ttl;
|
||||
}).visualization.query.ttl;
|
||||
var refreshRate = _.min(_.map(_.flatten($scope.dashboard.widgets), function(widget) {
|
||||
var schedule = widget.visualization.query.schedule;
|
||||
if (schedule === null || schedule.match(/\d\d:\d\d/) !== null) {
|
||||
return 60;
|
||||
}
|
||||
return widget.visualization.query.schedule;
|
||||
}));
|
||||
|
||||
$scope.refreshRate = _.max([120, refreshRate * 2]) * 1000;
|
||||
|
||||
@@ -103,7 +115,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
var WidgetCtrl = function($scope, Events, Query) {
|
||||
var WidgetCtrl = function($scope, $location, Events, Query) {
|
||||
$scope.deleteWidget = function() {
|
||||
if (!confirm('Are you sure you want to remove "' + $scope.widget.getName() + '" from the dashboard?')) {
|
||||
return;
|
||||
@@ -127,8 +139,9 @@
|
||||
Events.record(currentUser, "view", "visualization", $scope.widget.visualization.id);
|
||||
|
||||
$scope.query = $scope.widget.getQuery();
|
||||
$scope.queryResult = $scope.query.getQueryResult();
|
||||
$scope.nextUpdateTime = moment(new Date(($scope.query.updated_at + $scope.query.ttl + $scope.query.runtime + 300) * 1000)).fromNow();
|
||||
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
|
||||
var maxAge = $location.search()['maxAge'];
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
|
||||
|
||||
$scope.type = 'visualization';
|
||||
} else {
|
||||
@@ -137,7 +150,7 @@
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('DashboardCtrl', ['$scope', 'Events', 'Widget', '$routeParams', '$http', '$timeout', '$q', 'Dashboard', DashboardCtrl])
|
||||
.controller('WidgetCtrl', ['$scope', 'Events', 'Query', WidgetCtrl])
|
||||
.controller('DashboardCtrl', ['$scope', 'Events', 'Widget', '$routeParams', '$location', '$http', '$timeout', '$q', 'Dashboard', DashboardCtrl])
|
||||
.controller('WidgetCtrl', ['$scope', '$location', 'Events', 'Query', WidgetCtrl])
|
||||
|
||||
})();
|
||||
})();
|
||||
|
||||
47
rd_ui/app/scripts/controllers/data_sources.js
Normal file
47
rd_ui/app/scripts/controllers/data_sources.js
Normal file
@@ -0,0 +1,47 @@
|
||||
(function () {
|
||||
var DataSourcesCtrl = function ($scope, $location, growl, Events, DataSource) {
|
||||
Events.record(currentUser, "view", "page", "admin/data_sources");
|
||||
$scope.$parent.pageTitle = "Data Sources";
|
||||
|
||||
$scope.dataSources = DataSource.query();
|
||||
|
||||
$scope.openDataSource = function(datasource) {
|
||||
$location.path('/data_sources/' + datasource.id);
|
||||
};
|
||||
|
||||
$scope.deleteDataSource = function(event, datasource) {
|
||||
event.stopPropagation();
|
||||
Events.record(currentUser, "delete", "datasource", datasource.id);
|
||||
datasource.$delete(function(resource) {
|
||||
growl.addSuccessMessage("Data source deleted succesfully.");
|
||||
this.$parent.dataSources = _.without(this.dataSources, resource);
|
||||
}.bind(this), function(httpResponse) {
|
||||
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
growl.addErrorMessage("Failed to delete data source.");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var DataSourceCtrl = function ($scope, $routeParams, $http, $location, Events, DataSource) {
|
||||
Events.record(currentUser, "view", "page", "admin/data_source");
|
||||
$scope.$parent.pageTitle = "Data Sources";
|
||||
|
||||
$scope.dataSourceId = $routeParams.dataSourceId;
|
||||
|
||||
if ($scope.dataSourceId == "new") {
|
||||
$scope.dataSource = new DataSource({options: {}});
|
||||
} else {
|
||||
$scope.dataSource = DataSource.get({id: $routeParams.dataSourceId});
|
||||
}
|
||||
|
||||
$scope.$watch('dataSource.id', function(id) {
|
||||
if (id != $scope.dataSourceId && id !== undefined) {
|
||||
$location.path('/data_sources/' + id).replace();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('DataSourcesCtrl', ['$scope', '$location', 'growl', 'Events', 'DataSource', DataSourcesCtrl])
|
||||
.controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'Events', 'DataSource', DataSourceCtrl])
|
||||
})();
|
||||
@@ -14,20 +14,10 @@
|
||||
var isNewQuery = !$scope.query.id,
|
||||
queryText = $scope.query.query,
|
||||
// ref to QueryViewCtrl.saveQuery
|
||||
saveQuery = $scope.saveQuery,
|
||||
shortcuts = {
|
||||
'meta+s': function () {
|
||||
if ($scope.canEdit) {
|
||||
$scope.saveQuery();
|
||||
}
|
||||
},
|
||||
'meta+enter': function () {
|
||||
$scope.executeQuery();
|
||||
}
|
||||
};
|
||||
saveQuery = $scope.saveQuery;
|
||||
|
||||
$scope.sourceMode = true;
|
||||
$scope.canEdit = currentUser.canEdit($scope.query);
|
||||
$scope.canEdit = true;
|
||||
$scope.isDirty = false;
|
||||
|
||||
$scope.newVisualization = undefined;
|
||||
@@ -39,6 +29,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
var shortcuts = {
|
||||
'meta+s': function () {
|
||||
if ($scope.canEdit) {
|
||||
$scope.saveQuery();
|
||||
}
|
||||
},
|
||||
'ctrl+s': function () {
|
||||
if ($scope.canEdit) {
|
||||
$scope.saveQuery();
|
||||
}
|
||||
},
|
||||
// Cmd+Enter for Mac
|
||||
'meta+enter': $scope.executeQuery,
|
||||
// Ctrl+Enter for PC
|
||||
'ctrl+enter': $scope.executeQuery
|
||||
};
|
||||
|
||||
KeyboardShortcuts.bind(shortcuts);
|
||||
|
||||
@@ -62,7 +68,7 @@
|
||||
$scope.duplicateQuery = function() {
|
||||
Events.record(currentUser, 'fork', 'query', $scope.query.id);
|
||||
$scope.query.id = null;
|
||||
$scope.query.ttl = -1;
|
||||
$scope.query.schedule = null;
|
||||
|
||||
$scope.saveQuery({
|
||||
successMessage: 'Query forked',
|
||||
|
||||
@@ -1,29 +1,76 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function QueryViewCtrl($scope, Events, $route, $location, notifications, growl, Query, DataSource) {
|
||||
function QueryViewCtrl($scope, Events, $route, $location, notifications, growl, $modal, Query, DataSource) {
|
||||
var DEFAULT_TAB = 'table';
|
||||
|
||||
var getQueryResult = function(maxAge) {
|
||||
// Collect params, and getQueryResult with params; getQueryResult merges it into the query
|
||||
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
|
||||
if (maxAge == undefined) {
|
||||
maxAge = $location.search()['maxAge'];
|
||||
}
|
||||
|
||||
if (maxAge == undefined) {
|
||||
maxAge = -1;
|
||||
}
|
||||
|
||||
$scope.showLog = false;
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
|
||||
}
|
||||
|
||||
$scope.dataSource = {};
|
||||
$scope.query = $route.current.locals.query;
|
||||
|
||||
var updateSchema = function() {
|
||||
$scope.hasSchema = false;
|
||||
$scope.editorSize = "col-md-12";
|
||||
var dataSourceId = $scope.query.data_source_id || $scope.dataSources[0].id;
|
||||
DataSource.getSchema({id: dataSourceId}, function(data) {
|
||||
if (data && data.length > 0) {
|
||||
$scope.schema = data;
|
||||
_.each(data, function(table) {
|
||||
table.collapsed = true;
|
||||
});
|
||||
|
||||
$scope.editorSize = "col-md-9";
|
||||
$scope.hasSchema = true;
|
||||
} else {
|
||||
$scope.hasSchema = false;
|
||||
$scope.editorSize = "col-md-12";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Events.record(currentUser, 'view', 'query', $scope.query.id);
|
||||
$scope.queryResult = $scope.query.getQueryResult();
|
||||
getQueryResult();
|
||||
$scope.queryExecuting = false;
|
||||
|
||||
$scope.isQueryOwner = currentUser.id === $scope.query.user.id;
|
||||
$scope.isQueryOwner = (currentUser.id === $scope.query.user.id) || currentUser.hasPermission('admin');
|
||||
$scope.canViewSource = currentUser.hasPermission('view_source');
|
||||
|
||||
$scope.dataSources = DataSource.get(function(dataSources) {
|
||||
$scope.query.data_source_id = $scope.query.data_source_id || dataSources[0].id;
|
||||
$scope.dataSources = DataSource.query(function(dataSources) {
|
||||
updateSchema();
|
||||
|
||||
if ($scope.query.isNew()) {
|
||||
$scope.query.data_source_id = $scope.query.data_source_id || dataSources[0].id;
|
||||
$scope.dataSource = _.find(dataSources, function(ds) { return ds.id == $scope.query.data_source_id; });
|
||||
}
|
||||
});
|
||||
|
||||
// in view mode, latest dataset is always visible
|
||||
// source mode changes this behavior
|
||||
$scope.showDataset = true;
|
||||
$scope.showLog = false;
|
||||
|
||||
$scope.lockButton = function(lock) {
|
||||
$scope.queryExecuting = lock;
|
||||
};
|
||||
|
||||
$scope.showApiKey = function() {
|
||||
alert("API Key for this query:\n" + $scope.query.api_key);
|
||||
};
|
||||
|
||||
$scope.saveQuery = function(options, data) {
|
||||
if (data) {
|
||||
data.id = $scope.query.id;
|
||||
@@ -57,7 +104,10 @@
|
||||
};
|
||||
|
||||
$scope.executeQuery = function() {
|
||||
$scope.queryResult = $scope.query.getQueryResult(0);
|
||||
if (!$scope.query.query) {
|
||||
return;
|
||||
}
|
||||
getQueryResult(0);
|
||||
$scope.lockButton(true);
|
||||
$scope.cancelling = false;
|
||||
Events.record(currentUser, 'execute', 'query', $scope.query.id);
|
||||
@@ -69,6 +119,31 @@
|
||||
Events.record(currentUser, 'cancel_execute', 'query', $scope.query.id);
|
||||
};
|
||||
|
||||
$scope.archiveQuery = function(options, data) {
|
||||
if (data) {
|
||||
data.id = $scope.query.id;
|
||||
} else {
|
||||
data = $scope.query;
|
||||
}
|
||||
|
||||
$scope.isDirty = false;
|
||||
|
||||
options = _.extend({}, {
|
||||
successMessage: 'Query archived',
|
||||
errorMessage: 'Query could not be archived'
|
||||
}, options);
|
||||
|
||||
return Query.delete({id: data.id}, function() {
|
||||
$scope.query.is_archived = true;
|
||||
$scope.query.schedule = null;
|
||||
growl.addSuccessMessage(options.successMessage);
|
||||
// This feels dirty.
|
||||
$('#archive-confirmation-modal').modal('hide');
|
||||
}, function(httpResponse) {
|
||||
growl.addErrorMessage(options.errorMessage);
|
||||
}).$promise;
|
||||
}
|
||||
|
||||
$scope.updateDataSource = function() {
|
||||
Events.record(currentUser, 'update_data_source', 'query', $scope.query.id);
|
||||
|
||||
@@ -83,6 +158,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
updateSchema();
|
||||
$scope.dataSource = _.find($scope.dataSources, function(ds) { return ds.id == $scope.query.data_source_id; });
|
||||
$scope.executeQuery();
|
||||
};
|
||||
|
||||
@@ -121,13 +198,41 @@
|
||||
$scope.query.queryResult = $scope.queryResult;
|
||||
|
||||
notifications.showNotification("re:dash", $scope.query.name + " updated.");
|
||||
} else if (status == 'failed') {
|
||||
notifications.showNotification("re:dash", $scope.query.name + " failed to run: " + $scope.queryResult.getError());
|
||||
}
|
||||
|
||||
if (status === 'done' || status === 'failed') {
|
||||
$scope.lockButton(false);
|
||||
}
|
||||
|
||||
if ($scope.queryResult.getLog() != null) {
|
||||
$scope.showLog = true;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.openScheduleForm = function() {
|
||||
if (!$scope.isQueryOwner) {
|
||||
return;
|
||||
};
|
||||
|
||||
$modal.open({
|
||||
templateUrl: '/views/schedule_form.html',
|
||||
size: 'sm',
|
||||
scope: $scope,
|
||||
controller: ['$scope', '$modalInstance', function($scope, $modalInstance) {
|
||||
$scope.close = function() {
|
||||
$modalInstance.close();
|
||||
}
|
||||
if ($scope.query.hasDailySchedule()) {
|
||||
$scope.refreshType = 'daily';
|
||||
} else {
|
||||
$scope.refreshType = 'periodic';
|
||||
}
|
||||
}]
|
||||
});
|
||||
};
|
||||
|
||||
$scope.$watch(function() {
|
||||
return $location.hash()
|
||||
}, function(hash) {
|
||||
@@ -140,5 +245,5 @@
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('QueryViewCtrl',
|
||||
['$scope', 'Events', '$route', '$location', 'notifications', 'growl', 'Query', 'DataSource', QueryViewCtrl]);
|
||||
['$scope', 'Events', '$route', '$location', 'notifications', 'growl', '$modal', 'Query', 'DataSource', QueryViewCtrl]);
|
||||
})();
|
||||
|
||||
76
rd_ui/app/scripts/directives/data_source_directives.js
Normal file
76
rd_ui/app/scripts/directives/data_source_directives.js
Normal file
@@ -0,0 +1,76 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var directives = angular.module('redash.directives');
|
||||
|
||||
// Angular strips data- from the directive, so data-source-form becomes sourceForm...
|
||||
directives.directive('sourceForm', ['$http', 'growl', function ($http, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
templateUrl: '/views/data_sources/form.html',
|
||||
scope: {
|
||||
'dataSource': '='
|
||||
},
|
||||
link: function ($scope) {
|
||||
var setType = function(types) {
|
||||
if ($scope.dataSource.type === undefined) {
|
||||
$scope.dataSource.type = types[0].type;
|
||||
return types[0];
|
||||
}
|
||||
|
||||
$scope.type = _.find(types, function (t) {
|
||||
return t.type == $scope.dataSource.type;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.files = {};
|
||||
|
||||
$scope.$watchCollection('files', function() {
|
||||
_.each($scope.files, function(v, k) {
|
||||
if (v) {
|
||||
$scope.dataSource.options[k] = v.base64;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$http.get('/api/data_sources/types').success(function (types) {
|
||||
setType(types);
|
||||
|
||||
$scope.dataSourceTypes = types;
|
||||
|
||||
_.each(types, function (type) {
|
||||
_.each(type.configuration_schema.properties, function (prop, name) {
|
||||
if (name == 'password' || name == 'passwd') {
|
||||
prop.type = 'password';
|
||||
}
|
||||
|
||||
if (_.string.endsWith(name, "File")) {
|
||||
prop.type = 'file';
|
||||
}
|
||||
|
||||
prop.required = _.contains(type.configuration_schema.required, name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$scope.$watch('dataSource.type', function(current, prev) {
|
||||
if (prev !== current) {
|
||||
if (prev !== undefined) {
|
||||
$scope.dataSource.options = {};
|
||||
}
|
||||
setType($scope.dataSourceTypes);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.saveChanges = function() {
|
||||
$scope.dataSource.$save(function() {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
})();
|
||||
@@ -8,7 +8,7 @@
|
||||
'query': '=',
|
||||
'visualization': '=?'
|
||||
},
|
||||
template: '<a ng-href="{{link}}" class="query-link">{{query.name}}</a>',
|
||||
template: '<small><span class="glyphicon glyphicon-link"></span></small> <a ng-href="{{link}}" class="query-link">{{query.name}}</a>',
|
||||
link: function(scope, element) {
|
||||
scope.link = '/queries/' + scope.query.id;
|
||||
if (scope.visualization) {
|
||||
@@ -29,7 +29,7 @@
|
||||
restrict: 'E',
|
||||
template: '<span ng-show="query.id && canViewSource">\
|
||||
<a ng-show="!sourceMode"\
|
||||
ng-href="{{query.id}}/source#{{selectedTab}}">Show Source\
|
||||
ng-href="/queries/{{query.id}}/source#{{selectedTab}}">Show Source\
|
||||
</a>\
|
||||
<a ng-show="sourceMode"\
|
||||
ng-href="/queries/{{query.id}}#{{selectedTab}}">Hide Source\
|
||||
@@ -63,26 +63,97 @@
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
'query': '=',
|
||||
'lock': '='
|
||||
'lock': '=',
|
||||
'schema': '=',
|
||||
'syntax': '='
|
||||
},
|
||||
template: '<textarea\
|
||||
ui-codemirror="editorOptions"\
|
||||
ng-model="query.query">',
|
||||
link: function($scope) {
|
||||
$scope.editorOptions = {
|
||||
mode: 'text/x-sql',
|
||||
template: '<textarea></textarea>',
|
||||
link: {
|
||||
pre: function ($scope, element) {
|
||||
$scope.syntax = $scope.syntax || 'sql';
|
||||
|
||||
var modes = {
|
||||
'sql': 'text/x-sql',
|
||||
'python': 'text/x-python',
|
||||
'json': 'application/json'
|
||||
};
|
||||
|
||||
var textarea = element.children()[0];
|
||||
var editorOptions = {
|
||||
mode: modes[$scope.syntax],
|
||||
lineWrapping: true,
|
||||
lineNumbers: true,
|
||||
readOnly: false,
|
||||
matchBrackets: true,
|
||||
autoCloseBrackets: true
|
||||
};
|
||||
autoCloseBrackets: true,
|
||||
extraKeys: {"Ctrl-Space": "autocomplete"}
|
||||
};
|
||||
|
||||
$scope.$watch('lock', function(locked) {
|
||||
$scope.editorOptions.readOnly = locked ? 'nocursor' : false;
|
||||
});
|
||||
var additionalHints = [];
|
||||
|
||||
CodeMirror.commands.autocomplete = function(cm) {
|
||||
var hinter = function(editor, options) {
|
||||
var hints = CodeMirror.hint.anyword(editor, options);
|
||||
var cur = editor.getCursor(), token = editor.getTokenAt(cur).string;
|
||||
|
||||
hints.list = _.union(hints.list, _.filter(additionalHints, function (h) {
|
||||
return h.search(token) === 0;
|
||||
}));
|
||||
|
||||
return hints;
|
||||
};
|
||||
|
||||
// CodeMirror.showHint(cm, CodeMirror.hint.anyword);
|
||||
CodeMirror.showHint(cm, hinter);
|
||||
};
|
||||
|
||||
var codemirror = CodeMirror.fromTextArea(textarea, editorOptions);
|
||||
|
||||
codemirror.on('change', function(instance) {
|
||||
var newValue = instance.getValue();
|
||||
|
||||
if (newValue !== $scope.query.query) {
|
||||
$scope.$evalAsync(function() {
|
||||
$scope.query.query = newValue;
|
||||
});
|
||||
}
|
||||
|
||||
$('.schema-container').css('height', $('.CodeMirror').css('height'));
|
||||
});
|
||||
|
||||
$scope.$watch('query.query', function () {
|
||||
if ($scope.query.query !== codemirror.getValue()) {
|
||||
codemirror.setValue($scope.query.query);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('schema', function (schema) {
|
||||
if (schema) {
|
||||
var keywords = [];
|
||||
_.each(schema, function (table) {
|
||||
keywords.push(table.name);
|
||||
_.each(table.columns, function (c) {
|
||||
keywords.push(c);
|
||||
});
|
||||
});
|
||||
|
||||
additionalHints = _.unique(keywords);
|
||||
}
|
||||
|
||||
codemirror.refresh();
|
||||
});
|
||||
|
||||
$scope.$watch('syntax', function(syntax) {
|
||||
codemirror.setOption('mode', modes[syntax]);
|
||||
});
|
||||
|
||||
$scope.$watch('lock', function (locked) {
|
||||
var readOnly = locked ? 'nocursor' : false;
|
||||
codemirror.setOption('readOnly', readOnly);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function queryFormatter($http) {
|
||||
@@ -98,55 +169,111 @@
|
||||
</button>',
|
||||
link: function($scope) {
|
||||
$scope.formatQuery = function formatQuery() {
|
||||
$scope.queryExecuting = true;
|
||||
$scope.queryFormatting = true;
|
||||
$http.post('/api/queries/format', {
|
||||
'query': $scope.query.query
|
||||
}).success(function (response) {
|
||||
$scope.query.query = response;
|
||||
}).finally(function () {
|
||||
$scope.queryExecuting = false;
|
||||
$scope.queryFormatting = false;
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queryTimePicker() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<select ng-disabled="refreshType != \'daily\'" ng-model="hour" ng-change="updateSchedule()" ng-options="c as c for c in hourOptions"></select> :\
|
||||
<select ng-disabled="refreshType != \'daily\'" ng-model="minute" ng-change="updateSchedule()" ng-options="c as c for c in minuteOptions"></select>',
|
||||
link: function($scope) {
|
||||
var padWithZeros = function(size, v) {
|
||||
v = String(v);
|
||||
if (v.length < size) {
|
||||
v = "0" + v;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
$scope.hourOptions = _.map(_.range(0, 24), _.partial(padWithZeros, 2));
|
||||
$scope.minuteOptions = _.map(_.range(0, 60, 5), _.partial(padWithZeros, 2));
|
||||
|
||||
if ($scope.query.hasDailySchedule()) {
|
||||
var parts = $scope.query.scheduleInLocalTime().split(':');
|
||||
$scope.minute = parts[1];
|
||||
$scope.hour = parts[0];
|
||||
} else {
|
||||
$scope.minute = "15";
|
||||
$scope.hour = "00";
|
||||
}
|
||||
|
||||
$scope.updateSchedule = function() {
|
||||
var newSchedule = moment().hour($scope.hour).minute($scope.minute).utc().format('HH:mm');
|
||||
if (newSchedule != $scope.query.schedule) {
|
||||
$scope.query.schedule = newSchedule;
|
||||
$scope.saveQuery();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch('refreshType', function() {
|
||||
if ($scope.refreshType == 'daily') {
|
||||
$scope.updateSchedule();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queryRefreshSelect() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<select\
|
||||
ng-disabled="!isQueryOwner"\
|
||||
ng-model="query.ttl"\
|
||||
ng-disabled="refreshType != \'periodic\'"\
|
||||
ng-model="query.schedule"\
|
||||
ng-change="saveQuery()"\
|
||||
ng-options="c.value as c.name for c in refreshOptions">\
|
||||
<option value="">No Refresh</option>\
|
||||
</select>',
|
||||
link: function($scope) {
|
||||
$scope.refreshOptions = [
|
||||
{
|
||||
value: -1,
|
||||
name: 'No Refresh'
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
value: "60",
|
||||
name: 'Every minute'
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
_.each([5, 10, 15, 30], function(i) {
|
||||
$scope.refreshOptions.push({
|
||||
value: String(i*60),
|
||||
name: "Every " + i + " minutes"
|
||||
})
|
||||
});
|
||||
|
||||
_.each(_.range(1, 13), function (i) {
|
||||
$scope.refreshOptions.push({
|
||||
value: i * 3600,
|
||||
value: String(i * 3600),
|
||||
name: 'Every ' + i + 'h'
|
||||
});
|
||||
})
|
||||
|
||||
$scope.refreshOptions.push({
|
||||
value: 24 * 3600,
|
||||
value: String(24 * 3600),
|
||||
name: 'Every 24h'
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: 7 * 24 * 3600,
|
||||
value: String(7 * 24 * 3600),
|
||||
name: 'Once a week'
|
||||
});
|
||||
|
||||
$scope.$watch('refreshType', function() {
|
||||
if ($scope.refreshType == 'periodic') {
|
||||
if ($scope.query.hasDailySchedule()) {
|
||||
$scope.query.schedule = null;
|
||||
$scope.saveQuery();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -158,5 +285,6 @@
|
||||
.directive('queryResultLink', queryResultCSVLink)
|
||||
.directive('queryEditor', queryEditor)
|
||||
.directive('queryRefreshSelect', queryRefreshSelect)
|
||||
.directive('queryTimePicker', queryTimePicker)
|
||||
.directive('queryFormatter', ['$http', queryFormatter]);
|
||||
})();
|
||||
@@ -24,13 +24,17 @@ angular.module('redash.filters', []).
|
||||
return durationHumanize;
|
||||
})
|
||||
|
||||
.filter('refreshRateHumanize', function () {
|
||||
return function (ttl) {
|
||||
if (ttl == -1) {
|
||||
.filter('scheduleHumanize', function() {
|
||||
return function (schedule) {
|
||||
if (schedule === null) {
|
||||
return "Never";
|
||||
} else {
|
||||
return "Every " + durationHumanize(ttl);
|
||||
} else if (schedule.match(/\d\d:\d\d/) !== null) {
|
||||
var parts = schedule.split(':');
|
||||
var localTime = moment.utc().hour(parts[0]).minute(parts[1]).local().format('HH:mm');
|
||||
return "Every day at " + localTime;
|
||||
}
|
||||
|
||||
return "Every " + durationHumanize(parseInt(schedule));
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
;
|
||||
|
||||
if (moment.isMoment(this.x)) {
|
||||
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>',
|
||||
var s = '<b>' + this.x.toDate().toLocaleString() + '</b>',
|
||||
pointsCount = this.points.length;
|
||||
|
||||
$.each(this.points, function (i, point) {
|
||||
@@ -126,23 +126,51 @@
|
||||
{
|
||||
text: 'Show Total',
|
||||
onclick: function () {
|
||||
var hasTotalsAlready = _.some(this.series, function (s) {
|
||||
var res = (s.name == 'Total');
|
||||
//if 'Total' already exists - just make it visible
|
||||
if (res) s.setVisible(true, false);
|
||||
return res;
|
||||
})
|
||||
var data = {};
|
||||
_.each(this.series, function (s) {
|
||||
s.setVisible(false, false);
|
||||
_.each(s.data, function (p) {
|
||||
data[p.x] = data[p.x] || {'x': p.x, 'y': 0};
|
||||
data[p.x].y = data[p.x].y + p.y;
|
||||
});
|
||||
if (s.name != 'Total') s.setVisible(false, false);
|
||||
if (!hasTotalsAlready) {
|
||||
_.each(s.data, function (p) {
|
||||
data[p.x] = data[p.x] || {'x': p.x, 'y': 0};
|
||||
data[p.x].y = data[p.x].y + p.y;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.addSeries({
|
||||
data: _.values(data),
|
||||
type: 'line',
|
||||
name: 'Total'
|
||||
}, false)
|
||||
if (!hasTotalsAlready) {
|
||||
this.addSeries({
|
||||
data: _.sortBy(_.values(data), 'x'),
|
||||
type: 'line',
|
||||
name: 'Total'
|
||||
}, false)
|
||||
}
|
||||
|
||||
this.redraw();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Save Image',
|
||||
onclick: function () {
|
||||
var canvas = document.createElement('canvas');
|
||||
window.canvg(canvas, this.getSVG());
|
||||
var href = canvas.toDataURL('image/png');
|
||||
var a = document.createElement('a');
|
||||
a.href = href;
|
||||
var filenameSuffix = new Date().toISOString().replace(/:/g,'_').replace('Z', '');
|
||||
if (this.title) {
|
||||
filenameSuffix = this.title.text;
|
||||
}
|
||||
a.download = 'redash_charts_'+filenameSuffix+'.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -277,8 +305,29 @@
|
||||
scope.chart.series[0].remove(false);
|
||||
};
|
||||
|
||||
// We check either for true or undefined for backward compatibility.
|
||||
var series = scope.series;
|
||||
|
||||
|
||||
// If this is a chart that has just one row for multiple columns, sort
|
||||
// by the Y values. For example:
|
||||
//
|
||||
// A | B | C
|
||||
// 20 | 30 | 15
|
||||
//
|
||||
// Will be sorted:
|
||||
// C | A | B
|
||||
// 15 | 20 | 30
|
||||
var sortable = _.every(series, function(s) { return s.data.length == 1 });
|
||||
|
||||
if (sortable) {
|
||||
series = _.sortBy(series, function (s) {
|
||||
return s.data[0].y
|
||||
});
|
||||
}
|
||||
|
||||
if (!('xAxis' in chartOptions && 'type' in chartOptions['xAxis'])) {
|
||||
if (scope.series.length > 0 && _.some(scope.series[0].data, function (p) {
|
||||
if (series.length > 0 && _.some(series[0].data, function (p) {
|
||||
return (angular.isString(p.x) || angular.isDefined(p.name));
|
||||
})) {
|
||||
chartOptions['xAxis'] = chartOptions['xAxis'] || {};
|
||||
@@ -290,13 +339,13 @@
|
||||
}
|
||||
|
||||
if (chartOptions['xAxis']['type'] == 'category' || chartOptions['series']['type']=='pie') {
|
||||
if (!angular.isDefined(scope.series[0].data[0].name)) {
|
||||
if (!angular.isDefined(series[0].data[0].name)) {
|
||||
// We need to make sure that for each category, each series has a value.
|
||||
var categories = _.union.apply(this, _.map(scope.series, function (s) {
|
||||
var categories = _.union.apply(this, _.map(series, function (s) {
|
||||
return _.pluck(s.data, 'x')
|
||||
}));
|
||||
|
||||
_.each(scope.series, function (s) {
|
||||
_.each(series, function (s) {
|
||||
// TODO: move this logic to Query#getChartData
|
||||
var yValues = _.groupBy(s.data, 'x');
|
||||
|
||||
@@ -307,19 +356,31 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (categories.length == 1) {
|
||||
newData = _.sortBy(newData, 'y').reverse();
|
||||
}
|
||||
;
|
||||
|
||||
s.data = newData;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (chartOptions['sortX'] === true || chartOptions['sortX'] === undefined) {
|
||||
var seriesCopy = [];
|
||||
|
||||
_.each(series, function (s) {
|
||||
// make a copy of series data, so we don't override original.
|
||||
var fieldName = 'x';
|
||||
if (s.data.length > 0 && _.has(s.data[0], 'name')) {
|
||||
fieldName = 'name';
|
||||
};
|
||||
|
||||
var sorted = _.extend({}, s, {data: _.sortBy(s.data, fieldName)});
|
||||
seriesCopy.push(sorted);
|
||||
});
|
||||
|
||||
series = seriesCopy;
|
||||
}
|
||||
|
||||
scope.chart.counters.color = 0;
|
||||
|
||||
_.each(scope.series, function (s) {
|
||||
_.each(series, function (s) {
|
||||
// here we override the series with the visualization config
|
||||
s = _.extend(s, chartOptions['series']);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
||||
(function () {
|
||||
var Dashboard = function($resource) {
|
||||
var resource = $resource('/api/dashboards/:slug', {slug: '@slug'});
|
||||
var resource = $resource('/api/dashboards/:slug', {slug: '@slug'}, {
|
||||
recent: {
|
||||
method: 'get',
|
||||
isArray: true,
|
||||
url: "/api/dashboards/recent"
|
||||
}});
|
||||
|
||||
resource.prototype.canEdit = function() {
|
||||
return currentUser.hasPermission('admin') || currentUser.canEdit(this);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
(function () {
|
||||
var notifications = function (Events) {
|
||||
var notificationService = {};
|
||||
var lastNotification = null;
|
||||
|
||||
notificationService.isSupported = function () {
|
||||
if (window.webkitNotifications) {
|
||||
if ("Notification" in window) {
|
||||
return true;
|
||||
} else {
|
||||
console.log("HTML5 notifications are not supported.");
|
||||
@@ -17,8 +16,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.webkitNotifications.checkPermission() == 0) { // 0 is PERMISSION_ALLOWED
|
||||
window.webkitNotifications.requestPermission();
|
||||
if (Notification.permission !== "granted") {
|
||||
Notification.requestPermission(function (status) {
|
||||
if (Notification.permission !== status) {
|
||||
Notification.permission = status;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,23 +30,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.webkitVisibilityState && document.webkitVisibilityState == 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastNotification) {
|
||||
lastNotification.cancel();
|
||||
}
|
||||
|
||||
var notification = window.webkitNotifications.createNotification('', title, content);
|
||||
lastNotification = notification;
|
||||
//using the 'tag' to avoid showing duplicate notifications
|
||||
var notification = new Notification(title, {'tag': title+content, 'body': content});
|
||||
setTimeout(function(){
|
||||
notification.close();
|
||||
},3000);
|
||||
notification.onclick = function () {
|
||||
window.focus();
|
||||
this.cancel();
|
||||
Events.record(currentUser, 'click', 'notification');
|
||||
};
|
||||
|
||||
notification.show()
|
||||
}
|
||||
|
||||
return notificationService;
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
(function () {
|
||||
function QueryResultError(errorMessage) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
QueryResultError.prototype.getError = function() {
|
||||
return this.errorMessage;
|
||||
};
|
||||
|
||||
QueryResultError.prototype.getStatus = function() {
|
||||
return 'failed';
|
||||
};
|
||||
|
||||
QueryResultError.prototype.getData = function() {
|
||||
return null;
|
||||
};
|
||||
|
||||
QueryResultError.prototype.getLog = function() {
|
||||
return null;
|
||||
};
|
||||
|
||||
QueryResultError.prototype.getChartData = function() {
|
||||
return null;
|
||||
};
|
||||
|
||||
var QueryResult = function ($resource, $timeout, $q) {
|
||||
var QueryResultResource = $resource('/api/query_results/:id', {id: '@id'}, {'post': {'method': 'POST'}});
|
||||
var Job = $resource('/api/jobs/:id', {id: '@id'});
|
||||
@@ -12,27 +36,29 @@
|
||||
|
||||
var columnTypes = {};
|
||||
|
||||
// TODO: we should stop manipulating incoming data, and switch to relaying on the column type set by the backend.
|
||||
// This logic is prone to errors, and better be removed. Kept for now, for backward compatability.
|
||||
_.each(this.query_result.data.rows, function (row) {
|
||||
_.each(row, function (v, k) {
|
||||
if (angular.isNumber(v)) {
|
||||
if (parseInt(v) === v) {
|
||||
columnTypes[k] = 'integer';
|
||||
} else {
|
||||
columnTypes[k] = 'float';
|
||||
}
|
||||
columnTypes[k] = 'float';
|
||||
} else if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}T/)) {
|
||||
row[k] = moment(v);
|
||||
columnTypes[k] = 'datetime';
|
||||
} else if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
row[k] = moment(v);
|
||||
columnTypes[k] = 'date';
|
||||
} else if (typeof(v) == 'object' && v !== null) {
|
||||
row[k] = JSON.stringify(v);
|
||||
}
|
||||
}, this);
|
||||
}, this);
|
||||
|
||||
_.each(this.query_result.data.columns, function(column) {
|
||||
if (columnTypes[column.name]) {
|
||||
column.type = columnTypes[column.name];
|
||||
if (column.type == null || column.type == 'string') {
|
||||
column.type = columnTypes[column.name];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,7 +68,7 @@
|
||||
} else {
|
||||
this.status = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function QueryResult(props) {
|
||||
this.deferred = $q.defer();
|
||||
@@ -93,6 +119,14 @@
|
||||
return this.job.error;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getLog = function() {
|
||||
if (!this.query_result.data || !this.query_result.data.log || this.query_result.data.log.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.query_result.data.log;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getUpdatedAt = function () {
|
||||
return this.query_result.retrieved_at || this.job.updated_at * 1000.0 || this.updatedAt;
|
||||
}
|
||||
@@ -216,10 +250,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
_.each(series, function (series) {
|
||||
series.data = _.sortBy(series.data, 'x');
|
||||
});
|
||||
|
||||
return _.values(series);
|
||||
};
|
||||
|
||||
@@ -249,26 +279,9 @@
|
||||
return parts[0];
|
||||
};
|
||||
|
||||
var charConversionMap = {
|
||||
'__pct': /%/g,
|
||||
'_': / /g,
|
||||
'__qm': /\?/g,
|
||||
'__brkt': /[\(\)\[\]]/g,
|
||||
'__dash': /-/g,
|
||||
'__amp': /&/g,
|
||||
'__sl': /\//g,
|
||||
'__fsl': /\\/g,
|
||||
};
|
||||
|
||||
QueryResult.prototype.getColumnCleanName = function (column) {
|
||||
var name = this.getColumnNameWithoutType(column);
|
||||
|
||||
if (name != '') {
|
||||
_.each(charConversionMap, function(regex, replacement) {
|
||||
name = name.replace(regex, replacement);
|
||||
});
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -331,7 +344,7 @@
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
var refreshStatus = function (queryResult, query, ttl) {
|
||||
var refreshStatus = function (queryResult, query) {
|
||||
Job.get({'id': queryResult.job.id}, function (response) {
|
||||
queryResult.update(response);
|
||||
|
||||
@@ -341,7 +354,7 @@
|
||||
});
|
||||
} else if (queryResult.getStatus() != "failed") {
|
||||
$timeout(function () {
|
||||
refreshStatus(queryResult, query, ttl);
|
||||
refreshStatus(queryResult, query);
|
||||
}, 3000);
|
||||
}
|
||||
})
|
||||
@@ -361,14 +374,19 @@
|
||||
return this.deferred.promise;
|
||||
}
|
||||
|
||||
QueryResult.get = function (data_source_id, query, ttl) {
|
||||
QueryResult.get = function (data_source_id, query, maxAge, queryId) {
|
||||
var queryResult = new QueryResult();
|
||||
|
||||
QueryResultResource.post({'data_source_id': data_source_id, 'query': query, 'ttl': ttl}, function (response) {
|
||||
var params = {'data_source_id': data_source_id, 'query': query, 'max_age': maxAge};
|
||||
if (queryId !== undefined) {
|
||||
params['query_id'] = queryId;
|
||||
};
|
||||
|
||||
QueryResultResource.post(params, function (response) {
|
||||
queryResult.update(response);
|
||||
|
||||
if ('job' in response) {
|
||||
refreshStatus(queryResult, query, ttl);
|
||||
refreshStatus(queryResult, query);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -379,36 +397,100 @@
|
||||
};
|
||||
|
||||
var Query = function ($resource, QueryResult, DataSource) {
|
||||
var Query = $resource('/api/queries/:id', {id: '@id'}, {search: {method: 'get', isArray: true, url: "/api/queries/search"}});
|
||||
var Query = $resource('/api/queries/:id', {id: '@id'},
|
||||
{
|
||||
search: {
|
||||
method: 'get',
|
||||
isArray: true,
|
||||
url: "/api/queries/search"
|
||||
},
|
||||
recent: {
|
||||
method: 'get',
|
||||
isArray: true,
|
||||
url: "/api/queries/recent"
|
||||
}});
|
||||
|
||||
Query.newQuery = function () {
|
||||
return new Query({
|
||||
query: "",
|
||||
name: "New Query",
|
||||
ttl: -1,
|
||||
schedule: null,
|
||||
user: currentUser
|
||||
});
|
||||
};
|
||||
|
||||
Query.collectParamsFromQueryString = function($location, query) {
|
||||
var parameterNames = query.getParameters();
|
||||
var parameters = {};
|
||||
|
||||
var queryString = $location.search();
|
||||
_.each(parameterNames, function(param, i) {
|
||||
var qsName = "p_" + param;
|
||||
if (qsName in queryString) {
|
||||
parameters[param] = queryString[qsName];
|
||||
}
|
||||
});
|
||||
|
||||
return parameters;
|
||||
};
|
||||
|
||||
Query.prototype.getSourceLink = function () {
|
||||
return '/queries/' + this.id + '/source';
|
||||
};
|
||||
|
||||
Query.prototype.getQueryResult = function (ttl) {
|
||||
if (ttl == undefined) {
|
||||
ttl = this.ttl;
|
||||
Query.prototype.isNew = function() {
|
||||
return this.id === undefined;
|
||||
};
|
||||
|
||||
Query.prototype.hasDailySchedule = function() {
|
||||
return (this.schedule && this.schedule.match(/\d\d:\d\d/) !== null);
|
||||
};
|
||||
|
||||
Query.prototype.scheduleInLocalTime = function() {
|
||||
var parts = this.schedule.split(':');
|
||||
return moment.utc().hour(parts[0]).minute(parts[1]).local().format('HH:mm');
|
||||
};
|
||||
|
||||
Query.prototype.getQueryResult = function (maxAge, parameters) {
|
||||
if (!this.query) {
|
||||
return;
|
||||
}
|
||||
var queryText = this.query;
|
||||
|
||||
var queryParameters = this.getParameters();
|
||||
var paramsRequired = !_.isEmpty(queryParameters);
|
||||
|
||||
var missingParams = parameters === undefined ? queryParameters : _.difference(queryParameters, _.keys(parameters));
|
||||
|
||||
if (paramsRequired && missingParams.length > 0) {
|
||||
var paramsWord = "parameter";
|
||||
if (missingParams.length > 1) {
|
||||
paramsWord = "parameters";
|
||||
}
|
||||
|
||||
return new QueryResult({job: {error: "Missing values for " + missingParams.join(', ') + " "+paramsWord+".", status: 4}});
|
||||
}
|
||||
|
||||
if (this.latest_query_data && ttl != 0) {
|
||||
if (paramsRequired) {
|
||||
queryText = Mustache.render(queryText, parameters);
|
||||
|
||||
// Need to clear latest results, to make sure we don't used results for different params.
|
||||
this.latest_query_data = null;
|
||||
this.latest_query_data_id = null;
|
||||
}
|
||||
|
||||
if (this.latest_query_data && maxAge != 0) {
|
||||
if (!this.queryResult) {
|
||||
this.queryResult = new QueryResult({'query_result': this.latest_query_data});
|
||||
}
|
||||
} else if (this.latest_query_data_id && ttl != 0) {
|
||||
} else if (this.latest_query_data_id && maxAge != 0) {
|
||||
if (!this.queryResult) {
|
||||
this.queryResult = QueryResult.getById(this.latest_query_data_id);
|
||||
}
|
||||
} else if (this.data_source_id) {
|
||||
this.queryResult = QueryResult.get(this.data_source_id, this.query, ttl);
|
||||
this.queryResult = QueryResult.get(this.data_source_id, queryText, maxAge, this.id);
|
||||
} else {
|
||||
return new QueryResultError("Please select data source to run this query.");
|
||||
}
|
||||
|
||||
return this.queryResult;
|
||||
@@ -416,6 +498,26 @@
|
||||
|
||||
Query.prototype.getQueryResultPromise = function() {
|
||||
return this.getQueryResult().toPromise();
|
||||
};
|
||||
|
||||
Query.prototype.getParameters = function() {
|
||||
var parts = Mustache.parse(this.query);
|
||||
var parameters = [];
|
||||
var collectParams = function(parts) {
|
||||
parameters = [];
|
||||
_.each(parts, function(part) {
|
||||
if (part[0] == 'name' || part[0] == '&') {
|
||||
parameters.push(part[1]);
|
||||
} else if (part[0] == '#') {
|
||||
parameters = _.union(parameters, collectParams(part[4]));
|
||||
}
|
||||
});
|
||||
return parameters;
|
||||
};
|
||||
|
||||
parameters = collectParams(parts);
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
return Query;
|
||||
@@ -424,10 +526,42 @@
|
||||
|
||||
|
||||
var DataSource = function ($resource) {
|
||||
var DataSourceResource = $resource('/api/data_sources/:id', {id: '@id'}, {'get': {'method': 'GET', 'cache': true, 'isArray': true}});
|
||||
var actions = {
|
||||
'get': {'method': 'GET', 'cache': false, 'isArray': false},
|
||||
'query': {'method': 'GET', 'cache': false, 'isArray': true},
|
||||
'getSchema': {'method': 'GET', 'cache': true, 'isArray': true, 'url': '/api/data_sources/:id/schema'}
|
||||
};
|
||||
|
||||
var DataSourceResource = $resource('/api/data_sources/:id', {id: '@id'}, actions);
|
||||
|
||||
|
||||
return DataSourceResource;
|
||||
}
|
||||
};
|
||||
|
||||
var AlertSubscription = function ($resource) {
|
||||
var resource = $resource('/api/alerts/:alertId/subscriptions/:userId', {alertId: '@alert_id', userId: '@user.id'});
|
||||
return resource;
|
||||
};
|
||||
|
||||
var Alert = function ($resource, $http) {
|
||||
var actions = {
|
||||
save: {
|
||||
method: 'POST',
|
||||
transformRequest: [function(data) {
|
||||
var newData = _.extend({}, data);
|
||||
if (newData.query_id === undefined) {
|
||||
newData.query_id = newData.query.id;
|
||||
delete newData.query;
|
||||
}
|
||||
|
||||
return newData;
|
||||
}].concat($http.defaults.transformRequest)
|
||||
}
|
||||
};
|
||||
var resource = $resource('/api/alerts/:id', {id: '@id'}, actions);
|
||||
|
||||
return resource;
|
||||
};
|
||||
|
||||
var Widget = function ($resource, Query) {
|
||||
var WidgetResource = $resource('/api/widgets/:id', {id: '@id'});
|
||||
@@ -454,5 +588,7 @@
|
||||
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
|
||||
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
|
||||
.factory('DataSource', ['$resource', DataSource])
|
||||
.factory('Alert', ['$resource', '$http', Alert])
|
||||
.factory('AlertSubscription', ['$resource', AlertSubscription])
|
||||
.factory('Widget', ['$resource', 'Query', Widget]);
|
||||
})();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -55,6 +55,22 @@
|
||||
}];
|
||||
};
|
||||
|
||||
var VisualizationName = function(Visualization) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
visualization: '='
|
||||
},
|
||||
template: '<small>{{name}}</small>',
|
||||
replace: false,
|
||||
link: function (scope) {
|
||||
if (Visualization.visualizations[scope.visualization.type].name != scope.visualization.name) {
|
||||
scope.name = scope.visualization.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var VisualizationRenderer = function ($location, Visualization) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
@@ -72,42 +88,9 @@
|
||||
width: '50%'
|
||||
};
|
||||
|
||||
function readURL() {
|
||||
var searchFilters = angular.fromJson($location.search().filters);
|
||||
if (searchFilters) {
|
||||
_.forEach(scope.filters, function(filter) {
|
||||
var value = searchFilters[filter.friendlyName];
|
||||
if (value) {
|
||||
filter.current = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateURL(filters) {
|
||||
var current = {};
|
||||
_.each(filters, function(filter) {
|
||||
if (filter.current) {
|
||||
current[filter.friendlyName] = filter.current;
|
||||
}
|
||||
});
|
||||
|
||||
var newSearch = angular.extend($location.search(), {
|
||||
filters: angular.toJson(current)
|
||||
});
|
||||
$location.search(newSearch);
|
||||
}
|
||||
|
||||
scope.$watch('queryResult && queryResult.getFilters()', function (filters) {
|
||||
if (filters) {
|
||||
scope.filters = filters;
|
||||
|
||||
if (filters.length && false) {
|
||||
readURL();
|
||||
|
||||
// start watching for changes and update URL
|
||||
scope.$watch('filters', updateURL, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -138,7 +121,7 @@
|
||||
query: '=',
|
||||
queryResult: '=',
|
||||
visualization: '=?',
|
||||
openEditor: '=?',
|
||||
openEditor: '@',
|
||||
onNewSuccess: '=?'
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
@@ -167,9 +150,13 @@
|
||||
scope.$watch('visualization.type', function (type, oldType) {
|
||||
// if not edited by user, set name to match type
|
||||
if (type && oldType != type && scope.visualization && !scope.visForm.name.$dirty) {
|
||||
// poor man's titlecase
|
||||
scope.visualization.name = scope.visualization.type[0] + scope.visualization.type.slice(1).toLowerCase();
|
||||
scope.visualization.name = _.string.titleize(scope.visualization.type);
|
||||
}
|
||||
|
||||
if (type && oldType != type && scope.visualization) {
|
||||
scope.visualization.options = Visualization.visualizations[scope.visualization.type].defaultOptions;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
scope.submit = function () {
|
||||
@@ -208,6 +195,7 @@
|
||||
.provider('Visualization', VisualizationProvider)
|
||||
.directive('visualizationRenderer', ['$location', 'Visualization', VisualizationRenderer])
|
||||
.directive('visualizationOptionsEditor', ['Visualization', VisualizationOptionsEditor])
|
||||
.directive('visualizationName', ['Visualization', VisualizationName])
|
||||
.directive('filters', Filters)
|
||||
.directive('editVisulatizationForm', ['Events', 'Visualization', 'growl', EditVisualizationForm])
|
||||
})();
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
|
||||
_.each($scope.queryResult.getChartData($scope.options.columnMapping), function (s) {
|
||||
var additional = {'stacking': 'normal'};
|
||||
if ('globalSeriesType' in $scope.options) {
|
||||
additional['type'] = $scope.options.globalSeriesType;
|
||||
}
|
||||
if ($scope.options.seriesOptions && $scope.options.seriesOptions[s.name]) {
|
||||
additional = $scope.options.seriesOptions[s.name];
|
||||
if (!additional.name || additional.name == "") {
|
||||
@@ -89,7 +92,7 @@
|
||||
'Pie': 'pie'
|
||||
};
|
||||
|
||||
scope.globalSeriesType = 'column';
|
||||
scope.globalSeriesType = scope.visualization.options.globalSeriesType || 'column';
|
||||
|
||||
scope.stackingOptions = {
|
||||
"None": "none",
|
||||
@@ -109,9 +112,6 @@
|
||||
|
||||
scope.columnTypes = {
|
||||
"X": "x",
|
||||
// "X (Date time)": "x",
|
||||
// "X (Linear)": "x-linear",
|
||||
// "X (Category)": "x-category",
|
||||
"Y": "y",
|
||||
"Series": "series",
|
||||
"Unused": "unused"
|
||||
@@ -125,12 +125,15 @@
|
||||
columnsWatch = null;
|
||||
|
||||
scope.$watch('globalSeriesType', function(type, old) {
|
||||
scope.visualization.options.globalSeriesType = type;
|
||||
|
||||
if (type && old && type !== old && scope.visualization.options.seriesOptions) {
|
||||
_.each(scope.visualization.options.seriesOptions, function(sOptions) {
|
||||
sOptions.type = type;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
scope.$watch('visualization.type', function (visualizationType) {
|
||||
if (visualizationType == 'CHART') {
|
||||
if (scope.visualization.options.series.stacking === null) {
|
||||
@@ -141,6 +144,10 @@
|
||||
scope.stacking = scope.visualization.options.series.stacking;
|
||||
}
|
||||
|
||||
if (scope.visualization.options.sortX === undefined) {
|
||||
scope.visualization.options.sortX = true;
|
||||
}
|
||||
|
||||
var refreshSeries = function() {
|
||||
scope.series = _.map(scope.queryResult.getChartData(scope.visualization.options.columnMapping), function (s) { return s.name; });
|
||||
|
||||
@@ -153,10 +160,10 @@
|
||||
|
||||
_.each(scope.series, function(s, i) {
|
||||
if (scope.visualization.options.seriesOptions[s] == undefined) {
|
||||
scope.visualization.options.seriesOptions[s] = {'type': 'column', 'yAxis': 0};
|
||||
scope.visualization.options.seriesOptions[s] = {'type': scope.visualization.options.globalSeriesType, 'yAxis': 0};
|
||||
}
|
||||
scope.visualization.options.seriesOptions[s].zIndex = i;
|
||||
|
||||
scope.visualization.options.seriesOptions[s].zIndex = scope.visualization.options.seriesOptions[s].zIndex === undefined ? i : scope.visualization.options.seriesOptions[s].zIndex;
|
||||
scope.visualization.options.seriesOptions[s].index = scope.visualization.options.seriesOptions[s].index === undefined ? i : scope.visualization.options.seriesOptions[s].index;
|
||||
});
|
||||
scope.zIndexes = _.range(scope.series.length);
|
||||
scope.yAxes = [[0, 'left'], [1, 'right']];
|
||||
@@ -217,6 +224,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
scope.visualization.options.xAxis = scope.visualization.options.xAxis || {};
|
||||
scope.visualization.options.xAxis.labels = scope.visualization.options.xAxis.labels || {};
|
||||
if (scope.visualization.options.xAxis.labels.enabled === undefined) {
|
||||
scope.visualization.options.xAxis.labels.enabled = true;
|
||||
}
|
||||
|
||||
scope.xAxisType = (scope.visualization.options.xAxis && scope.visualization.options.xAxis.type) || scope.xAxisType;
|
||||
|
||||
xAxisUnwatch = scope.$watch("xAxisType", function (xAxisType) {
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
if ($scope.queryResult.getData() == null) {
|
||||
|
||||
} else {
|
||||
var sortedData = _.sortBy($scope.queryResult.getData(), "date");
|
||||
var sortedData = _.sortBy($scope.queryResult.getData(),function(r) {
|
||||
return r['date'] + r['day_number'] ;
|
||||
});
|
||||
|
||||
var grouped = _.groupBy(sortedData, "date");
|
||||
var maxColumns = _.reduce(grouped, function(memo, data){
|
||||
return (data.length > memo)? data.length : memo;
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
$scope.visualization.options.rowNumber =
|
||||
$scope.visualization.options.rowNumber || 0;
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData() && visualization.options',
|
||||
$scope.$watch('[queryResult && queryResult.getData(), visualization.options]',
|
||||
function() {
|
||||
var queryData = $scope.queryResult.getData();
|
||||
if (queryData) {
|
||||
|
||||
238
rd_ui/app/scripts/visualizations/map.js
Normal file
238
rd_ui/app/scripts/visualizations/map.js
Normal file
@@ -0,0 +1,238 @@
|
||||
'use strict';
|
||||
|
||||
(function() {
|
||||
var module = angular.module('redash.visualization');
|
||||
|
||||
module.config(['VisualizationProvider', function(VisualizationProvider) {
|
||||
var renderTemplate =
|
||||
'<map-renderer ' +
|
||||
'options="visualization.options" query-result="queryResult">' +
|
||||
'</map-renderer>';
|
||||
|
||||
var editTemplate = '<map-editor></map-editor>';
|
||||
var defaultOptions = {
|
||||
'height': 500,
|
||||
'draw': 'Marker',
|
||||
'classify':'none'
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'MAP',
|
||||
name: 'Map',
|
||||
renderTemplate: renderTemplate,
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions: defaultOptions
|
||||
});
|
||||
}
|
||||
]);
|
||||
|
||||
module.directive('mapRenderer', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/map.html',
|
||||
link: function($scope, elm, attrs) {
|
||||
|
||||
var setBounds = function(){
|
||||
var b = $scope.visualization.options.bounds;
|
||||
|
||||
if(b){
|
||||
$scope.map.fitBounds([[b._southWest.lat, b._southWest.lng],[b._northEast.lat, b._northEast.lng]]);
|
||||
} else if ($scope.features.length > 0){
|
||||
var group= new L.featureGroup($scope.features);
|
||||
$scope.map.fitBounds(group.getBounds());
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch('[queryResult && queryResult.getData(), visualization.options.draw,visualization.options.latColName,'+
|
||||
'visualization.options.lonColName,visualization.options.classify,visualization.options.classify]',
|
||||
function() {
|
||||
var marker = function(lat,lon){
|
||||
if (lat == null || lon == null) return;
|
||||
|
||||
return L.marker([lat, lon]);
|
||||
};
|
||||
|
||||
var heatpoint = function(lat,lon,obj){
|
||||
if (lat == null || lon == null) return;
|
||||
|
||||
var color = 'red';
|
||||
|
||||
if (obj &&
|
||||
obj[$scope.visualization.options.classify] &&
|
||||
$scope.visualization.options.classification){
|
||||
var v = $.grep($scope.visualization.options.classification,function(e){
|
||||
return e.value == obj[$scope.visualization.options.classify];
|
||||
});
|
||||
if (v.length >0) color = v[0].color;
|
||||
}
|
||||
|
||||
var style = {
|
||||
fillColor:color,
|
||||
fillOpacity:0.5,
|
||||
stroke:false
|
||||
};
|
||||
|
||||
return L.circleMarker([lat,lon],style)
|
||||
};
|
||||
|
||||
var color = function(val){
|
||||
// taken from http://jsfiddle.net/xgJ2e/2/
|
||||
|
||||
var h= Math.floor((100 - val) * 120 / 100);
|
||||
var s = Math.abs(val - 50)/50;
|
||||
var v = 1;
|
||||
|
||||
var rgb, i, data = [];
|
||||
if (s === 0) {
|
||||
rgb = [v,v,v];
|
||||
} else {
|
||||
h = h / 60;
|
||||
i = Math.floor(h);
|
||||
data = [v*(1-s), v*(1-s*(h-i)), v*(1-s*(1-(h-i)))];
|
||||
switch(i) {
|
||||
case 0:
|
||||
rgb = [v, data[2], data[0]];
|
||||
break;
|
||||
case 1:
|
||||
rgb = [data[1], v, data[0]];
|
||||
break;
|
||||
case 2:
|
||||
rgb = [data[0], v, data[2]];
|
||||
break;
|
||||
case 3:
|
||||
rgb = [data[0], data[1], v];
|
||||
break;
|
||||
case 4:
|
||||
rgb = [data[2], data[0], v];
|
||||
break;
|
||||
default:
|
||||
rgb = [v, data[0], data[1]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return '#' + rgb.map(function(x){
|
||||
return ("0" + Math.round(x*255).toString(16)).slice(-2);
|
||||
}).join('');
|
||||
};
|
||||
|
||||
// Following line is used to avoid "Couldn't autodetect L.Icon.Default.imagePath" error
|
||||
// https://github.com/Leaflet/Leaflet/issues/766#issuecomment-7741039
|
||||
L.Icon.Default.imagePath = L.Icon.Default.imagePath || "//api.tiles.mapbox.com/mapbox.js/v2.2.1/images";
|
||||
|
||||
function getBounds(e) {
|
||||
$scope.visualization.options.bounds = $scope.map.getBounds();
|
||||
}
|
||||
|
||||
var queryData = $scope.queryResult.getData();
|
||||
var classify = $scope.visualization.options.classify;
|
||||
|
||||
if (queryData) {
|
||||
$scope.visualization.options.classification = [];
|
||||
|
||||
for (var row in queryData) {
|
||||
if (queryData[row][classify] &&
|
||||
$.grep($scope.visualization.options.classification, function (e) {
|
||||
return e.value == queryData[row][classify]
|
||||
}).length == 0) {
|
||||
$scope.visualization.options.classification.push({value: queryData[row][classify], color: null});
|
||||
}
|
||||
}
|
||||
|
||||
$.each($scope.visualization.options.classification, function (i, c) {
|
||||
c.color = color(parseInt((i / $scope.visualization.options.classification.length) * 100));
|
||||
});
|
||||
|
||||
if (!$scope.map) {
|
||||
$scope.map = L.map(elm[0].children[0].children[0])
|
||||
}
|
||||
|
||||
L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo($scope.map);
|
||||
|
||||
$scope.features = $scope.features || [];
|
||||
|
||||
var tmp_features = [];
|
||||
|
||||
var lat_col = $scope.visualization.options.latColName || 'lat';
|
||||
var lon_col = $scope.visualization.options.lonColName || 'lon';
|
||||
|
||||
for (var row in queryData) {
|
||||
var feature;
|
||||
|
||||
if ($scope.visualization.options.draw == 'Marker') {
|
||||
feature = marker(queryData[row][lat_col], queryData[row][lon_col])
|
||||
} else if ($scope.visualization.options.draw == 'Color') {
|
||||
feature = heatpoint(queryData[row][lat_col], queryData[row][lon_col], queryData[row])
|
||||
}
|
||||
|
||||
if (!feature) continue;
|
||||
|
||||
var obj_description = '<ul style="list-style-type: none;padding-left: 0">';
|
||||
for (var k in queryData[row]){
|
||||
obj_description += "<li>" + k + ": " + queryData[row][k] + "</li>";
|
||||
}
|
||||
obj_description += '</ul>';
|
||||
feature.bindPopup(obj_description);
|
||||
tmp_features.push(feature);
|
||||
}
|
||||
|
||||
$.each($scope.features, function (i, f) {
|
||||
$scope.map.removeLayer(f);
|
||||
});
|
||||
|
||||
$scope.features = tmp_features;
|
||||
|
||||
$.each($scope.features, function (i, f) {
|
||||
f.addTo($scope.map)
|
||||
});
|
||||
|
||||
setBounds();
|
||||
|
||||
$scope.map.on('focus',function(){
|
||||
$scope.map.on('moveend', getBounds);
|
||||
});
|
||||
|
||||
$scope.map.on('blur',function(){
|
||||
$scope.map.off('moveend', getBounds);
|
||||
});
|
||||
|
||||
|
||||
// We redraw the map if it was loaded in a hidden tab
|
||||
if ($('a[href="#'+$scope.visualization.id+'"]').length > 0) {
|
||||
|
||||
$('a[href="#'+$scope.visualization.id+'"]').on('click', function () {
|
||||
setTimeout(function() {
|
||||
$scope.map.invalidateSize(false);
|
||||
|
||||
setBounds();
|
||||
},500);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}, true);
|
||||
|
||||
$scope.$watch('visualization.options.height', function() {
|
||||
|
||||
if (!$scope.map) return;
|
||||
$scope.map.invalidateSize(false);
|
||||
setBounds();
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.directive('mapEditor', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/map_editor.html',
|
||||
link: function($scope, elm, attrs) {
|
||||
$scope.draw_options = ['Marker','Color'];
|
||||
$scope.classify_columns = $scope.queryResult.columnNames.concat('none');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -78,15 +78,15 @@
|
||||
};
|
||||
} else if (columnType === 'date') {
|
||||
columnDefinition.formatFunction = function (value) {
|
||||
if (value) {
|
||||
return value.format("DD/MM/YY");
|
||||
if (value && moment.isMoment(value)) {
|
||||
return value.toDate().toLocaleDateString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
} else if (columnType === 'datetime') {
|
||||
columnDefinition.formatFunction = function (value) {
|
||||
if (value) {
|
||||
return value.format("DD/MM/YY HH:mm");
|
||||
if (value && moment.isMoment(value)) {
|
||||
return value.toDate().toLocaleString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -14,7 +14,12 @@ a.page-title {
|
||||
}
|
||||
|
||||
a.navbar-brand {
|
||||
font-style: italic;
|
||||
padding: 5px 5px 0px 0px;
|
||||
margin-left: 0px !important;
|
||||
}
|
||||
|
||||
a.navbar-brand img {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.graph {
|
||||
@@ -92,7 +97,16 @@ a.navbar-brand {
|
||||
}
|
||||
|
||||
.panel-heading .query-link:hover {
|
||||
text-decoration: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.list-group-item.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-group-item.clickable:focus,
|
||||
.list-group-item.clickable:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* angular-growl */
|
||||
@@ -273,7 +287,6 @@ pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div
|
||||
counter-renderer {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
}
|
||||
counter-renderer counter {
|
||||
margin: 0 auto;
|
||||
@@ -283,7 +296,8 @@ counter-renderer counter {
|
||||
}
|
||||
counter-renderer value,
|
||||
counter-renderer counter-target {
|
||||
font-size: 130px;
|
||||
font-size: 80px;
|
||||
display: block;
|
||||
}
|
||||
counter-renderer counter-target {
|
||||
color: #ccc;
|
||||
@@ -308,6 +322,28 @@ counter-renderer counter-name {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.schema-container {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.schema-browser {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
div.table-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.blankslate {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/*
|
||||
bootstrap's hidden-xs class adds display:block when not hidden
|
||||
use this class when you need to keep the original display value
|
||||
@@ -317,3 +353,7 @@ use this class when you need to keep the original display value
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.log-container {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
@@ -24,10 +24,6 @@
|
||||
<span class="badge">{{manager.outdated_queries_count}}</span>
|
||||
Outdated Queries Count
|
||||
</li>
|
||||
|
||||
<li class="list-group-item" ng-if="flowerUrl">
|
||||
<a href="/admin/workers">Workers' Status</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="list-group col-lg-4">
|
||||
<li class="list-group-item active">Queues</li>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<div class="container-fluid iframe-container">
|
||||
<iframe src="{{flowerUrl}}" style="width:100%; height:100%; background-color:transparent;"></iframe>
|
||||
</div>
|
||||
58
rd_ui/app/views/alerts/edit.html
Normal file
58
rd_ui/app/views/alerts/edit.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/alerts">Alerts</a></li>
|
||||
<li class="active">{{alert.name || getDefaultName() || "New"}}</li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<form name="alertForm" ng-submit="saveChanges()" class="form">
|
||||
<div class="form-group">
|
||||
<label>Query</label>
|
||||
<ui-select ng-model="alert.query" theme="bootstrap" reset-search-input="false" on-select="onQuerySelected($item)">
|
||||
<ui-select-match placeholder="Search a query by name">{{$select.selected.name}}</ui-select-match>
|
||||
<ui-select-choices repeat="q in queries"
|
||||
refresh="searchQueries($select.search)"
|
||||
refresh-delay="0">
|
||||
<div ng-bind-html="q.name | highlight: $select.search | trustAsHtml"></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-show="selectedQuery">
|
||||
<label>Name</label>
|
||||
<input type="string" placeholder="{{getDefaultName()}}" class="form-control" ng-model="alert.name">
|
||||
</div>
|
||||
|
||||
<div ng-show="queryResult" class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-2">Value column</label>
|
||||
<div class="col-md-4">
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="alert.options.column" class="form-control"></select>
|
||||
</div>
|
||||
<label class="control-label col-md-2">Value</label>
|
||||
<div class="col-md-4">
|
||||
<p class="form-control-static">{{queryResult.getData()[0][alert.options.column]}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-2">Op</label>
|
||||
<div class="col-md-4">
|
||||
<select ng-options="name for name in ops" ng-model="alert.options.op" class="form-control"></select>
|
||||
</div>
|
||||
<label class="control-label col-md-2">Reference</label>
|
||||
<div class="col-md-4">
|
||||
<input type="number" class="form-control" ng-model="alert.options.value" placeholder="reference value" required/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" ng-disabled="!alertForm.$valid">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-4" ng-if="alert.id">
|
||||
<alert-subscribers alert-id="alert.id"></alert-subscribers>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
16
rd_ui/app/views/alerts/list.html
Normal file
16
rd_ui/app/views/alerts/list.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li class="active">Alerts</li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p>
|
||||
<a href="/alerts/new" class="btn btn-default"><i class="fa fa-plus"></i> New Alert</a>
|
||||
</p>
|
||||
|
||||
<smart-table rows="alerts" columns="gridColumns"
|
||||
config="gridConfig"
|
||||
class="table table-condensed table-hover"></smart-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
4
rd_ui/app/views/alerts/subscribers.html
Normal file
4
rd_ui/app/views/alerts/subscribers.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<strong>Subscribers</strong> <subscribe-button alert-id="alertId" subscribers="subscribers"></subscribe-button><br/>
|
||||
<img ng-src="{{s.user.gravatar_url}}" class="img-circle" alt="{{s.user.name}}" ng-repeat="s in subscribers"/>
|
||||
</div>
|
||||
@@ -14,7 +14,7 @@
|
||||
</button>
|
||||
</span>
|
||||
</h2>
|
||||
<filters></filters>
|
||||
<filters ng-if="dashboard.dashboard_filters_enabled"></filters>
|
||||
</div>
|
||||
|
||||
<div class="container" id="dashboard">
|
||||
@@ -28,6 +28,7 @@
|
||||
<p>
|
||||
<span ng-hide="currentUser.hasPermission('view_query')">{{query.name}}</span>
|
||||
<query-link query="query" visualization="widget.visualization" ng-show="currentUser.hasPermission('view_query')"></query-link>
|
||||
<visualization-name visualization="widget.visualization"/>
|
||||
</p>
|
||||
<div class="text-muted" ng-bind-html="query.description | markdown"></div>
|
||||
</h3>
|
||||
@@ -37,7 +38,7 @@
|
||||
|
||||
<div class="panel-footer">
|
||||
<span class="label label-default"
|
||||
tooltip="next update {{nextUpdateTime}} (query runtime: {{queryResult.getRuntime() | durationHumanize}})"
|
||||
tooltip="(query runtime: {{queryResult.getRuntime() | durationHumanize}})"
|
||||
tooltip-placement="bottom">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
|
||||
|
||||
<span class="pull-right">
|
||||
|
||||
11
rd_ui/app/views/data_sources/edit.html
Normal file
11
rd_ui/app/views/data_sources/edit.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/data_sources">Data Sources</a></li>
|
||||
<li class="active">{{dataSource.name || "New"}}</li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<data-source-form data-data-source="dataSource" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
20
rd_ui/app/views/data_sources/form.html
Normal file
20
rd_ui/app/views/data_sources/form.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<form name="dataSourceForm" ng-submit="saveChanges()">
|
||||
<div class="form-group">
|
||||
<label for="dataSourceName">Name</label>
|
||||
<input type="string" class="form-control" name="dataSourceName" ng-model="dataSource.name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="type">Type</label>
|
||||
<select name="type" class="form-control" ng-options="type.type as type.name for type in dataSourceTypes" ng-model="dataSource.type"></select>
|
||||
</div>
|
||||
<div class="form-group" ng-class='{"has-error": !inner.input.$valid}' ng-form="inner" ng-repeat="(name, input) in type.configuration_schema.properties">
|
||||
<label>{{input.title || name | capitalize}}</label>
|
||||
<input name="input" type="{{input.type}}" class="form-control" ng-model="dataSource.options[name]" ng-required="input.required"
|
||||
ng-if="input.type !== 'file'" accesskey="tab">
|
||||
|
||||
<input name="input" type="file" class="form-control" ng-model="files[name]" ng-required="input.required"
|
||||
base-sixty-four-input
|
||||
ng-if="input.type === 'file'">
|
||||
</div>
|
||||
<button class="btn btn-primary" ng-disabled="!dataSourceForm.$valid">Save</button>
|
||||
</form>
|
||||
18
rd_ui/app/views/data_sources/list.html
Normal file
18
rd_ui/app/views/data_sources/list.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li class="active">Data Sources</li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item clickable" ng-repeat="dataSource in dataSources" ng-click="openDataSource(dataSource)">
|
||||
<i class="fa fa-database"></i> {{dataSource.name}}
|
||||
<button class="btn btn-xs btn-danger pull-right" ng-click="deleteDataSource($event, dataSource)">Delete</button>
|
||||
</div>
|
||||
<a ng-href="/data_sources/new" class="list-group-item">
|
||||
<i class="fa fa-plus"></i> Add Data Source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
28
rd_ui/app/views/personal.html
Normal file
28
rd_ui/app/views/personal.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="list-group col-md-6">
|
||||
<div class="list-group-item active">
|
||||
Recent Dashboards
|
||||
<button ng-show="currentUser.hasPermission('create_dashboard')" type="button" class="btn btn-sm btn-link" data-toggle="modal" href="#new_dashboard_dialog" tooltip="New Dashboard"><span class="glyphicon glyphicon-plus-sign"></span></button>
|
||||
</div>
|
||||
<div class="list-group-item" ng-repeat="dashboard in recentDashboards" >
|
||||
<button type="button" class="close delete-button" aria-hidden="true" ng-show="dashboard.canEdit()" ng-click="archiveDashboard(dashboard)" tooltip="Delete Dashboard">×</button>
|
||||
<a ng-href="/dashboard/{{dashboard.slug}}">{{dashboard.name}}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group col-md-6">
|
||||
<div class="list-group-item active">
|
||||
Recent Queries
|
||||
</div>
|
||||
<a ng-href="/queries/{{query.id}}" class="list-group-item" ng-repeat="query in recentQueries">{{query.name}}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="currentUser.hasPermission('admin')" class="row">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item active">Admin</div>
|
||||
<a href="/admin/status" class="list-group-item">Status</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
<div class="container">
|
||||
|
||||
<p class="alert alert-warning" ng-if="query.is_archived">This query is archived and can't be used in dashboards, and won't appear in search results.</p>
|
||||
<alert-unsaved-changes ng-if="canEdit" is-dirty="isDirty"></alert-unsaved-changes>
|
||||
|
||||
<div class="row">
|
||||
@@ -58,9 +59,9 @@
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div ng-show="sourceMode">
|
||||
<div class="row" ng-if="sourceMode">
|
||||
<div ng-class="editorSize">
|
||||
<div>
|
||||
<p>
|
||||
<button type="button" class="btn btn-primary btn-xs" ng-disabled="queryExecuting" ng-click="executeQuery()">
|
||||
<span class="glyphicon glyphicon-play"></span> Execute
|
||||
@@ -76,21 +77,43 @@
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- code editor -->
|
||||
<div ng-show="sourceMode">
|
||||
<p>
|
||||
<query-editor query="query" lock="queryExecuting"></query-editor>
|
||||
<query-editor query="query" schema="schema" syntax="dataSource.syntax" lock="queryFormatting"></query-editor>
|
||||
</p>
|
||||
<hr>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 schema-container" ng-show="hasSchema">
|
||||
<div ng-show="schema.length < 200">
|
||||
<input type="text" placeholder="Search schema..." class="form-control" ng-model="schemaFilter">
|
||||
</div>
|
||||
<div class="schema-browser">
|
||||
<div ng-repeat="table in schema | filter:schemaFilter track by table.name">
|
||||
<div class="table-name" ng-click="table.collapsed = !table.collapsed">
|
||||
<i class="fa fa-table"></i> <strong><span title="{{table.name}}">{{table.name}}</span></strong>
|
||||
</div>
|
||||
<div collapse="table.collapsed && !schemaFilter">
|
||||
<div ng-repeat="column in table.columns track by column" style="padding-left:16px;">{{column}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<hr ng-if="sourceMode">
|
||||
<div class="row">
|
||||
<div class="col-lg-3 rd-hidden-xs">
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-user"></span>
|
||||
<span class="text-muted">Created By </span>
|
||||
<strong>{{query.user.name}}</strong>
|
||||
</p>
|
||||
<p ng-if="query.last_modified_by && query.user.id != query.last_modified_by.id">
|
||||
<span class="glyphicon glyphicon-user"></span>
|
||||
<span class="text-muted">Last Modified By </span>
|
||||
<strong>{{query.last_modified_by.name}}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-time"></span>
|
||||
<span class="text-muted">Last update </span>
|
||||
@@ -98,12 +121,6 @@
|
||||
<rd-time-ago value="queryResult.query_result.retrieved_at"></rd-time-ago>
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-user"></span>
|
||||
<span class="text-muted">Created By </span>
|
||||
<strong ng-hide="isQueryOwner">{{query.user.name}}</strong>
|
||||
<strong ng-show="isQueryOwner">You</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-play"></span>
|
||||
<span class="text-muted">Runtime </span>
|
||||
@@ -116,12 +133,12 @@
|
||||
</p>
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-refresh"></span>
|
||||
<span class="text-muted">Refresh Interval</span>
|
||||
<query-refresh-select></query-refresh-select>
|
||||
<span class="text-muted">Refresh Schedule</span>
|
||||
<a href="" ng-click="openScheduleForm()">{{query.schedule | scheduleHumanize}}</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-hdd"></span>
|
||||
<i class="fa fa-database"></i>
|
||||
<span class="text-muted">Data Source</span>
|
||||
<select ng-disabled="!isQueryOwner" ng-model="query.data_source_id" ng-change="updateDataSource()" ng-options="ds.id as ds.name for ds in dataSources"></select>
|
||||
</p>
|
||||
@@ -133,6 +150,33 @@
|
||||
<span class="glyphicon glyphicon-cloud-download"></span>
|
||||
<span class="rd-hidden-xs">Download Dataset</span>
|
||||
</a>
|
||||
|
||||
<a class="btn btn-warning btn-sm" ng-disabled="queryExecuting" data-toggle="modal" data-target="#archive-confirmation-modal"
|
||||
ng-show="!query.is_archived && query.id != undefined && (isQueryOwner || currentUser.hasPermission('admin'))">
|
||||
<i class="fa fa-archive" title="Archive Query"></i>
|
||||
</a>
|
||||
|
||||
<button class="btn btn-default btn-sm" ng-show="query.id != undefined" ng-click="showApiKey()">
|
||||
<i class="fa fa-key" title="Show API Key"></i>
|
||||
</button>
|
||||
|
||||
<div class="modal fade" id="archive-confirmation-modal" tabindex="-1" role="dialog" aria-labelledby="archiveConfirmationModal" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Query Archive</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Are you sure you want to archive this query? <br/>
|
||||
All dashboard widgets created with its visualizations will be deleted.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">No</button>
|
||||
<button type="button" class="btn btn-primary" ng-click="archiveQuery()">Yes, archive.</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -148,6 +192,16 @@
|
||||
</div>
|
||||
<div class="alert alert-danger" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong></div>
|
||||
|
||||
<div class="row log-container" ng-show="showLog">
|
||||
<span ng-show="showLog">Log Information:</span>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr ng-repeat="l in queryResult.getLog()">
|
||||
<td>{{l}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- tabs and data -->
|
||||
<div ng-show="showDataset">
|
||||
<div class="row">
|
||||
@@ -158,7 +212,7 @@
|
||||
<rd-tab tab-id="{{vis.id}}" name="{{vis.name}}" ng-if="vis.type!='TABLE'" ng-repeat="vis in query.visualizations">
|
||||
<span class="remove" ng-click="deleteVisualization($event, vis)" ng-show="canEdit"> ×</span>
|
||||
</rd-tab>
|
||||
<rd-tab tab-id="add" name="+ New" removeable="true" ng-show="canEdit"></rd-tab>
|
||||
<rd-tab tab-id="add" name="+ New Visualization" removeable="true" ng-show="canEdit"></rd-tab>
|
||||
<li ng-if="!sourceMode" class="rd-tab-btn"><button class="btn btn-sm btn-default" ng-click="executeQuery()" ng-disabled="queryExecuting" title="Refresh Dataset"><span class="glyphicon glyphicon-refresh"></span></button></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
18
rd_ui/app/views/schedule_form.html
Normal file
18
rd_ui/app/views/schedule_form.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" aria-label="Close" ng-click="close()"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">Refresh Schedule</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" value="periodic" ng-model="refreshType">
|
||||
<query-refresh-select ng-disabled="refreshType != 'periodic'"></query-refresh-select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" value="daily" ng-model="refreshType">
|
||||
<query-time-picker ng-disabled="refreshType != 'daily'"></query-time-picker>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,6 +27,41 @@
|
||||
ng-model="globalSeriesType" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-2">y Axis min</label>
|
||||
|
||||
<div class="col-sm-10">
|
||||
<input name="yAxisMin" type="number" class="form-control"
|
||||
ng-model="visualization.options.yAxis.min"
|
||||
placeholder="Auto">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-2">y Axis max</label>
|
||||
|
||||
<div class="col-sm-10">
|
||||
<input name="yAxisMin" type="number" class="form-control"
|
||||
ng-model="visualization.options.yAxis.max"
|
||||
placeholder="Auto">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-2">Sort X Values</label>
|
||||
|
||||
<div class="col-sm-10">
|
||||
<input name="sortX" type="checkbox" class="form-control"
|
||||
ng-model="visualization.options.sortX">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-2">Show X Axis Labels</label>
|
||||
|
||||
<div class="col-sm-10">
|
||||
<input name="sortX" type="checkbox" class="form-control"
|
||||
ng-model="visualization.options.xAxis.labels.enabled">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +108,15 @@
|
||||
class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">Index</label>
|
||||
|
||||
<div class="col-sm-9">
|
||||
<select required ng-model="visualization.options.seriesOptions[seriesName].index"
|
||||
ng-options="o as o for o in zIndexes"
|
||||
class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">y Axis</label>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<counter ng-class="{'positive': targetValue && trendPositive, 'negative': targetValue && !trendPositive}">
|
||||
<value>{{counterValue}}</value>
|
||||
<counter-target ng-if="targetValue">({{targetValue}})</counter-target>
|
||||
<value>{{counterValue|number}}</value>
|
||||
<counter-target ng-if="targetValue">({{targetValue|number}})</counter-target>
|
||||
<counter-name>{{visualization.name}}</counter-name>
|
||||
</counter>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div>
|
||||
<span ng-click="openEditor=!openEditor" class="details-toggle" ng-class="{open: openEditor}">Edit</span>
|
||||
|
||||
<form ng-if="openEditor" role="form" name="visForm" ng-submit="submit()">
|
||||
<form ng-show="openEditor" role="form" name="visForm" ng-submit="submit()">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Name</label>
|
||||
<input name="name" type="text" class="form-control" ng-model="visualization.name" placeholder="{{visualization.type | capitalize}}">
|
||||
|
||||
3
rd_ui/app/views/visualizations/map.html
Normal file
3
rd_ui/app/views/visualizations/map.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div style='margin:1%;width:98%;height:{{visualization.options.height}}px'>
|
||||
<div style="width:100%; height:100%;"></div>
|
||||
</div>
|
||||
55
rd_ui/app/views/visualizations/map_editor.html
Normal file
55
rd_ui/app/views/visualizations/map_editor.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="col-lg-2">Map height (px)</label>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="number" ng-model = "visualization.options.height" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-lg-2">Draw option</label>
|
||||
<div class="col-sm-4">
|
||||
<select ng-options="opt for opt in draw_options" ng-model="visualization.options.draw" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-2">Latitude column name</label>
|
||||
<div class="col-sm-4">
|
||||
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.latColName" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-2">Longitude column name</label>
|
||||
<div class="col-sm-4">
|
||||
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.lonColName" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show = "visualization.options.draw == 'Color'">
|
||||
<div class="form-group">
|
||||
<label class="col-lg-2">Classify by column</label>
|
||||
<div class="col-sm-4">
|
||||
<select ng-options="name for name in classify_columns" ng-model="visualization.options.classify" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" >
|
||||
<div class="col-lg-6">
|
||||
<div ng-repeat="element in visualization.options.classification" class="list-group">
|
||||
<div class="list-group-item active">
|
||||
{{element.value}}
|
||||
</div>
|
||||
|
||||
<div class="list-group-item">
|
||||
<div class="form-group">
|
||||
<label class="col-lg-4">Color</label>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" style="background-color:{{element.color}};" type="text" ng-model = "element.color" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,8 +12,7 @@
|
||||
"es5-shim": "2.0.8",
|
||||
"angular-moment": "0.2.0",
|
||||
"moment": "2.1.0",
|
||||
"angular-ui-bootstrap": "0.5.0",
|
||||
"angular-ui-codemirror": "0.0.5",
|
||||
"codemirror": "4.8.0",
|
||||
"highcharts": "3.0.10",
|
||||
"underscore": "1.5.1",
|
||||
"pivottable": "~1.1.1",
|
||||
@@ -26,7 +25,13 @@
|
||||
"marked": "~0.3.2",
|
||||
"bucky": "~0.2.6",
|
||||
"pace": "~0.5.1",
|
||||
"angular-ui-select": "0.8.2"
|
||||
"angular-ui-select": "~0.12.0",
|
||||
"font-awesome": "~4.2.0",
|
||||
"mustache": "~1.0.0",
|
||||
"canvg": "gabelerner/canvg",
|
||||
"angular-ui-bootstrap-bower": "~0.12.1",
|
||||
"leaflet": "~0.7.3",
|
||||
"angular-base64-upload": "~0.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"angular-mocks": "1.2.18",
|
||||
|
||||
BIN
rd_ui/favicon.ico
Executable file
BIN
rd_ui/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -36,6 +36,7 @@
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "grunt test"
|
||||
"test": "grunt test",
|
||||
"bower": "bower"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ module.exports = function(config) {
|
||||
'app/scripts/ui-bootstrap-tpls-0.5.0.min.js',
|
||||
'app/bower_components/bucky/bucky.js',
|
||||
'app/bower_components/pace/pace.js',
|
||||
'app/bower_components/mustache/mustache.js',
|
||||
|
||||
'app/scripts/app.js',
|
||||
'app/scripts/services/services.js',
|
||||
|
||||
@@ -2,10 +2,12 @@ import logging
|
||||
import urlparse
|
||||
import redis
|
||||
from statsd import StatsClient
|
||||
from flask_mail import Mail
|
||||
|
||||
from redash import settings
|
||||
from redash.query_runner import import_query_runners
|
||||
|
||||
__version__ = '0.4.0'
|
||||
__version__ = '0.7.0'
|
||||
|
||||
|
||||
def setup_logging():
|
||||
@@ -14,6 +16,7 @@ def setup_logging():
|
||||
handler.setFormatter(formatter)
|
||||
logging.getLogger().addHandler(handler)
|
||||
logging.getLogger().setLevel(settings.LOG_LEVEL)
|
||||
logging.getLogger("passlib").setLevel("ERROR")
|
||||
|
||||
|
||||
def create_redis_connection():
|
||||
@@ -30,4 +33,8 @@ def create_redis_connection():
|
||||
|
||||
setup_logging()
|
||||
redis_connection = create_redis_connection()
|
||||
statsd_client = StatsClient(host=settings.STATSD_HOST, port=settings.STATSD_PORT, prefix=settings.STATSD_PREFIX)
|
||||
mail = Mail()
|
||||
mail.init_mail(settings.all_settings())
|
||||
statsd_client = StatsClient(host=settings.STATSD_HOST, port=settings.STATSD_PORT, prefix=settings.STATSD_PREFIX)
|
||||
|
||||
import_query_runners(settings.QUERY_RUNNERS)
|
||||
|
||||
109
redash/admin.py
Normal file
109
redash/admin.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import json
|
||||
from flask_admin.contrib.peewee import ModelView
|
||||
from flask.ext.admin import Admin
|
||||
from flask_admin.contrib.peewee.form import CustomModelConverter
|
||||
from flask_admin.form.widgets import DateTimePickerWidget
|
||||
from playhouse.postgres_ext import ArrayField, DateTimeTZField
|
||||
from wtforms import fields
|
||||
from wtforms.widgets import TextInput
|
||||
|
||||
from redash import models
|
||||
from redash import query_runner
|
||||
from redash.permissions import require_permission
|
||||
|
||||
|
||||
class ArrayListField(fields.Field):
|
||||
widget = TextInput()
|
||||
|
||||
def _value(self):
|
||||
if self.data:
|
||||
return u', '.join(self.data)
|
||||
else:
|
||||
return u''
|
||||
|
||||
def process_formdata(self, valuelist):
|
||||
if valuelist:
|
||||
self.data = [x.strip() for x in valuelist[0].split(',')]
|
||||
else:
|
||||
self.data = []
|
||||
|
||||
|
||||
class JSONTextAreaField(fields.TextAreaField):
|
||||
def process_formdata(self, valuelist):
|
||||
if valuelist:
|
||||
try:
|
||||
json.loads(valuelist[0])
|
||||
except ValueError:
|
||||
raise ValueError(self.gettext(u'Invalid JSON'))
|
||||
self.data = valuelist[0]
|
||||
else:
|
||||
self.data = ''
|
||||
|
||||
class PasswordHashField(fields.PasswordField):
|
||||
def _value(self):
|
||||
return u''
|
||||
|
||||
def process_formdata(self, valuelist):
|
||||
if valuelist:
|
||||
self.data = models.pwd_context.encrypt(valuelist[0])
|
||||
else:
|
||||
self.data = u''
|
||||
|
||||
|
||||
class PgModelConverter(CustomModelConverter):
|
||||
def __init__(self, view, additional=None):
|
||||
additional = {ArrayField: self.handle_array_field,
|
||||
DateTimeTZField: self.handle_datetime_tz_field,
|
||||
}
|
||||
super(PgModelConverter, self).__init__(view, additional)
|
||||
self.view = view
|
||||
|
||||
def handle_array_field(self, model, field, **kwargs):
|
||||
return field.name, ArrayListField(**kwargs)
|
||||
|
||||
def handle_datetime_tz_field(self, model, field, **kwargs):
|
||||
kwargs['widget'] = DateTimePickerWidget()
|
||||
return field.name, fields.DateTimeField(**kwargs)
|
||||
|
||||
|
||||
class BaseModelView(ModelView):
|
||||
model_form_converter = PgModelConverter
|
||||
|
||||
@require_permission('admin')
|
||||
def is_accessible(self):
|
||||
return True
|
||||
|
||||
|
||||
class UserModelView(BaseModelView):
|
||||
column_searchable_list = ('name', 'email')
|
||||
form_excluded_columns = ('created_at', 'updated_at')
|
||||
column_exclude_list = ('password_hash',)
|
||||
|
||||
form_overrides = dict(password_hash=PasswordHashField)
|
||||
form_args = {
|
||||
'password_hash': {'label': 'Password'}
|
||||
}
|
||||
|
||||
|
||||
class QueryResultModelView(BaseModelView):
|
||||
column_exclude_list = ('data',)
|
||||
|
||||
|
||||
class QueryModelView(BaseModelView):
|
||||
column_exclude_list = ('latest_query_data',)
|
||||
|
||||
|
||||
class DashboardModelView(BaseModelView):
|
||||
column_searchable_list = ('name', 'slug')
|
||||
|
||||
|
||||
def init_admin(app):
|
||||
admin = Admin(app, name='re:dash admin', template_mode='bootstrap3')
|
||||
|
||||
admin.add_view(UserModelView(models.User))
|
||||
admin.add_view(QueryModelView(models.Query))
|
||||
admin.add_view(QueryResultModelView(models.QueryResult))
|
||||
admin.add_view(DashboardModelView(models.Dashboard))
|
||||
|
||||
for m in (models.Visualization, models.Widget, models.ActivityLog, models.Group, models.Event):
|
||||
admin.add_view(BaseModelView(m))
|
||||
@@ -1,13 +1,13 @@
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import request, make_response, redirect, url_for
|
||||
from flask.ext.login import LoginManager, login_user, current_user
|
||||
from flask.ext.login import LoginManager
|
||||
from flask.ext.login import user_logged_in
|
||||
|
||||
from redash import models, settings, google_oauth
|
||||
from redash import models, settings, google_oauth, saml_auth
|
||||
from redash.tasks import record_event
|
||||
|
||||
login_manager = LoginManager()
|
||||
logger = logging.getLogger('authentication')
|
||||
@@ -23,47 +23,85 @@ def sign(key, path, expires):
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
class HMACAuthentication(object):
|
||||
@staticmethod
|
||||
def api_key_authentication():
|
||||
signature = request.args.get('signature')
|
||||
expires = float(request.args.get('expires') or 0)
|
||||
query_id = request.view_args.get('query_id', None)
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return models.User.get_by_id(user_id)
|
||||
|
||||
# TODO: 3600 should be a setting
|
||||
if signature and query_id and time.time() < expires <= time.time() + 3600:
|
||||
|
||||
def hmac_load_user_from_request(request):
|
||||
signature = request.args.get('signature')
|
||||
expires = float(request.args.get('expires') or 0)
|
||||
query_id = request.view_args.get('query_id', None)
|
||||
user_id = request.args.get('user_id', None)
|
||||
|
||||
# TODO: 3600 should be a setting
|
||||
if signature and time.time() < expires <= time.time() + 3600:
|
||||
if user_id:
|
||||
user = models.User.get_by_id(user_id)
|
||||
calculated_signature = sign(user.api_key, request.path, expires)
|
||||
|
||||
if user.api_key and signature == calculated_signature:
|
||||
return user
|
||||
|
||||
if query_id:
|
||||
query = models.Query.get(models.Query.id == query_id)
|
||||
calculated_signature = sign(query.api_key, request.path, expires)
|
||||
|
||||
if query.api_key and signature == calculated_signature:
|
||||
login_user(models.ApiUser(query.api_key), remember=False)
|
||||
return True
|
||||
return models.ApiUser(query.api_key)
|
||||
|
||||
return False
|
||||
return None
|
||||
|
||||
def required(self, fn):
|
||||
@functools.wraps(fn)
|
||||
def decorated(*args, **kwargs):
|
||||
if current_user.is_authenticated():
|
||||
return fn(*args, **kwargs)
|
||||
def get_user_from_api_key(api_key, query_id):
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
if self.api_key_authentication():
|
||||
return fn(*args, **kwargs)
|
||||
user = None
|
||||
try:
|
||||
user = models.User.get_by_api_key(api_key)
|
||||
except models.User.DoesNotExist:
|
||||
if query_id:
|
||||
query = models.Query.get_by_id(query_id)
|
||||
if query and query.api_key == api_key:
|
||||
user = models.ApiUser(api_key)
|
||||
|
||||
return make_response(redirect(url_for("login", next=request.url)))
|
||||
return user
|
||||
|
||||
return decorated
|
||||
def api_key_load_user_from_request(request):
|
||||
api_key = request.args.get('api_key', None)
|
||||
query_id = request.view_args.get('query_id', None)
|
||||
|
||||
user = get_user_from_api_key(api_key, query_id)
|
||||
return user
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return models.User.select().where(models.User.id == user_id).first()
|
||||
def log_user_logged_in(app, user):
|
||||
event = {
|
||||
'user_id': user.id,
|
||||
'action': 'login',
|
||||
'object_type': 'redash',
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
|
||||
record_event.delay(event)
|
||||
|
||||
|
||||
def setup_authentication(app):
|
||||
login_manager.init_app(app)
|
||||
login_manager.anonymous_user = models.AnonymousUser
|
||||
login_manager.login_view = 'login'
|
||||
app.secret_key = settings.COOKIE_SECRET
|
||||
app.register_blueprint(google_oauth.blueprint)
|
||||
app.register_blueprint(saml_auth.blueprint)
|
||||
|
||||
user_logged_in.connect(log_user_logged_in)
|
||||
|
||||
if settings.AUTH_TYPE == 'hmac':
|
||||
login_manager.request_loader(hmac_load_user_from_request)
|
||||
elif settings.AUTH_TYPE == 'api_key':
|
||||
login_manager.request_loader(api_key_load_user_from_request)
|
||||
else:
|
||||
logger.warning("Unknown authentication type ({}). Using default (HMAC).".format(settings.AUTH_TYPE))
|
||||
login_manager.request_loader(hmac_load_user_from_request)
|
||||
|
||||
|
||||
return HMACAuthentication()
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
from flask import make_response
|
||||
from functools import update_wrapper
|
||||
|
||||
ONE_YEAR = 60 * 60 * 24 * 365.25
|
||||
|
||||
headers = {
|
||||
|
||||
129
redash/cli/data_sources.py
Normal file
129
redash/cli/data_sources.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import click
|
||||
from flask.ext.script import Manager
|
||||
from redash import models
|
||||
from redash.query_runner import query_runners, validate_configuration
|
||||
|
||||
manager = Manager(help="Data sources management commands.")
|
||||
|
||||
@manager.command
|
||||
def list():
|
||||
"""List currently configured data sources"""
|
||||
for i, ds in enumerate(models.DataSource.select()):
|
||||
if i > 0:
|
||||
print "-"*20
|
||||
|
||||
print "Id: {}\nName: {}\nType: {}\nOptions: {}".format(ds.id, ds.name, ds.type, ds.options)
|
||||
|
||||
|
||||
def validate_data_source_type(type):
|
||||
if type not in query_runners.keys():
|
||||
print "Error: the type \"{}\" is not supported (supported types: {}).".format(type, ", ".join(query_runners.keys()))
|
||||
exit()
|
||||
|
||||
|
||||
def validate_data_source_options(type, options):
|
||||
if not validate_configuration(type, options):
|
||||
print "Error: invalid configuration."
|
||||
exit()
|
||||
|
||||
@manager.command
|
||||
def new(name=None, type=None, options=None):
|
||||
"""Create new data source"""
|
||||
if name is None:
|
||||
name = click.prompt("Name")
|
||||
|
||||
if type is None:
|
||||
print "Select type:"
|
||||
for i, query_runner_name in enumerate(query_runners.keys()):
|
||||
print "{}. {}".format(i+1, query_runner_name)
|
||||
|
||||
idx = 0
|
||||
while idx < 1 or idx > len(query_runners.keys()):
|
||||
idx = click.prompt("[{}-{}]".format(1, len(query_runners.keys())), type=int)
|
||||
|
||||
type = query_runners.keys()[idx-1]
|
||||
else:
|
||||
validate_data_source_type(type)
|
||||
|
||||
if options is None:
|
||||
query_runner = query_runners[type]
|
||||
schema = query_runner.configuration_schema()
|
||||
|
||||
types = {
|
||||
'string': unicode,
|
||||
'number': int,
|
||||
'boolean': bool
|
||||
}
|
||||
|
||||
options_obj = {}
|
||||
|
||||
for k, prop in schema['properties'].iteritems():
|
||||
required = k in schema.get('required', [])
|
||||
default_value = "<<DEFAULT_VALUE>>"
|
||||
if required:
|
||||
default_value = None
|
||||
|
||||
prompt = prop.get('title', k.capitalize())
|
||||
if required:
|
||||
prompt = "{} (required)".format(prompt)
|
||||
else:
|
||||
prompt = "{} (optional)".format(prompt)
|
||||
|
||||
value = click.prompt(prompt, default=default_value, type=types[prop['type']], show_default=False)
|
||||
if value != default_value:
|
||||
options_obj[k] = value
|
||||
|
||||
options = json.dumps(options_obj)
|
||||
|
||||
validate_data_source_options(type, options)
|
||||
|
||||
print "Creating {} data source ({}) with options:\n{}".format(type, name, options)
|
||||
|
||||
data_source = models.DataSource.create(name=name,
|
||||
type=type,
|
||||
options=options)
|
||||
print "Id: {}".format(data_source.id)
|
||||
|
||||
|
||||
@manager.command
|
||||
def delete(name):
|
||||
"""Deletes data source by name"""
|
||||
try:
|
||||
data_source = models.DataSource.get(models.DataSource.name==name)
|
||||
print "Deleting data source: {} (id={})".format(name, data_source.id)
|
||||
data_source.delete_instance()
|
||||
except models.DataSource.DoesNotExist:
|
||||
print "Couldn't find data source named: {}".format(name)
|
||||
|
||||
|
||||
def update_attr(obj, attr, new_value):
|
||||
if new_value is not None:
|
||||
old_value = getattr(obj, attr)
|
||||
print "Updating {}: {} -> {}".format(attr, old_value, new_value)
|
||||
setattr(obj, attr, new_value)
|
||||
|
||||
|
||||
@manager.option('name', default=None, help="name of data source to edit")
|
||||
@manager.option('--name', dest='new_name', default=None, help="new name for the data source")
|
||||
@manager.option('--options', dest='options', default=None, help="updated options for the data source")
|
||||
@manager.option('--type', dest='type', default=None, help="new type for the data source")
|
||||
def edit(name, new_name=None, options=None, type=None):
|
||||
"""Edit data source settings (name, options, type)"""
|
||||
try:
|
||||
if type is not None:
|
||||
validate_data_source_type(type)
|
||||
|
||||
data_source = models.DataSource.get(models.DataSource.name==name)
|
||||
|
||||
if options is not None:
|
||||
validate_data_source_options(data_source.type, options)
|
||||
|
||||
update_attr(data_source, "name", new_name)
|
||||
update_attr(data_source, "type", type)
|
||||
update_attr(data_source, "options", options)
|
||||
data_source.save()
|
||||
|
||||
except models.DataSource.DoesNotExist:
|
||||
print "Couldn't find data source named: {}".format(name)
|
||||
|
||||
19
redash/cli/database.py
Normal file
19
redash/cli/database.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from flask.ext.script import Manager
|
||||
|
||||
manager = Manager(help="Manages the database (create/drop tables).")
|
||||
|
||||
@manager.command
|
||||
def create_tables():
|
||||
"""Creates the database tables."""
|
||||
from redash.models import create_db, init_db
|
||||
|
||||
create_db(True, False)
|
||||
init_db()
|
||||
|
||||
@manager.command
|
||||
def drop_tables():
|
||||
"""Drop the database tables."""
|
||||
from redash.models import create_db
|
||||
|
||||
create_db(False, True)
|
||||
|
||||
74
redash/cli/users.py
Normal file
74
redash/cli/users.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from flask.ext.script import Manager, prompt_pass
|
||||
from redash import models
|
||||
|
||||
manager = Manager(help="Users management commands.")
|
||||
|
||||
@manager.option('email', help="email address of the user to grant admin to")
|
||||
def grant_admin(email):
|
||||
try:
|
||||
user = models.User.get_by_email(email)
|
||||
|
||||
user.groups.append('admin')
|
||||
user.save()
|
||||
|
||||
print "User updated."
|
||||
except models.User.DoesNotExist:
|
||||
print "User [%s] not found." % email
|
||||
|
||||
|
||||
@manager.option('email', help="User's email")
|
||||
@manager.option('name', help="User's full name")
|
||||
@manager.option('--admin', dest='is_admin', action="store_true", default=False, help="set user as admin")
|
||||
@manager.option('--google', dest='google_auth', action="store_true", default=False, help="user uses Google Auth to login")
|
||||
@manager.option('--password', dest='password', default=None, help="Password for users who don't use Google Auth (leave blank for prompt).")
|
||||
@manager.option('--groups', dest='groups', default=models.User.DEFAULT_GROUPS, help="Comma seperated list of groups (leave blank for default).")
|
||||
def create(email, name, groups, is_admin=False, google_auth=False, password=None):
|
||||
print "Creating user (%s, %s)..." % (email, name)
|
||||
print "Admin: %r" % is_admin
|
||||
print "Login with Google Auth: %r\n" % google_auth
|
||||
if isinstance(groups, basestring):
|
||||
groups= groups.split(',')
|
||||
groups.remove('') # in case it was empty string
|
||||
|
||||
if is_admin:
|
||||
groups += ['admin']
|
||||
|
||||
user = models.User(email=email, name=name, groups=groups)
|
||||
if not google_auth:
|
||||
password = password or prompt_pass("Password")
|
||||
user.hash_password(password)
|
||||
|
||||
try:
|
||||
user.save()
|
||||
except Exception, e:
|
||||
print "Failed creating user: %s" % e.message
|
||||
|
||||
|
||||
@manager.option('email', help="email address of user to delete")
|
||||
def delete(email):
|
||||
deleted_count = models.User.delete().where(models.User.email == email).execute()
|
||||
print "Deleted %d users." % deleted_count
|
||||
|
||||
|
||||
@manager.option('password', help="new password for the user")
|
||||
@manager.option('email', help="email address of the user to change password for")
|
||||
def password(email, password):
|
||||
try:
|
||||
user = models.User.get_by_email(email)
|
||||
|
||||
user.hash_password(password)
|
||||
user.save()
|
||||
|
||||
print "User updated."
|
||||
except models.User.DoesNotExist:
|
||||
print "User [%s] not found." % email
|
||||
|
||||
|
||||
@manager.command
|
||||
def list():
|
||||
"""List all users"""
|
||||
for i, user in enumerate(models.User.select()):
|
||||
if i > 0:
|
||||
print "-"*20
|
||||
|
||||
print "Id: {}\nName: {}\nEmail: {}".format(user.id, user.name.encode('utf-8'), user.email)
|
||||
@@ -7,22 +7,24 @@ but this is only due to configuration issues and temporary.
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import numbers
|
||||
import cStringIO
|
||||
import datetime
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import render_template, send_from_directory, make_response, request, jsonify, redirect, \
|
||||
session, url_for
|
||||
from flask.ext.restful import Resource, abort
|
||||
from flask_login import current_user, login_user, logout_user
|
||||
session, url_for, current_app, flash
|
||||
from flask.ext.restful import Resource, abort, reqparse
|
||||
from flask_login import current_user, login_user, logout_user, login_required
|
||||
from funcy import project
|
||||
import sqlparse
|
||||
|
||||
from redash import redis_connection, statsd_client, models, settings, utils, __version__
|
||||
from redash.wsgi import app, auth, api
|
||||
from redash import statsd_client, models, settings, utils
|
||||
from redash.wsgi import app, api
|
||||
from redash.tasks import QueryTask, record_event
|
||||
from redash.cache import headers as cache_headers
|
||||
from redash.permissions import require_permission
|
||||
from redash.query_runner import query_runners, validate_configuration
|
||||
from redash.monitor import get_status
|
||||
|
||||
|
||||
@app.route('/ping', methods=['GET'])
|
||||
@@ -30,13 +32,19 @@ def ping():
|
||||
return 'PONG.'
|
||||
|
||||
|
||||
@app.route('/admin/<anything>/<whatever>')
|
||||
@app.route('/admin/<anything>')
|
||||
@app.route('/dashboard/<anything>')
|
||||
@app.route('/alerts')
|
||||
@app.route('/alerts/<pk>')
|
||||
@app.route('/queries')
|
||||
@app.route('/data_sources')
|
||||
@app.route('/data_sources/<pk>')
|
||||
@app.route('/queries/<query_id>')
|
||||
@app.route('/queries/<query_id>/<anything>')
|
||||
@app.route('/personal')
|
||||
@app.route('/')
|
||||
@auth.required
|
||||
@login_required
|
||||
def index(**kwargs):
|
||||
email_md5 = hashlib.md5(current_user.email.lower()).hexdigest()
|
||||
gravatar_url = "https://www.gravatar.com/avatar/%s?s=40" % email_md5
|
||||
@@ -51,8 +59,7 @@ def index(**kwargs):
|
||||
}
|
||||
|
||||
features = {
|
||||
'clientSideMetrics': settings.CLIENT_SIDE_METRICS,
|
||||
'flowerUrl': settings.CELERY_FLOWER_URL
|
||||
'clientSideMetrics': settings.CLIENT_SIDE_METRICS
|
||||
}
|
||||
|
||||
return render_template("index.html", user=json.dumps(user), name=settings.NAME,
|
||||
@@ -66,22 +73,30 @@ def login():
|
||||
return redirect(request.args.get('next') or '/')
|
||||
|
||||
if not settings.PASSWORD_LOGIN_ENABLED:
|
||||
return redirect(url_for("google_oauth.authorize", next=request.args.get('next')))
|
||||
if settings.SAML_LOGIN_ENABLED:
|
||||
return redirect(url_for("saml_auth.sp_initiated", next=request.args.get('next')))
|
||||
else:
|
||||
return redirect(url_for("google_oauth.authorize", next=request.args.get('next')))
|
||||
|
||||
if request.method == 'POST':
|
||||
user = models.User.select().where(models.User.email == request.form['username']).first()
|
||||
if user and user.verify_password(request.form['password']):
|
||||
remember = ('remember' in request.form)
|
||||
login_user(user, remember=remember)
|
||||
return redirect(request.args.get('next') or '/')
|
||||
try:
|
||||
user = models.User.get_by_email(request.form['username'])
|
||||
if user and user.verify_password(request.form['password']):
|
||||
remember = ('remember' in request.form)
|
||||
login_user(user, remember=remember)
|
||||
return redirect(request.args.get('next') or '/')
|
||||
else:
|
||||
flash("Wrong username or password.")
|
||||
except models.User.DoesNotExist:
|
||||
flash("Wrong username or password.")
|
||||
|
||||
return render_template("login.html",
|
||||
name=settings.NAME,
|
||||
analytics=settings.ANALYTICS,
|
||||
next=request.args.get('next'),
|
||||
username=request.form.get('username', ''),
|
||||
show_google_openid=settings.GOOGLE_OAUTH_ENABLED)
|
||||
|
||||
show_google_openid=settings.GOOGLE_OAUTH_ENABLED,
|
||||
show_saml_login=settings.SAML_LOGIN_ENABLED)
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
@@ -91,42 +106,16 @@ def logout():
|
||||
return redirect('/login')
|
||||
|
||||
@app.route('/status.json')
|
||||
@auth.required
|
||||
@login_required
|
||||
@require_permission('admin')
|
||||
def status_api():
|
||||
status = {}
|
||||
info = redis_connection.info()
|
||||
status['redis_used_memory'] = info['used_memory_human']
|
||||
status['version'] = __version__
|
||||
status['queries_count'] = models.Query.select().count()
|
||||
status['query_results_count'] = models.QueryResult.select().count()
|
||||
status['dashboards_count'] = models.Dashboard.select().count()
|
||||
status['widgets_count'] = models.Widget.select().count()
|
||||
|
||||
status['workers'] = []
|
||||
|
||||
manager_status = redis_connection.hgetall('redash:status')
|
||||
status['manager'] = manager_status
|
||||
status['manager']['outdated_queries_count'] = models.Query.outdated_queries().count()
|
||||
|
||||
queues = {}
|
||||
for ds in models.DataSource.select():
|
||||
for queue in (ds.queue_name, ds.scheduled_queue_name):
|
||||
queues.setdefault(queue, set())
|
||||
queues[queue].add(ds.name)
|
||||
|
||||
status['manager']['queues'] = {}
|
||||
for queue, sources in queues.iteritems():
|
||||
status['manager']['queues'][queue] = {
|
||||
'data_sources': ', '.join(sources),
|
||||
'size': redis_connection.llen(queue)
|
||||
}
|
||||
status = get_status()
|
||||
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@app.route('/api/queries/format', methods=['POST'])
|
||||
@auth.required
|
||||
@login_required
|
||||
def format_sql_query():
|
||||
arguments = request.get_json(force=True)
|
||||
query = arguments.get("query", "")
|
||||
@@ -134,8 +123,26 @@ def format_sql_query():
|
||||
return sqlparse.format(query, reindent=True, keyword_case='upper')
|
||||
|
||||
|
||||
@app.route('/queries/new', methods=['POST'])
|
||||
@login_required
|
||||
def create_query_route():
|
||||
query = request.form.get('query', None)
|
||||
data_source_id = request.form.get('data_source_id', None)
|
||||
|
||||
if query is None or data_source_id is None:
|
||||
abort(400)
|
||||
|
||||
query = models.Query.create(name="New Query",
|
||||
query=query,
|
||||
data_source=data_source_id,
|
||||
user=current_user._get_current_object(),
|
||||
schedule=None)
|
||||
|
||||
return redirect('/queries/{}'.format(query.id), 303)
|
||||
|
||||
|
||||
class BaseResource(Resource):
|
||||
decorators = [auth.required]
|
||||
decorators = [login_required]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(BaseResource, self).__init__(*args, **kwargs)
|
||||
@@ -172,12 +179,78 @@ class MetricsAPI(BaseResource):
|
||||
api.add_resource(MetricsAPI, '/api/metrics/v1/send', endpoint='metrics')
|
||||
|
||||
|
||||
class DataSourceTypeListAPI(BaseResource):
|
||||
@require_permission("admin")
|
||||
def get(self):
|
||||
return [q.to_dict() for q in query_runners.values()]
|
||||
|
||||
api.add_resource(DataSourceTypeListAPI, '/api/data_sources/types', endpoint='data_source_types')
|
||||
|
||||
|
||||
class DataSourceAPI(BaseResource):
|
||||
@require_permission('admin')
|
||||
def get(self, data_source_id):
|
||||
data_source = models.DataSource.get_by_id(data_source_id)
|
||||
return data_source.to_dict(all=True)
|
||||
|
||||
@require_permission('admin')
|
||||
def post(self, data_source_id):
|
||||
data_source = models.DataSource.get_by_id(data_source_id)
|
||||
req = request.get_json(True)
|
||||
if not validate_configuration(req['type'], req['options']):
|
||||
abort(400)
|
||||
|
||||
data_source.name = req['name']
|
||||
data_source.options = json.dumps(req['options'])
|
||||
|
||||
data_source.save()
|
||||
|
||||
return data_source.to_dict(all=True)
|
||||
|
||||
@require_permission('admin')
|
||||
def delete(self, data_source_id):
|
||||
data_source = models.DataSource.get_by_id(data_source_id)
|
||||
data_source.delete_instance(recursive=True)
|
||||
|
||||
return make_response('', 204)
|
||||
|
||||
|
||||
class DataSourceListAPI(BaseResource):
|
||||
def get(self):
|
||||
data_sources = [ds.to_dict() for ds in models.DataSource.all()]
|
||||
return data_sources
|
||||
|
||||
@require_permission("admin")
|
||||
def post(self):
|
||||
req = request.get_json(True)
|
||||
required_fields = ('options', 'name', 'type')
|
||||
for f in required_fields:
|
||||
if f not in req:
|
||||
abort(400)
|
||||
|
||||
if not validate_configuration(req['type'], req['options']):
|
||||
abort(400)
|
||||
|
||||
datasource = models.DataSource.create(name=req['name'], type=req['type'], options=json.dumps(req['options']))
|
||||
|
||||
return datasource.to_dict(all=True)
|
||||
|
||||
api.add_resource(DataSourceListAPI, '/api/data_sources', endpoint='data_sources')
|
||||
api.add_resource(DataSourceAPI, '/api/data_sources/<data_source_id>', endpoint='data_source')
|
||||
|
||||
|
||||
class DataSourceSchemaAPI(BaseResource):
|
||||
def get(self, data_source_id):
|
||||
data_source = models.DataSource.get_by_id(data_source_id)
|
||||
schema = data_source.get_schema()
|
||||
|
||||
return schema
|
||||
|
||||
api.add_resource(DataSourceSchemaAPI, '/api/data_sources/<data_source_id>/schema')
|
||||
|
||||
class DashboardRecentAPI(BaseResource):
|
||||
def get(self):
|
||||
return [d.to_dict() for d in models.Dashboard.recent(current_user.id).limit(20)]
|
||||
|
||||
|
||||
class DashboardListAPI(BaseResource):
|
||||
@@ -224,6 +297,7 @@ class DashboardAPI(BaseResource):
|
||||
dashboard.save()
|
||||
|
||||
api.add_resource(DashboardListAPI, '/api/dashboards', endpoint='dashboards')
|
||||
api.add_resource(DashboardRecentAPI, '/api/dashboards/recent', endpoint='recent_dashboards')
|
||||
api.add_resource(DashboardAPI, '/api/dashboards/<dashboard_slug>', endpoint='dashboard')
|
||||
|
||||
|
||||
@@ -263,13 +337,6 @@ class WidgetAPI(BaseResource):
|
||||
@require_permission('edit_dashboard')
|
||||
def delete(self, widget_id):
|
||||
widget = models.Widget.get(models.Widget.id == widget_id)
|
||||
# TODO: reposition existing ones
|
||||
layout = json.loads(widget.dashboard.layout)
|
||||
layout = map(lambda row: filter(lambda w: w != widget_id, row), layout)
|
||||
layout = filter(lambda row: len(row) > 0, layout)
|
||||
widget.dashboard.layout = json.dumps(layout)
|
||||
widget.dashboard.save()
|
||||
|
||||
widget.delete_instance()
|
||||
|
||||
api.add_resource(WidgetListAPI, '/api/widgets', endpoint='widgets')
|
||||
@@ -284,11 +351,17 @@ class QuerySearchAPI(BaseResource):
|
||||
return [q.to_dict() for q in models.Query.search(term)]
|
||||
|
||||
|
||||
class QueryRecentAPI(BaseResource):
|
||||
@require_permission('view_query')
|
||||
def get(self):
|
||||
return [q.to_dict() for q in models.Query.recent(current_user.id).limit(20)]
|
||||
|
||||
|
||||
class QueryListAPI(BaseResource):
|
||||
@require_permission('create_query')
|
||||
def post(self):
|
||||
query_def = request.get_json(force=True)
|
||||
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data']:
|
||||
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'last_modified_by']:
|
||||
query_def.pop(field, None)
|
||||
|
||||
query_def['user'] = self.current_user
|
||||
@@ -296,8 +369,6 @@ class QueryListAPI(BaseResource):
|
||||
query = models.Query(**query_def)
|
||||
query.save()
|
||||
|
||||
query.create_default_visualizations()
|
||||
|
||||
return query.to_dict()
|
||||
|
||||
@require_permission('view_query')
|
||||
@@ -308,8 +379,10 @@ class QueryListAPI(BaseResource):
|
||||
class QueryAPI(BaseResource):
|
||||
@require_permission('edit_query')
|
||||
def post(self, query_id):
|
||||
query = models.Query.get_by_id(query_id)
|
||||
|
||||
query_def = request.get_json(force=True)
|
||||
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'user']:
|
||||
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'user', 'last_modified_by']:
|
||||
query_def.pop(field, None)
|
||||
|
||||
if 'latest_query_data_id' in query_def:
|
||||
@@ -318,6 +391,9 @@ class QueryAPI(BaseResource):
|
||||
if 'data_source_id' in query_def:
|
||||
query_def['data_source'] = query_def.pop('data_source_id')
|
||||
|
||||
query_def['last_modified_by'] = self.current_user
|
||||
|
||||
# TODO: use #save() with #dirty_fields.
|
||||
models.Query.update_instance(query_id, **query_def)
|
||||
|
||||
query = models.Query.get_by_id(query_id)
|
||||
@@ -332,7 +408,20 @@ class QueryAPI(BaseResource):
|
||||
else:
|
||||
abort(404, message="Query not found.")
|
||||
|
||||
# TODO: move to resource of its own? (POST /queries/{id}/archive)
|
||||
def delete(self, query_id):
|
||||
q = models.Query.get(models.Query.id == query_id)
|
||||
|
||||
if q:
|
||||
if q.user.id == self.current_user.id or self.current_user.has_permission('admin'):
|
||||
q.archive()
|
||||
else:
|
||||
abort(403)
|
||||
else:
|
||||
abort(404, message="Query not found.")
|
||||
|
||||
api.add_resource(QuerySearchAPI, '/api/queries/search', endpoint='queries_search')
|
||||
api.add_resource(QueryRecentAPI, '/api/queries/recent', endpoint='recent_queries')
|
||||
api.add_resource(QueryListAPI, '/api/queries', endpoint='queries')
|
||||
api.add_resource(QueryAPI, '/api/queries/<query_id>', endpoint='query')
|
||||
|
||||
@@ -378,7 +467,7 @@ api.add_resource(VisualizationAPI, '/api/visualizations/<visualization_id>', end
|
||||
class QueryResultListAPI(BaseResource):
|
||||
@require_permission('execute_query')
|
||||
def post(self):
|
||||
params = request.json
|
||||
params = request.get_json(force=True)
|
||||
|
||||
if settings.FEATURE_TABLES_PERMISSIONS:
|
||||
metadata = utils.SQLMetaData(params['query'])
|
||||
@@ -404,16 +493,19 @@ class QueryResultListAPI(BaseResource):
|
||||
activity=params['query']
|
||||
).save()
|
||||
|
||||
if params['ttl'] == 0:
|
||||
max_age = int(params.get('max_age', -1))
|
||||
|
||||
if max_age == 0:
|
||||
query_result = None
|
||||
else:
|
||||
query_result = models.QueryResult.get_latest(params['data_source_id'], params['query'], int(params['ttl']))
|
||||
query_result = models.QueryResult.get_latest(params['data_source_id'], params['query'], max_age)
|
||||
|
||||
if query_result:
|
||||
return {'query_result': query_result.to_dict()}
|
||||
else:
|
||||
data_source = models.DataSource.get_by_id(params['data_source_id'])
|
||||
job = QueryTask.add_task(params['query'], data_source)
|
||||
query_id = params.get('query_id', 'adhoc')
|
||||
job = QueryTask.add_task(params['query'], data_source, metadata={"Username": self.current_user.name, "Query ID": query_id})
|
||||
return {'job': job.to_dict()}
|
||||
|
||||
|
||||
@@ -427,16 +519,34 @@ class QueryResultAPI(BaseResource):
|
||||
writer.writer = utils.UnicodeWriter(s)
|
||||
writer.writeheader()
|
||||
for row in query_data['rows']:
|
||||
for k, v in row.iteritems():
|
||||
if isinstance(v, numbers.Number) and (v > 1000 * 1000 * 1000 * 100):
|
||||
row[k] = datetime.datetime.fromtimestamp(v/1000.0)
|
||||
|
||||
writer.writerow(row)
|
||||
|
||||
headers = {'Content-Type': "text/csv; charset=UTF-8"}
|
||||
headers.update(cache_headers)
|
||||
return make_response(s.getvalue(), 200, headers)
|
||||
|
||||
@staticmethod
|
||||
def add_cors_headers(headers):
|
||||
if 'Origin' in request.headers:
|
||||
origin = request.headers['Origin']
|
||||
|
||||
if origin in settings.ACCESS_CONTROL_ALLOW_ORIGIN:
|
||||
headers['Access-Control-Allow-Origin'] = origin
|
||||
headers['Access-Control-Allow-Credentials'] = str(settings.ACCESS_CONTROL_ALLOW_CREDENTIALS).lower()
|
||||
|
||||
@require_permission('view_query')
|
||||
def options(self, query_id=None, query_result_id=None, filetype='json'):
|
||||
headers = {}
|
||||
self.add_cors_headers(headers)
|
||||
|
||||
if settings.ACCESS_CONTROL_REQUEST_METHOD:
|
||||
headers['Access-Control-Request-Method'] = settings.ACCESS_CONTROL_REQUEST_METHOD
|
||||
|
||||
if settings.ACCESS_CONTROL_ALLOW_HEADERS:
|
||||
headers['Access-Control-Allow-Headers'] = settings.ACCESS_CONTROL_ALLOW_HEADERS
|
||||
|
||||
return make_response("", 200, headers)
|
||||
|
||||
@require_permission('view_query')
|
||||
def get(self, query_id=None, query_result_id=None, filetype='json'):
|
||||
if query_result_id is None and query_id is not None:
|
||||
@@ -448,9 +558,33 @@ class QueryResultAPI(BaseResource):
|
||||
query_result = models.QueryResult.get_by_id(query_result_id)
|
||||
|
||||
if query_result:
|
||||
if isinstance(self.current_user, models.ApiUser):
|
||||
event = {
|
||||
'user_id': None,
|
||||
'action': 'api_get',
|
||||
'timestamp': int(time.time()),
|
||||
'api_key': self.current_user.id,
|
||||
'file_type': filetype
|
||||
}
|
||||
|
||||
if query_id:
|
||||
event['object_type'] = 'query'
|
||||
event['object_id'] = query_id
|
||||
else:
|
||||
event['object_type'] = 'query_result'
|
||||
event['object_id'] = query_result_id
|
||||
|
||||
record_event.delay(event)
|
||||
|
||||
headers = {}
|
||||
|
||||
if len(settings.ACCESS_CONTROL_ALLOW_ORIGIN) > 0:
|
||||
self.add_cors_headers(headers)
|
||||
|
||||
if filetype == 'json':
|
||||
data = json.dumps({'query_result': query_result.to_dict()}, cls=utils.JSONEncoder)
|
||||
return make_response(data, 200, cache_headers)
|
||||
headers.update(cache_headers)
|
||||
return make_response(data, 200, headers)
|
||||
else:
|
||||
return self.csv_response(query_result)
|
||||
|
||||
@@ -478,13 +612,110 @@ class JobAPI(BaseResource):
|
||||
|
||||
api.add_resource(JobAPI, '/api/jobs/<job_id>', endpoint='job')
|
||||
|
||||
|
||||
class AlertAPI(BaseResource):
|
||||
def get(self, alert_id):
|
||||
alert = models.Alert.get_by_id(alert_id)
|
||||
return alert.to_dict()
|
||||
|
||||
def post(self, alert_id):
|
||||
req = request.get_json(True)
|
||||
params = project(req, ('options', 'name', 'query_id'))
|
||||
alert = models.Alert.get_by_id(alert_id)
|
||||
if 'query_id' in params:
|
||||
params['query'] = params.pop('query_id')
|
||||
|
||||
alert.update_instance(**params)
|
||||
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'edit',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert.id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
return alert.to_dict()
|
||||
|
||||
|
||||
class AlertListAPI(BaseResource):
|
||||
def post(self):
|
||||
req = request.get_json(True)
|
||||
required_fields = ('options', 'name', 'query_id')
|
||||
for f in required_fields:
|
||||
if f not in req:
|
||||
abort(400)
|
||||
|
||||
alert = models.Alert.create(
|
||||
name=req['name'],
|
||||
query=req['query_id'],
|
||||
user=self.current_user,
|
||||
options=req['options']
|
||||
)
|
||||
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'create',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert.id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
# TODO: should be in model?
|
||||
models.AlertSubscription.create(alert=alert, user=self.current_user)
|
||||
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'subscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert.id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
return alert.to_dict()
|
||||
|
||||
def get(self):
|
||||
return [alert.to_dict() for alert in models.Alert.all()]
|
||||
|
||||
|
||||
class AlertSubscriptionListResource(BaseResource):
|
||||
def post(self, alert_id):
|
||||
subscription = models.AlertSubscription.create(alert=alert_id, user=self.current_user)
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'subscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert_id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
return subscription.to_dict()
|
||||
|
||||
def get(self, alert_id):
|
||||
subscriptions = models.AlertSubscription.all(alert_id)
|
||||
return [s.to_dict() for s in subscriptions]
|
||||
|
||||
|
||||
class AlertSubscriptionResource(BaseResource):
|
||||
def delete(self, alert_id, subscriber_id):
|
||||
models.AlertSubscription.unsubscribe(alert_id, subscriber_id)
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'unsubscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert_id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
api.add_resource(AlertAPI, '/api/alerts/<alert_id>', endpoint='alert')
|
||||
api.add_resource(AlertSubscriptionListResource, '/api/alerts/<alert_id>/subscriptions', endpoint='alert_subscriptions')
|
||||
api.add_resource(AlertSubscriptionResource, '/api/alerts/<alert_id>/subscriptions/<subscriber_id>', endpoint='alert_subscription')
|
||||
api.add_resource(AlertListAPI, '/api/alerts', endpoint='alerts')
|
||||
|
||||
@app.route('/<path:filename>')
|
||||
def send_static(filename):
|
||||
return send_from_directory(settings.STATIC_ASSETS_PATH, filename)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
|
||||
|
||||
if current_app.debug:
|
||||
cache_timeout = 0
|
||||
else:
|
||||
cache_timeout = None
|
||||
|
||||
return send_from_directory(settings.STATIC_ASSETS_PATH, filename, cache_timeout=cache_timeout)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import json
|
||||
|
||||
|
||||
def get_query_runner(connection_type, connection_string):
|
||||
if connection_type == 'mysql':
|
||||
from redash.data import query_runner_mysql
|
||||
runner = query_runner_mysql.mysql(connection_string)
|
||||
elif connection_type == 'graphite':
|
||||
from redash.data import query_runner_graphite
|
||||
connection_params = json.loads(connection_string)
|
||||
if connection_params['auth']:
|
||||
connection_params['auth'] = tuple(connection_params['auth'])
|
||||
else:
|
||||
connection_params['auth'] = None
|
||||
runner = query_runner_graphite.graphite(connection_params)
|
||||
elif connection_type == 'bigquery':
|
||||
from redash.data import query_runner_bigquery
|
||||
connection_params = json.loads(connection_string)
|
||||
runner = query_runner_bigquery.bigquery(connection_params)
|
||||
elif connection_type == 'script':
|
||||
from redash.data import query_runner_script
|
||||
runner = query_runner_script.script(connection_string)
|
||||
elif connection_type == 'url':
|
||||
from redash.data import query_runner_url
|
||||
runner = query_runner_url.url(connection_string)
|
||||
elif connection_type == "mongo":
|
||||
from redash.data import query_runner_mongodb
|
||||
connection_params = json.loads(connection_string)
|
||||
runner = query_runner_mongodb.mongodb(connection_params)
|
||||
else:
|
||||
from redash.data import query_runner_pg
|
||||
runner = query_runner_pg.pg(connection_string)
|
||||
|
||||
return runner
|
||||
@@ -1,138 +0,0 @@
|
||||
import datetime
|
||||
import httplib2
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import apiclient.errors
|
||||
from apiclient.discovery import build
|
||||
from apiclient.errors import HttpError
|
||||
from oauth2client.client import SignedJwtAssertionCredentials
|
||||
except ImportError:
|
||||
print "Missing dependencies. Please install google-api-python-client and oauth2client."
|
||||
print "You can use pip: pip install google-api-python-client oauth2client"
|
||||
|
||||
from redash.utils import JSONEncoder
|
||||
|
||||
types_map = {
|
||||
'INTEGER': 'integer',
|
||||
'FLOAT': 'float',
|
||||
'BOOLEAN': 'boolean',
|
||||
'STRING': 'string',
|
||||
'TIMESTAMP': 'datetime',
|
||||
}
|
||||
|
||||
def transform_row(row, fields):
|
||||
column_index = 0
|
||||
row_data = {}
|
||||
|
||||
for cell in row["f"]:
|
||||
field = fields[column_index]
|
||||
cell_value = cell['v']
|
||||
|
||||
if cell_value is None:
|
||||
pass
|
||||
# Otherwise just cast the value
|
||||
elif field['type'] == 'INTEGER':
|
||||
cell_value = int(cell_value)
|
||||
elif field['type'] == 'FLOAT':
|
||||
cell_value = float(cell_value)
|
||||
elif field['type'] == 'BOOLEAN':
|
||||
cell_value = cell_value.lower() == "true"
|
||||
elif field['type'] == 'TIMESTAMP':
|
||||
cell_value = datetime.datetime.fromtimestamp(float(cell_value))
|
||||
|
||||
row_data[field["name"]] = cell_value
|
||||
column_index += 1
|
||||
|
||||
return row_data
|
||||
|
||||
def bigquery(connection_string):
|
||||
def load_key(filename):
|
||||
f = file(filename, "rb")
|
||||
try:
|
||||
return f.read()
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
def get_bigquery_service():
|
||||
scope = [
|
||||
"https://www.googleapis.com/auth/bigquery",
|
||||
]
|
||||
|
||||
credentials = SignedJwtAssertionCredentials(connection_string["serviceAccount"],
|
||||
load_key(connection_string["privateKey"]), scope=scope)
|
||||
http = httplib2.Http()
|
||||
http = credentials.authorize(http)
|
||||
|
||||
return build("bigquery", "v2", http=http)
|
||||
|
||||
def get_query_results(jobs, project_id, job_id, start_index):
|
||||
query_reply = jobs.getQueryResults(projectId=project_id, jobId=job_id, startIndex=start_index).execute()
|
||||
logging.debug('query_reply %s', query_reply)
|
||||
if not query_reply['jobComplete']:
|
||||
time.sleep(10)
|
||||
return get_query_results(jobs, project_id, job_id, start_index)
|
||||
|
||||
return query_reply
|
||||
|
||||
def query_runner(query):
|
||||
bigquery_service = get_bigquery_service()
|
||||
|
||||
jobs = bigquery_service.jobs()
|
||||
job_data = {
|
||||
"configuration": {
|
||||
"query": {
|
||||
"query": query,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logging.debug("bigquery got query: %s", query)
|
||||
|
||||
project_id = connection_string["projectId"]
|
||||
|
||||
try:
|
||||
insert_response = jobs.insert(projectId=project_id, body=job_data).execute()
|
||||
current_row = 0
|
||||
query_reply = get_query_results(jobs, project_id=project_id,
|
||||
job_id=insert_response['jobReference']['jobId'], start_index=current_row)
|
||||
|
||||
logging.debug("bigquery replied: %s", query_reply)
|
||||
|
||||
rows = []
|
||||
|
||||
while ("rows" in query_reply) and current_row < query_reply['totalRows']:
|
||||
for row in query_reply["rows"]:
|
||||
rows.append(transform_row(row, query_reply["schema"]["fields"]))
|
||||
|
||||
current_row += len(query_reply['rows'])
|
||||
query_reply = jobs.getQueryResults(projectId=project_id, jobId=query_reply['jobReference']['jobId'],
|
||||
startIndex=current_row).execute()
|
||||
|
||||
columns = [{'name': f["name"],
|
||||
'friendly_name': f["name"],
|
||||
'type': types_map.get(f['type'], "string")} for f in query_reply["schema"]["fields"]]
|
||||
|
||||
data = {
|
||||
"columns": columns,
|
||||
"rows": rows
|
||||
}
|
||||
error = None
|
||||
|
||||
json_data = json.dumps(data, cls=JSONEncoder)
|
||||
except apiclient.errors.HttpError, e:
|
||||
json_data = None
|
||||
error = e.content
|
||||
except KeyboardInterrupt:
|
||||
error = "Query cancelled by user."
|
||||
json_data = None
|
||||
except Exception:
|
||||
raise sys.exc_info()[1], None, sys.exc_info()[2]
|
||||
|
||||
return json_data, error
|
||||
|
||||
|
||||
return query_runner
|
||||
@@ -1,46 +0,0 @@
|
||||
"""
|
||||
QueryRunner for Graphite.
|
||||
"""
|
||||
import json
|
||||
import datetime
|
||||
import requests
|
||||
from redash.utils import JSONEncoder
|
||||
|
||||
|
||||
def graphite(connection_params):
|
||||
def transform_result(response):
|
||||
columns = [{'name': 'Time::x'}, {'name': 'value::y'}, {'name': 'name::series'}]
|
||||
rows = []
|
||||
|
||||
for series in response.json():
|
||||
for values in series['datapoints']:
|
||||
timestamp = datetime.datetime.fromtimestamp(int(values[1]))
|
||||
rows.append({'Time::x': timestamp, 'name::series': series['target'], 'value::y': values[0]})
|
||||
|
||||
data = {'columns': columns, 'rows': rows}
|
||||
return json.dumps(data, cls=JSONEncoder)
|
||||
|
||||
def query_runner(query):
|
||||
base_url = "%s/render?format=json&" % connection_params['url']
|
||||
url = "%s%s" % (base_url, "&".join(query.split("\n")))
|
||||
error = None
|
||||
data = None
|
||||
|
||||
try:
|
||||
response = requests.get(url, auth=connection_params['auth'],
|
||||
verify=connection_params['verify'])
|
||||
|
||||
if response.status_code == 200:
|
||||
data = transform_result(response)
|
||||
else:
|
||||
error = "Failed getting results (%d)" % response.status_code
|
||||
|
||||
except Exception, ex:
|
||||
data = None
|
||||
error = ex.message
|
||||
|
||||
return data, error
|
||||
|
||||
query_runner.annotate_query = False
|
||||
|
||||
return query_runner
|
||||
@@ -1,150 +0,0 @@
|
||||
import datetime
|
||||
import logging
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
import time
|
||||
from redash.utils import JSONEncoder
|
||||
|
||||
try:
|
||||
import pymongo
|
||||
from bson.objectid import ObjectId
|
||||
except ImportError:
|
||||
print "Missing dependencies. Please install pymongo."
|
||||
print "You can use pip: pip install pymongo"
|
||||
|
||||
TYPES_MAP = {
|
||||
ObjectId : "string",
|
||||
str : "string",
|
||||
unicode : "string",
|
||||
int : "integer",
|
||||
long : "integer",
|
||||
float : "float",
|
||||
bool : "boolean",
|
||||
datetime.datetime: "datetime",
|
||||
}
|
||||
|
||||
date_regex = re.compile("ISODate\(\"(.*)\"\)", re.IGNORECASE)
|
||||
|
||||
def mongodb(connection_string):
|
||||
def _get_column_by_name(columns, column_name):
|
||||
for c in columns:
|
||||
if "name" in c and c["name"] == column_name:
|
||||
return c
|
||||
|
||||
return None
|
||||
|
||||
def _convert_date(q, field_name):
|
||||
m = date_regex.findall(q[field_name])
|
||||
if len(m) > 0:
|
||||
if q[field_name].find(":") == -1:
|
||||
q[field_name] = datetime.datetime.fromtimestamp(time.mktime(time.strptime(m[0], "%Y-%m-%d")))
|
||||
else:
|
||||
q[field_name] = datetime.datetime.fromtimestamp(time.mktime(time.strptime(m[0], "%Y-%m-%d %H:%M")))
|
||||
|
||||
def query_runner(query):
|
||||
if not "dbName" in connection_string or not connection_string["dbName"]:
|
||||
return None, "dbName is missing from connection string JSON or is empty"
|
||||
|
||||
db_name = connection_string["dbName"]
|
||||
|
||||
if not "connectionString" in connection_string or not connection_string["connectionString"]:
|
||||
return None, "connectionString is missing from connection string JSON or is empty"
|
||||
|
||||
is_replica_set = True if "replicaSetName" in connection_string and connection_string["replicaSetName"] else False
|
||||
|
||||
if is_replica_set:
|
||||
if not connection_string["replicaSetName"]:
|
||||
return None, "replicaSetName is set in the connection string JSON but is empty"
|
||||
|
||||
db_connection = pymongo.MongoReplicaSetClient(connection_string["connectionString"], replicaSet=connection_string["replicaSetName"])
|
||||
else:
|
||||
db_connection = pymongo.MongoClient(connection_string["connectionString"])
|
||||
|
||||
if db_name not in db_connection.database_names():
|
||||
return None, "Unknown database name '%s'" % db_name
|
||||
|
||||
db = db_connection[db_name]
|
||||
|
||||
logging.debug("mongodb connection string: %s", connection_string)
|
||||
logging.debug("mongodb got query: %s", query)
|
||||
|
||||
try:
|
||||
query_data = json.loads(query)
|
||||
except:
|
||||
return None, "Invalid query format. The query is not a valid JSON."
|
||||
|
||||
collection = None
|
||||
if not "collection" in query_data:
|
||||
return None, "'collection' must have a value to run a query"
|
||||
else:
|
||||
collection = query_data["collection"]
|
||||
|
||||
q = None
|
||||
if "query" in query_data:
|
||||
q = query_data["query"]
|
||||
for k in q:
|
||||
if q[k] and type(q[k]) in [str, unicode]:
|
||||
logging.debug(q[k])
|
||||
_convert_date(q, k)
|
||||
elif q[k] and type(q[k]) is dict:
|
||||
for k2 in q[k]:
|
||||
if type(q[k][k2]) in [str, unicode]:
|
||||
_convert_date(q[k], k2)
|
||||
|
||||
f = None
|
||||
if "fields" in query_data:
|
||||
f = query_data["fields"]
|
||||
|
||||
s = None
|
||||
if "sort" in query_data and query_data["sort"]:
|
||||
s = []
|
||||
for field_name in query_data["sort"]:
|
||||
s.append((field_name, query_data["sort"][field_name]))
|
||||
|
||||
columns = []
|
||||
rows = []
|
||||
|
||||
error = None
|
||||
json_data = None
|
||||
|
||||
cursor = None
|
||||
if s:
|
||||
cursor = db[collection].find(q, f).sort(s)
|
||||
else:
|
||||
cursor = db[collection].find(q, f)
|
||||
|
||||
for r in cursor:
|
||||
for k in r:
|
||||
if _get_column_by_name(columns, k) is None:
|
||||
columns.append({
|
||||
"name": k,
|
||||
"friendly_name": k,
|
||||
"type": TYPES_MAP[type(r[k])] if type(r[k]) in TYPES_MAP else None
|
||||
})
|
||||
|
||||
# Convert ObjectId to string
|
||||
if type(r[k]) == ObjectId:
|
||||
r[k] = str(r[k])
|
||||
|
||||
|
||||
rows.append(r)
|
||||
|
||||
if f:
|
||||
ordered_columns = []
|
||||
for k in sorted(f, key=f.get):
|
||||
ordered_columns.append(_get_column_by_name(columns, k))
|
||||
|
||||
columns = ordered_columns
|
||||
|
||||
data = {
|
||||
"columns": columns,
|
||||
"rows": rows
|
||||
}
|
||||
error = None
|
||||
json_data = json.dumps(data, cls=JSONEncoder)
|
||||
|
||||
return json_data, error
|
||||
|
||||
query_runner.annotate_query = False
|
||||
return query_runner
|
||||
@@ -1,64 +0,0 @@
|
||||
"""
|
||||
QueryRunner is the function that the workers use, to execute queries. This is the Redshift
|
||||
(PostgreSQL in fact) version, but easily we can write another to support additional databases
|
||||
(MySQL and others).
|
||||
|
||||
Because the worker just pass the query, this can be used with any data store that has some sort of
|
||||
query language (for example: HiveQL).
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import MySQLdb
|
||||
import sys
|
||||
from redash.utils import JSONEncoder
|
||||
|
||||
def mysql(connection_string):
|
||||
if connection_string.endswith(';'):
|
||||
connection_string = connection_string[0:-1]
|
||||
|
||||
def query_runner(query):
|
||||
connections_params = [entry.split('=')[1] for entry in connection_string.split(';')]
|
||||
connection = MySQLdb.connect(*connections_params, charset="utf8", use_unicode=True)
|
||||
cursor = connection.cursor()
|
||||
|
||||
logging.debug("mysql got query: %s", query)
|
||||
|
||||
try:
|
||||
cursor.execute(query)
|
||||
|
||||
data = cursor.fetchall()
|
||||
|
||||
cursor_desc = cursor.description
|
||||
if (cursor_desc != None):
|
||||
num_fields = len(cursor_desc)
|
||||
column_names = [i[0] for i in cursor.description]
|
||||
|
||||
rows = [dict(zip(column_names, row)) for row in data]
|
||||
|
||||
columns = [{'name': col_name,
|
||||
'friendly_name': col_name,
|
||||
'type': None} for col_name in column_names]
|
||||
|
||||
data = {'columns': columns, 'rows': rows}
|
||||
json_data = json.dumps(data, cls=JSONEncoder)
|
||||
error = None
|
||||
else:
|
||||
json_data = None
|
||||
error = "No data was returned."
|
||||
|
||||
cursor.close()
|
||||
except MySQLdb.Error, e:
|
||||
json_data = None
|
||||
error = e.args[1]
|
||||
except KeyboardInterrupt:
|
||||
error = "Query cancelled by user."
|
||||
json_data = None
|
||||
except Exception as e:
|
||||
raise sys.exc_info()[1], None, sys.exc_info()[2]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
return json_data, error
|
||||
|
||||
|
||||
return query_runner
|
||||
@@ -1,110 +0,0 @@
|
||||
"""
|
||||
QueryRunner is the function that the workers use, to execute queries. This is the PostgreSQL
|
||||
version, but easily we can write another to support additional databases (MySQL and others).
|
||||
|
||||
Because the worker just pass the query, this can be used with any data store that has some sort of
|
||||
query language (for example: HiveQL).
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import select
|
||||
import logging
|
||||
import psycopg2
|
||||
|
||||
from redash.utils import JSONEncoder
|
||||
|
||||
types_map = {
|
||||
20: 'integer',
|
||||
21: 'integer',
|
||||
23: 'integer',
|
||||
700: 'float',
|
||||
1700: 'float',
|
||||
701: 'float',
|
||||
16: 'boolean',
|
||||
1082: 'date',
|
||||
1114: 'datetime',
|
||||
1184: 'datetime',
|
||||
1014: 'string',
|
||||
1015: 'string',
|
||||
1008: 'string',
|
||||
1009: 'string',
|
||||
2951: 'string'
|
||||
}
|
||||
|
||||
|
||||
def pg(connection_string):
|
||||
def column_friendly_name(column_name):
|
||||
return column_name
|
||||
|
||||
def wait(conn):
|
||||
while 1:
|
||||
try:
|
||||
state = conn.poll()
|
||||
if state == psycopg2.extensions.POLL_OK:
|
||||
break
|
||||
elif state == psycopg2.extensions.POLL_WRITE:
|
||||
select.select([], [conn.fileno()], [])
|
||||
elif state == psycopg2.extensions.POLL_READ:
|
||||
select.select([conn.fileno()], [], [])
|
||||
else:
|
||||
raise psycopg2.OperationalError("poll() returned %s" % state)
|
||||
except select.error:
|
||||
raise psycopg2.OperationalError("select.error received")
|
||||
|
||||
def query_runner(query):
|
||||
connection = psycopg2.connect(connection_string, async=True)
|
||||
wait(connection)
|
||||
|
||||
cursor = connection.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(query)
|
||||
wait(connection)
|
||||
|
||||
# While set would be more efficient here, it sorts the data which is not what we want, but due to the small
|
||||
# size of the data we can assume it's ok.
|
||||
column_names = []
|
||||
columns = []
|
||||
duplicates_counter = 1
|
||||
|
||||
for column in cursor.description:
|
||||
# TODO: this deduplication needs to be generalized and reused in all query runners.
|
||||
column_name = column.name
|
||||
if column_name in column_names:
|
||||
column_name = column_name + str(duplicates_counter)
|
||||
duplicates_counter += 1
|
||||
|
||||
column_names.append(column_name)
|
||||
|
||||
columns.append({
|
||||
'name': column_name,
|
||||
'friendly_name': column_friendly_name(column_name),
|
||||
'type': types_map.get(column.type_code, None)
|
||||
})
|
||||
|
||||
rows = [dict(zip(column_names, row)) for row in cursor]
|
||||
|
||||
data = {'columns': columns, 'rows': rows}
|
||||
json_data = json.dumps(data, cls=JSONEncoder)
|
||||
error = None
|
||||
cursor.close()
|
||||
except (select.error, OSError) as e:
|
||||
logging.exception(e)
|
||||
error = "Query interrupted. Please retry."
|
||||
json_data = None
|
||||
except psycopg2.DatabaseError as e:
|
||||
logging.exception(e)
|
||||
json_data = None
|
||||
error = e.message
|
||||
except KeyboardInterrupt:
|
||||
connection.cancel()
|
||||
error = "Query cancelled by user."
|
||||
json_data = None
|
||||
except Exception as e:
|
||||
raise sys.exc_info()[1], None, sys.exc_info()[2]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
return json_data, error
|
||||
|
||||
return query_runner
|
||||
@@ -1,51 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# We use subprocess.check_output because we are lazy.
|
||||
# If someone will really want to run this on Python < 2.7 they can easily update the code to run
|
||||
# Popen, check the retcodes and other things and read the standard output to a variable.
|
||||
if not "check_output" in subprocess.__dict__:
|
||||
print "ERROR: This runner uses subprocess.check_output function which exists in Python 2.7"
|
||||
|
||||
def script(connection_string):
|
||||
|
||||
def query_runner(query):
|
||||
try:
|
||||
json_data = None
|
||||
error = None
|
||||
|
||||
if connection_string is None:
|
||||
return None, "script execution path is not set. Please reconfigure the data source"
|
||||
|
||||
# Poor man's protection against running scripts from output the scripts directory
|
||||
if connection_string.find("../") > -1:
|
||||
return None, "Scripts can only be run from the configured scripts directory"
|
||||
|
||||
query = query.strip()
|
||||
|
||||
script = os.path.join(connection_string, query)
|
||||
if not os.path.exists(script):
|
||||
return None, "Script '%s' not found in script directory" % query
|
||||
|
||||
output = subprocess.check_output(script, shell=False)
|
||||
if output != None:
|
||||
output = output.strip()
|
||||
if output != "":
|
||||
return output, None
|
||||
|
||||
error = "Error reading output"
|
||||
except subprocess.CalledProcessError as e:
|
||||
return None, str(e)
|
||||
except KeyboardInterrupt:
|
||||
error = "Query cancelled by user."
|
||||
json_data = None
|
||||
except Exception as e:
|
||||
raise sys.exc_info()[1], None, sys.exc_info()[2]
|
||||
|
||||
return json_data, error
|
||||
|
||||
query_runner.annotate_query = False
|
||||
return query_runner
|
||||
@@ -1,25 +1,25 @@
|
||||
import logging
|
||||
from flask.ext.login import login_user
|
||||
import requests
|
||||
from flask import redirect, url_for, Blueprint
|
||||
from flask import redirect, url_for, Blueprint, flash
|
||||
from flask_oauth import OAuth
|
||||
from redash import models, settings
|
||||
|
||||
logger = logging.getLogger('google_oauth')
|
||||
oauth = OAuth()
|
||||
|
||||
request_token_params = {'scope': 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile', 'response_type': 'code'}
|
||||
|
||||
if settings.GOOGLE_APPS_DOMAIN:
|
||||
request_token_params['hd'] = settings.GOOGLE_APPS_DOMAIN
|
||||
else:
|
||||
if not settings.GOOGLE_APPS_DOMAIN:
|
||||
logger.warning("No Google Apps domain defined, all Google accounts allowed.")
|
||||
|
||||
google = oauth.remote_app('google',
|
||||
base_url='https://www.google.com/accounts/',
|
||||
authorize_url='https://accounts.google.com/o/oauth2/auth',
|
||||
request_token_url=None,
|
||||
request_token_params=request_token_params,
|
||||
request_token_params={
|
||||
'scope': 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile',
|
||||
'response_type': 'code'
|
||||
},
|
||||
access_token_url='https://accounts.google.com/o/oauth2/token',
|
||||
access_token_method='POST',
|
||||
access_token_params={'grant_type': 'authorization_code'},
|
||||
@@ -31,7 +31,7 @@ blueprint = Blueprint('google_oauth', __name__)
|
||||
|
||||
|
||||
def get_user_profile(access_token):
|
||||
headers = {'Authorization': 'OAuth '+access_token}
|
||||
headers = {'Authorization': 'OAuth {}'.format(access_token)}
|
||||
response = requests.get('https://www.googleapis.com/oauth2/v1/userinfo', headers=headers)
|
||||
|
||||
if response.status_code == 401:
|
||||
@@ -41,9 +41,17 @@ def get_user_profile(access_token):
|
||||
return response.json()
|
||||
|
||||
|
||||
def verify_profile(profile):
|
||||
if not settings.GOOGLE_APPS_DOMAIN:
|
||||
return True
|
||||
|
||||
domain = profile['email'].split('@')[-1]
|
||||
return domain in settings.GOOGLE_APPS_DOMAIN
|
||||
|
||||
|
||||
def create_and_login_user(name, email):
|
||||
try:
|
||||
user_object = models.User.get(models.User.email == email)
|
||||
user_object = models.User.get_by_email(email)
|
||||
if user_object.name != name:
|
||||
logger.debug("Updating user name (%r -> %r)", user_object.name, name)
|
||||
user_object.name = name
|
||||
@@ -70,10 +78,17 @@ def authorized(resp):
|
||||
|
||||
if access_token is None:
|
||||
logger.warning("Access token missing in call back request.")
|
||||
flash("Validation error. Please retry.")
|
||||
return redirect(url_for('login'))
|
||||
|
||||
profile = get_user_profile(access_token)
|
||||
if profile is None:
|
||||
flash("Validation error. Please retry.")
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if not verify_profile(profile):
|
||||
logger.warning("User tried to login with unauthorized domain name: %s", profile['email'])
|
||||
flash("Your Google Apps domain name isn't allowed.")
|
||||
return redirect(url_for('login'))
|
||||
|
||||
create_and_login_user(profile['name'], profile['email'])
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from redash import models
|
||||
from flask.ext.script import Manager
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
class Importer(object):
|
||||
def __init__(self, object_mapping=None, data_source=None):
|
||||
@@ -22,22 +25,17 @@ class Importer(object):
|
||||
|
||||
return query_result
|
||||
|
||||
|
||||
def import_query(self, user, query):
|
||||
query_result = self.import_query_result(query['latest_query_data'])
|
||||
|
||||
new_query = self._get_or_create(models.Query, query['id'], name=query['name'],
|
||||
user=user,
|
||||
ttl=-1,
|
||||
schedule=None,
|
||||
query=query['query'],
|
||||
query_hash=query['query_hash'],
|
||||
description=query['description'],
|
||||
latest_query_data=query_result,
|
||||
data_source=self.data_source)
|
||||
|
||||
return new_query
|
||||
|
||||
|
||||
def import_visualization(self, user, visualization):
|
||||
query = self.import_query(user, visualization['query'])
|
||||
|
||||
@@ -50,9 +48,13 @@ class Importer(object):
|
||||
return new_visualization
|
||||
|
||||
def import_widget(self, dashboard, widget):
|
||||
visualization = self.import_visualization(dashboard.user, widget['visualization'])
|
||||
if 'visualization' in widget:
|
||||
visualization = self.import_visualization(dashboard.user, widget['visualization'])
|
||||
else:
|
||||
visualization = None
|
||||
|
||||
new_widget = self._get_or_create(models.Widget, widget['id'],
|
||||
text=widget.get('text', None),
|
||||
dashboard=dashboard,
|
||||
width=widget['width'],
|
||||
options=json.dumps(widget['options']),
|
||||
@@ -91,6 +93,7 @@ class Importer(object):
|
||||
|
||||
def _get_or_create(self, object_type, external_id, **properties):
|
||||
internal_id = self._get_mapping(object_type, external_id)
|
||||
logger.info("Creating %s with external id: %s and internal id: %s", object_type, external_id, internal_id)
|
||||
if internal_id:
|
||||
update = object_type.update(**properties).where(object_type.id == internal_id)
|
||||
update.execute()
|
||||
@@ -114,11 +117,21 @@ export_manager = Manager(help="export utilities")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def importer_with_mapping_file(mapping_filename):
|
||||
def importer_with_mapping_file(mapping_filename, data_source_id=None):
|
||||
# Touch file in case it doesn't exists
|
||||
if not os.path.isfile(mapping_filename):
|
||||
with open(mapping_filename, 'w') as f:
|
||||
f.write("{}")
|
||||
|
||||
with open(mapping_filename) as f:
|
||||
mapping = json.loads(f.read())
|
||||
|
||||
importer = Importer(object_mapping=mapping, data_source=get_data_source())
|
||||
if data_source_id is not None:
|
||||
data_source = models.DataSource.get_by_id(data_source_id)
|
||||
else:
|
||||
data_source = get_data_source()
|
||||
|
||||
importer = Importer(object_mapping=mapping, data_source=data_source)
|
||||
yield importer
|
||||
|
||||
with open(mapping_filename, 'w') as f:
|
||||
@@ -146,12 +159,13 @@ def query(mapping_filename, query_filename, user_id):
|
||||
|
||||
|
||||
@import_manager.command
|
||||
def dashboard(mapping_filename, dashboard_filename, user_id):
|
||||
def dashboard(mapping_filename, dashboard_filename, user_id, data_source_id=None):
|
||||
user = models.User.get_by_id(user_id)
|
||||
|
||||
with open(dashboard_filename) as f:
|
||||
dashboard = json.loads(f.read())
|
||||
|
||||
with importer_with_mapping_file(mapping_filename) as importer:
|
||||
with importer_with_mapping_file(mapping_filename, data_source_id) as importer:
|
||||
importer.import_dashboard(user, dashboard)
|
||||
|
||||
|
||||
|
||||
414
redash/models.py
414
redash/models.py
@@ -9,17 +9,21 @@ import itertools
|
||||
|
||||
import peewee
|
||||
from passlib.apps import custom_app_context as pwd_context
|
||||
from playhouse.postgres_ext import ArrayField
|
||||
from playhouse.postgres_ext import ArrayField, DateTimeTZField, PostgresqlExtDatabase
|
||||
from flask.ext.login import UserMixin, AnonymousUserMixin
|
||||
import psycopg2
|
||||
|
||||
from redash import utils, settings
|
||||
from redash import utils, settings, redis_connection
|
||||
from redash.query_runner import get_query_runner
|
||||
from utils import generate_token
|
||||
|
||||
|
||||
class Database(object):
|
||||
def __init__(self):
|
||||
self.database_config = dict(settings.DATABASE_CONFIG)
|
||||
self.database_config['register_hstore'] = False
|
||||
self.database_name = self.database_config.pop('name')
|
||||
self.database = peewee.PostgresqlDatabase(self.database_name, **self.database_config)
|
||||
self.database = PostgresqlExtDatabase(self.database_name, **self.database_config)
|
||||
self.app = None
|
||||
self.pid = os.getpid()
|
||||
|
||||
@@ -59,17 +63,68 @@ class BaseModel(peewee.Model):
|
||||
def get_by_id(cls, model_id):
|
||||
return cls.get(cls.id == model_id)
|
||||
|
||||
def pre_save(self, created):
|
||||
pass
|
||||
|
||||
class AnonymousUser(AnonymousUserMixin):
|
||||
def post_save(self, created):
|
||||
# Handler for post_save operations. Overriding if needed.
|
||||
pass
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
pk_value = self._get_pk_value()
|
||||
created = kwargs.get('force_insert', False) or not bool(pk_value)
|
||||
self.pre_save(created)
|
||||
super(BaseModel, self).save(*args, **kwargs)
|
||||
self.post_save(created)
|
||||
|
||||
def update_instance(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
# setattr(model_instance, field_name, field_obj.python_value(value))
|
||||
setattr(self, k, v)
|
||||
|
||||
dirty_fields = self.dirty_fields
|
||||
if hasattr(self, 'updated_at'):
|
||||
dirty_fields = dirty_fields + [self.__class__.updated_at]
|
||||
|
||||
self.save(only=dirty_fields)
|
||||
|
||||
|
||||
class ModelTimestampsMixin(BaseModel):
|
||||
updated_at = DateTimeTZField(default=datetime.datetime.now)
|
||||
created_at = DateTimeTZField(default=datetime.datetime.now)
|
||||
|
||||
def pre_save(self, created):
|
||||
super(ModelTimestampsMixin, self).pre_save(created)
|
||||
|
||||
self.updated_at = datetime.datetime.now()
|
||||
|
||||
|
||||
class PermissionsCheckMixin(object):
|
||||
def has_permission(self, permission):
|
||||
return self.has_permissions((permission,))
|
||||
|
||||
def has_permissions(self, permissions):
|
||||
has_permissions = reduce(lambda a, b: a and b,
|
||||
map(lambda permission: permission in self.permissions,
|
||||
permissions),
|
||||
True)
|
||||
|
||||
return has_permissions
|
||||
|
||||
|
||||
class AnonymousUser(AnonymousUserMixin, PermissionsCheckMixin):
|
||||
@property
|
||||
def permissions(self):
|
||||
return []
|
||||
|
||||
|
||||
class ApiUser(UserMixin):
|
||||
class ApiUser(UserMixin, PermissionsCheckMixin):
|
||||
def __init__(self, api_key):
|
||||
self.id = api_key
|
||||
|
||||
def __repr__(self):
|
||||
return u"<ApiUser: {}>".format(self.id)
|
||||
|
||||
@property
|
||||
def permissions(self):
|
||||
return ['view_query']
|
||||
@@ -83,7 +138,7 @@ class Group(BaseModel):
|
||||
name = peewee.CharField(max_length=100)
|
||||
permissions = ArrayField(peewee.CharField, default=DEFAULT_PERMISSIONS)
|
||||
tables = ArrayField(peewee.CharField)
|
||||
created_at = peewee.DateTimeField(default=datetime.datetime.now)
|
||||
created_at = DateTimeTZField(default=datetime.datetime.now)
|
||||
|
||||
class Meta:
|
||||
db_table = 'groups'
|
||||
@@ -101,7 +156,7 @@ class Group(BaseModel):
|
||||
return unicode(self.id)
|
||||
|
||||
|
||||
class User(BaseModel, UserMixin):
|
||||
class User(ModelTimestampsMixin, BaseModel, UserMixin, PermissionsCheckMixin):
|
||||
DEFAULT_GROUPS = ['default']
|
||||
|
||||
id = peewee.PrimaryKeyField()
|
||||
@@ -109,6 +164,7 @@ class User(BaseModel, UserMixin):
|
||||
email = peewee.CharField(max_length=320, index=True, unique=True)
|
||||
password_hash = peewee.CharField(max_length=128, null=True)
|
||||
groups = ArrayField(peewee.CharField, default=DEFAULT_GROUPS)
|
||||
api_key = peewee.CharField(max_length=40, unique=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
@@ -117,13 +173,27 @@ class User(BaseModel, UserMixin):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'email': self.email
|
||||
'email': self.email,
|
||||
'gravatar_url': self.gravatar_url,
|
||||
'updated_at': self.updated_at,
|
||||
'created_at': self.created_at
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(User, self).__init__(*args, **kwargs)
|
||||
self._allowed_tables = None
|
||||
|
||||
def pre_save(self, created):
|
||||
super(User, self).pre_save(created)
|
||||
|
||||
if not self.api_key:
|
||||
self.api_key = generate_token(40)
|
||||
|
||||
@property
|
||||
def gravatar_url(self):
|
||||
email_md5 = hashlib.md5(self.email.lower()).hexdigest()
|
||||
return "https://www.gravatar.com/avatar/%s?s=40" % email_md5
|
||||
|
||||
@property
|
||||
def permissions(self):
|
||||
# TODO: this should be cached.
|
||||
@@ -143,8 +213,12 @@ class User(BaseModel, UserMixin):
|
||||
def get_by_email(cls, email):
|
||||
return cls.get(cls.email == email)
|
||||
|
||||
@classmethod
|
||||
def get_by_api_key(cls, api_key):
|
||||
return cls.get(cls.api_key == api_key)
|
||||
|
||||
def __unicode__(self):
|
||||
return '%r, %r' % (self.name, self.email)
|
||||
return u'%s (%s)' % (self.name, self.email)
|
||||
|
||||
def hash_password(self, password):
|
||||
self.password_hash = pwd_context.encrypt(password)
|
||||
@@ -160,7 +234,7 @@ class ActivityLog(BaseModel):
|
||||
user = peewee.ForeignKeyField(User)
|
||||
type = peewee.IntegerField()
|
||||
activity = peewee.TextField()
|
||||
created_at = peewee.DateTimeField(default=datetime.datetime.now)
|
||||
created_at = DateTimeTZField(default=datetime.datetime.now)
|
||||
|
||||
class Meta:
|
||||
db_table = 'activity_log'
|
||||
@@ -180,28 +254,68 @@ class ActivityLog(BaseModel):
|
||||
|
||||
class DataSource(BaseModel):
|
||||
id = peewee.PrimaryKeyField()
|
||||
name = peewee.CharField()
|
||||
name = peewee.CharField(unique=True)
|
||||
type = peewee.CharField()
|
||||
options = peewee.TextField()
|
||||
queue_name = peewee.CharField(default="queries")
|
||||
scheduled_queue_name = peewee.CharField(default="queries")
|
||||
created_at = peewee.DateTimeField(default=datetime.datetime.now)
|
||||
scheduled_queue_name = peewee.CharField(default="scheduled_queries")
|
||||
created_at = DateTimeTZField(default=datetime.datetime.now)
|
||||
|
||||
class Meta:
|
||||
db_table = 'data_sources'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
def to_dict(self, all=False):
|
||||
d = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'type': self.type
|
||||
'type': self.type,
|
||||
'syntax': self.query_runner.syntax
|
||||
}
|
||||
|
||||
if all:
|
||||
d['options'] = json.loads(self.options)
|
||||
d['queue_name'] = self.queue_name
|
||||
d['scheduled_queue_name'] = self.scheduled_queue_name
|
||||
|
||||
return d
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def get_schema(self, refresh=False):
|
||||
key = "data_source:schema:{}".format(self.id)
|
||||
|
||||
cache = None
|
||||
if not refresh:
|
||||
cache = redis_connection.get(key)
|
||||
|
||||
if cache is None:
|
||||
query_runner = self.query_runner
|
||||
schema = sorted(query_runner.get_schema(), key=lambda t: t['name'])
|
||||
|
||||
redis_connection.set(key, json.dumps(schema))
|
||||
else:
|
||||
schema = json.loads(cache)
|
||||
|
||||
return schema
|
||||
|
||||
@property
|
||||
def query_runner(self):
|
||||
return get_query_runner(self.type, self.options)
|
||||
|
||||
@classmethod
|
||||
def all(cls):
|
||||
return cls.select().order_by(cls.id.asc())
|
||||
|
||||
|
||||
class JSONField(peewee.TextField):
|
||||
def db_value(self, value):
|
||||
return json.dumps(value)
|
||||
|
||||
def python_value(self, value):
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
class QueryResult(BaseModel):
|
||||
id = peewee.PrimaryKeyField()
|
||||
data_source = peewee.ForeignKeyField(DataSource)
|
||||
@@ -209,7 +323,7 @@ class QueryResult(BaseModel):
|
||||
query = peewee.TextField()
|
||||
data = peewee.TextField()
|
||||
runtime = peewee.FloatField()
|
||||
retrieved_at = peewee.DateTimeField()
|
||||
retrieved_at = DateTimeTZField()
|
||||
|
||||
class Meta:
|
||||
db_table = 'query_results'
|
||||
@@ -226,16 +340,25 @@ class QueryResult(BaseModel):
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_latest(cls, data_source, query, ttl=0):
|
||||
def unused(cls):
|
||||
week_ago = datetime.datetime.now() - datetime.timedelta(days=7)
|
||||
|
||||
unused_results = cls.select().where(Query.id == None, cls.retrieved_at < week_ago)\
|
||||
.join(Query, join_type=peewee.JOIN_LEFT_OUTER)
|
||||
|
||||
return unused_results
|
||||
|
||||
@classmethod
|
||||
def get_latest(cls, data_source, query, max_age=0):
|
||||
query_hash = utils.gen_query_hash(query)
|
||||
|
||||
if ttl == -1:
|
||||
if max_age == -1:
|
||||
query = cls.select().where(cls.query_hash == query_hash,
|
||||
cls.data_source == data_source).order_by(cls.retrieved_at.desc())
|
||||
else:
|
||||
query = cls.select().where(cls.query_hash == query_hash, cls.data_source == data_source,
|
||||
peewee.SQL("retrieved_at + interval '%s second' >= now() at time zone 'utc'",
|
||||
ttl)).order_by(cls.retrieved_at.desc())
|
||||
max_age)).order_by(cls.retrieved_at.desc())
|
||||
|
||||
return query.first()
|
||||
|
||||
@@ -250,41 +373,61 @@ class QueryResult(BaseModel):
|
||||
|
||||
logging.info("Inserted query (%s) data; id=%s", query_hash, query_result.id)
|
||||
|
||||
updated_count = Query.update(latest_query_data=query_result).\
|
||||
where(Query.query_hash==query_hash, Query.data_source==data_source_id).\
|
||||
execute()
|
||||
sql = "UPDATE queries SET latest_query_data_id = %s WHERE query_hash = %s AND data_source_id = %s RETURNING id"
|
||||
query_ids = [row[0] for row in db.database.execute_sql(sql, params=(query_result.id, query_hash, data_source_id))]
|
||||
|
||||
logging.info("Updated %s queries with result (%s).", updated_count, query_hash)
|
||||
# TODO: when peewee with update & returning support is released, we can get back to using this code:
|
||||
# updated_count = Query.update(latest_query_data=query_result).\
|
||||
# where(Query.query_hash==query_hash, Query.data_source==data_source_id).\
|
||||
# execute()
|
||||
|
||||
return query_result
|
||||
logging.info("Updated %s queries with result (%s).", len(query_ids), query_hash)
|
||||
|
||||
return query_result, query_ids
|
||||
|
||||
def __unicode__(self):
|
||||
return u"%d | %s | %s" % (self.id, self.query_hash, self.retrieved_at)
|
||||
|
||||
|
||||
class Query(BaseModel):
|
||||
def should_schedule_next(previous_iteration, now, schedule):
|
||||
if schedule.isdigit():
|
||||
ttl = int(schedule)
|
||||
next_iteration = previous_iteration + datetime.timedelta(seconds=ttl)
|
||||
else:
|
||||
hour, minute = schedule.split(':')
|
||||
hour, minute = int(hour), int(minute)
|
||||
|
||||
# The following logic is needed for cases like the following:
|
||||
# - The query scheduled to run at 23:59.
|
||||
# - The scheduler wakes up at 00:01.
|
||||
# - Using naive implementation of comparing timestamps, it will skip the execution.
|
||||
normalized_previous_iteration = previous_iteration.replace(hour=hour, minute=minute)
|
||||
if normalized_previous_iteration > previous_iteration:
|
||||
previous_iteration = normalized_previous_iteration - datetime.timedelta(days=1)
|
||||
|
||||
next_iteration = (previous_iteration + datetime.timedelta(days=1)).replace(hour=hour, minute=minute)
|
||||
|
||||
return now > next_iteration
|
||||
|
||||
|
||||
class Query(ModelTimestampsMixin, BaseModel):
|
||||
id = peewee.PrimaryKeyField()
|
||||
data_source = peewee.ForeignKeyField(DataSource)
|
||||
data_source = peewee.ForeignKeyField(DataSource, null=True)
|
||||
latest_query_data = peewee.ForeignKeyField(QueryResult, null=True)
|
||||
name = peewee.CharField(max_length=255)
|
||||
description = peewee.CharField(max_length=4096, null=True)
|
||||
query = peewee.TextField()
|
||||
query_hash = peewee.CharField(max_length=32)
|
||||
api_key = peewee.CharField(max_length=40)
|
||||
ttl = peewee.IntegerField()
|
||||
user_email = peewee.CharField(max_length=360, null=True)
|
||||
user = peewee.ForeignKeyField(User)
|
||||
created_at = peewee.DateTimeField(default=datetime.datetime.now)
|
||||
last_modified_by = peewee.ForeignKeyField(User, null=True, related_name="modified_queries")
|
||||
is_archived = peewee.BooleanField(default=False, index=True)
|
||||
schedule = peewee.CharField(max_length=10, null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'queries'
|
||||
|
||||
def create_default_visualizations(self):
|
||||
table_visualization = Visualization(query=self, name="Table",
|
||||
description='',
|
||||
type="TABLE", options="{}")
|
||||
table_visualization.save()
|
||||
|
||||
def to_dict(self, with_stats=False, with_visualizations=False, with_user=True):
|
||||
d = {
|
||||
'id': self.id,
|
||||
@@ -293,14 +436,17 @@ class Query(BaseModel):
|
||||
'description': self.description,
|
||||
'query': self.query,
|
||||
'query_hash': self.query_hash,
|
||||
'ttl': self.ttl,
|
||||
'schedule': self.schedule,
|
||||
'api_key': self.api_key,
|
||||
'is_archived': self.is_archived,
|
||||
'updated_at': self.updated_at,
|
||||
'created_at': self.created_at,
|
||||
'data_source_id': self._data.get('data_source', None)
|
||||
}
|
||||
|
||||
if with_user:
|
||||
d['user'] = self.user.to_dict()
|
||||
d['last_modified_by'] = self.last_modified_by.to_dict() if self.last_modified_by is not None else None
|
||||
else:
|
||||
d['user_id'] = self._data['user']
|
||||
|
||||
@@ -314,42 +460,68 @@ class Query(BaseModel):
|
||||
|
||||
return d
|
||||
|
||||
def archive(self):
|
||||
self.is_archived = True
|
||||
self.schedule = None
|
||||
|
||||
for vis in self.visualizations:
|
||||
for w in vis.widgets:
|
||||
w.delete_instance()
|
||||
|
||||
self.save()
|
||||
|
||||
@classmethod
|
||||
def all_queries(cls):
|
||||
q = Query.select(Query, User, QueryResult.retrieved_at, QueryResult.runtime)\
|
||||
.join(QueryResult, join_type=peewee.JOIN_LEFT_OUTER)\
|
||||
.switch(Query).join(User)\
|
||||
.group_by(Query.id, User.id, QueryResult.id, QueryResult.retrieved_at, QueryResult.runtime)
|
||||
.where(Query.is_archived==False)\
|
||||
.group_by(Query.id, User.id, QueryResult.id, QueryResult.retrieved_at, QueryResult.runtime)\
|
||||
.order_by(cls.created_at.desc())
|
||||
|
||||
return q
|
||||
|
||||
@classmethod
|
||||
def outdated_queries(cls):
|
||||
# TODO: this will only find scheduled queries that were executed before. I think this is
|
||||
# a reasonable assumption, but worth revisiting.
|
||||
outdated_queries_ids = cls.select(
|
||||
peewee.Func('first_value', cls.id).over(partition_by=[cls.query_hash, cls.data_source])) \
|
||||
.join(QueryResult) \
|
||||
.where(cls.ttl > 0,
|
||||
(QueryResult.retrieved_at +
|
||||
(cls.ttl * peewee.SQL("interval '1 second'"))) <
|
||||
peewee.SQL("(now() at time zone 'utc')"))
|
||||
queries = cls.select(cls, QueryResult.retrieved_at, DataSource)\
|
||||
.join(QueryResult)\
|
||||
.switch(Query).join(DataSource)\
|
||||
.where(cls.schedule != None)
|
||||
|
||||
queries = cls.select(cls, DataSource).join(DataSource) \
|
||||
.where(cls.id << outdated_queries_ids)
|
||||
now = utils.utcnow()
|
||||
outdated_queries = {}
|
||||
for query in queries:
|
||||
if should_schedule_next(query.latest_query_data.retrieved_at, now, query.schedule):
|
||||
key = "{}:{}".format(query.query_hash, query.data_source.id)
|
||||
outdated_queries[key] = query
|
||||
|
||||
return queries
|
||||
return outdated_queries.values()
|
||||
|
||||
@classmethod
|
||||
def search(cls, term):
|
||||
# This is very naive implementation of search, to be replaced with PostgreSQL full-text-search solution.
|
||||
|
||||
where = (cls.name**"%{}%".format(term)) | (cls.description**"%{}%".format(term))
|
||||
where = (cls.name**u"%{}%".format(term)) | (cls.description**u"%{}%".format(term))
|
||||
|
||||
if term.isdigit():
|
||||
where |= cls.id == term
|
||||
|
||||
return cls.select().where(where)
|
||||
where &= cls.is_archived == False
|
||||
|
||||
return cls.select().where(where).order_by(cls.created_at.desc())
|
||||
|
||||
@classmethod
|
||||
def recent(cls, user_id):
|
||||
# TODO: instead of t2 here, we should define table_alias for Query table
|
||||
return cls.select().where(Event.created_at > peewee.SQL("current_date - 7")).\
|
||||
join(Event, on=(Query.id == peewee.SQL("t2.object_id::integer"))).\
|
||||
where(Event.action << ('edit', 'execute', 'edit_name', 'edit_description', 'view_source')).\
|
||||
where(Event.user == user_id).\
|
||||
where(~(Event.object_id >> None)).\
|
||||
where(Event.object_type == 'query'). \
|
||||
where(cls.is_archived == False).\
|
||||
group_by(Event.object_id, Query.id).\
|
||||
order_by(peewee.SQL("count(0) desc"))
|
||||
|
||||
@classmethod
|
||||
def update_instance(cls, query_id, **kwargs):
|
||||
@@ -359,10 +531,23 @@ class Query(BaseModel):
|
||||
update = cls.update(**kwargs).where(cls.id == query_id)
|
||||
return update.execute()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
def pre_save(self, created):
|
||||
super(Query, self).pre_save(created)
|
||||
self.query_hash = utils.gen_query_hash(self.query)
|
||||
self._set_api_key()
|
||||
super(Query, self).save(*args, **kwargs)
|
||||
|
||||
if self.last_modified_by is None:
|
||||
self.last_modified_by = self.user
|
||||
|
||||
def post_save(self, created):
|
||||
if created:
|
||||
self._create_default_visualizations()
|
||||
|
||||
def _create_default_visualizations(self):
|
||||
table_visualization = Visualization(query=self, name="Table",
|
||||
description='',
|
||||
type="TABLE", options="{}")
|
||||
table_visualization.save()
|
||||
|
||||
def _set_api_key(self):
|
||||
if not self.api_key:
|
||||
@@ -381,7 +566,84 @@ class Query(BaseModel):
|
||||
return unicode(self.id)
|
||||
|
||||
|
||||
class Dashboard(BaseModel):
|
||||
class Alert(ModelTimestampsMixin, BaseModel):
|
||||
UNKNOWN_STATE = 'unknown'
|
||||
OK_STATE = 'ok'
|
||||
TRIGGERED_STATE = 'triggered'
|
||||
|
||||
id = peewee.PrimaryKeyField()
|
||||
name = peewee.CharField()
|
||||
query = peewee.ForeignKeyField(Query, related_name='alerts')
|
||||
user = peewee.ForeignKeyField(User, related_name='alerts')
|
||||
options = JSONField()
|
||||
state = peewee.CharField(default=UNKNOWN_STATE)
|
||||
last_triggered_at = DateTimeTZField(null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'alerts'
|
||||
|
||||
@classmethod
|
||||
def all(cls):
|
||||
return cls.select(Alert, User, Query).join(Query).switch(Alert).join(User)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'query': self.query.to_dict(),
|
||||
'user': self.user.to_dict(),
|
||||
'options': self.options,
|
||||
'state': self.state,
|
||||
'last_triggered_at': self.last_triggered_at,
|
||||
'updated_at': self.updated_at,
|
||||
'created_at': self.created_at
|
||||
}
|
||||
|
||||
def evaluate(self):
|
||||
data = json.loads(self.query.latest_query_data.data)
|
||||
# todo: safe guard for empty
|
||||
value = data['rows'][0][self.options['column']]
|
||||
op = self.options['op']
|
||||
|
||||
if op == 'greater than' and value > self.options['value']:
|
||||
new_state = self.TRIGGERED_STATE
|
||||
elif op == 'less than' and value < self.options['value']:
|
||||
new_state = self.TRIGGERED_STATE
|
||||
elif op == 'equals' and value == self.options['value']:
|
||||
new_state = self.TRIGGERED_STATE
|
||||
else:
|
||||
new_state = self.OK_STATE
|
||||
|
||||
return new_state
|
||||
|
||||
def subscribers(self):
|
||||
return User.select().join(AlertSubscription).where(AlertSubscription.alert==self)
|
||||
|
||||
|
||||
class AlertSubscription(ModelTimestampsMixin, BaseModel):
|
||||
user = peewee.ForeignKeyField(User)
|
||||
alert = peewee.ForeignKeyField(Alert)
|
||||
|
||||
class Meta:
|
||||
db_table = 'alert_subscriptions'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'user': self.user.to_dict(),
|
||||
'alert_id': self._data['alert']
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def all(cls, alert_id):
|
||||
return AlertSubscription.select(AlertSubscription, User).join(User).where(AlertSubscription.alert==alert_id)
|
||||
|
||||
@classmethod
|
||||
def unsubscribe(cls, alert_id, user_id):
|
||||
query = AlertSubscription.delete().where(AlertSubscription.alert==alert_id).where(AlertSubscription.user==user_id)
|
||||
return query.execute()
|
||||
|
||||
|
||||
class Dashboard(ModelTimestampsMixin, BaseModel):
|
||||
id = peewee.PrimaryKeyField()
|
||||
slug = peewee.CharField(max_length=140, index=True)
|
||||
name = peewee.CharField(max_length=100)
|
||||
@@ -390,7 +652,6 @@ class Dashboard(BaseModel):
|
||||
layout = peewee.TextField()
|
||||
dashboard_filters_enabled = peewee.BooleanField(default=False)
|
||||
is_archived = peewee.BooleanField(default=False, index=True)
|
||||
created_at = peewee.DateTimeField(default=datetime.datetime.now)
|
||||
|
||||
class Meta:
|
||||
db_table = 'dashboards'
|
||||
@@ -432,13 +693,26 @@ class Dashboard(BaseModel):
|
||||
'user_id': self._data['user'],
|
||||
'layout': layout,
|
||||
'dashboard_filters_enabled': self.dashboard_filters_enabled,
|
||||
'widgets': widgets_layout
|
||||
'widgets': widgets_layout,
|
||||
'updated_at': self.updated_at,
|
||||
'created_at': self.created_at
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug):
|
||||
return cls.get(cls.slug == slug)
|
||||
|
||||
@classmethod
|
||||
def recent(cls, user_id):
|
||||
return cls.select().where(Event.created_at > peewee.SQL("current_date - 7")). \
|
||||
join(Event, on=(Dashboard.id == peewee.SQL("t2.object_id::integer"))). \
|
||||
where(Event.action << ('edit', 'view')).\
|
||||
where(Event.user == user_id). \
|
||||
where(~(Event.object_id >> None)). \
|
||||
where(Event.object_type == 'dashboard'). \
|
||||
group_by(Event.object_id, Dashboard.id). \
|
||||
order_by(peewee.SQL("count(0) desc"))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = utils.slugify(self.name)
|
||||
@@ -454,7 +728,7 @@ class Dashboard(BaseModel):
|
||||
return u"%s=%s" % (self.id, self.name)
|
||||
|
||||
|
||||
class Visualization(BaseModel):
|
||||
class Visualization(ModelTimestampsMixin, BaseModel):
|
||||
id = peewee.PrimaryKeyField()
|
||||
type = peewee.CharField(max_length=100)
|
||||
query = peewee.ForeignKeyField(Query, related_name='visualizations')
|
||||
@@ -472,6 +746,8 @@ class Visualization(BaseModel):
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'options': json.loads(self.options),
|
||||
'updated_at': self.updated_at,
|
||||
'created_at': self.created_at
|
||||
}
|
||||
|
||||
if with_query:
|
||||
@@ -483,14 +759,13 @@ class Visualization(BaseModel):
|
||||
return u"%s %s" % (self.id, self.type)
|
||||
|
||||
|
||||
class Widget(BaseModel):
|
||||
class Widget(ModelTimestampsMixin, BaseModel):
|
||||
id = peewee.PrimaryKeyField()
|
||||
visualization = peewee.ForeignKeyField(Visualization, related_name='widgets', null=True)
|
||||
text = peewee.TextField(null=True)
|
||||
width = peewee.IntegerField()
|
||||
options = peewee.TextField()
|
||||
dashboard = peewee.ForeignKeyField(Dashboard, related_name='widgets', index=True)
|
||||
created_at = peewee.DateTimeField(default=datetime.datetime.now)
|
||||
|
||||
# unused; kept for backward compatability:
|
||||
type = peewee.CharField(max_length=100, null=True)
|
||||
@@ -505,25 +780,35 @@ class Widget(BaseModel):
|
||||
'width': self.width,
|
||||
'options': json.loads(self.options),
|
||||
'dashboard_id': self._data['dashboard'],
|
||||
'text': self.text
|
||||
'text': self.text,
|
||||
'updated_at': self.updated_at,
|
||||
'created_at': self.created_at
|
||||
}
|
||||
|
||||
if self.visualization and self.visualization.id:
|
||||
d['visualization'] = self.visualization.to_dict()
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def __unicode__(self):
|
||||
return u"%s" % self.id
|
||||
|
||||
def delete_instance(self, *args, **kwargs):
|
||||
layout = json.loads(self.dashboard.layout)
|
||||
layout = map(lambda row: filter(lambda w: w != self.id, row), layout)
|
||||
layout = filter(lambda row: len(row) > 0, layout)
|
||||
self.dashboard.layout = json.dumps(layout)
|
||||
self.dashboard.save()
|
||||
super(Widget, self).delete_instance(*args, **kwargs)
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
user = peewee.ForeignKeyField(User, related_name="events")
|
||||
user = peewee.ForeignKeyField(User, related_name="events", null=True)
|
||||
action = peewee.CharField()
|
||||
object_type = peewee.CharField()
|
||||
object_id = peewee.CharField(null=True)
|
||||
additional_properties = peewee.TextField(null=True)
|
||||
created_at = peewee.DateTimeField(default=datetime.datetime.now)
|
||||
created_at = DateTimeTZField(default=datetime.datetime.now)
|
||||
|
||||
class Meta:
|
||||
db_table = 'events'
|
||||
@@ -547,7 +832,7 @@ class Event(BaseModel):
|
||||
return event
|
||||
|
||||
|
||||
all_models = (DataSource, User, QueryResult, Query, Dashboard, Visualization, Widget, ActivityLog, Group, Event)
|
||||
all_models = (DataSource, User, QueryResult, Query, Alert, Dashboard, Visualization, Widget, ActivityLog, Group, Event)
|
||||
|
||||
|
||||
def init_db():
|
||||
@@ -562,7 +847,6 @@ def create_db(create_tables, drop_tables):
|
||||
if drop_tables and model.table_exists():
|
||||
# TODO: submit PR to peewee to allow passing cascade option to drop_table.
|
||||
db.database.execute_sql('DROP TABLE %s CASCADE' % model._meta.db_table)
|
||||
#model.drop_table()
|
||||
|
||||
if create_tables and not model.table_exists():
|
||||
model.create_table()
|
||||
|
||||
33
redash/monitor.py
Normal file
33
redash/monitor.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from redash import redis_connection, models, __version__
|
||||
|
||||
def get_status():
|
||||
status = {}
|
||||
info = redis_connection.info()
|
||||
status['redis_used_memory'] = info['used_memory_human']
|
||||
status['version'] = __version__
|
||||
status['queries_count'] = models.Query.select().count()
|
||||
status['query_results_count'] = models.QueryResult.select().count()
|
||||
status['unused_query_results_count'] = models.QueryResult.unused().count()
|
||||
status['dashboards_count'] = models.Dashboard.select().count()
|
||||
status['widgets_count'] = models.Widget.select().count()
|
||||
|
||||
status['workers'] = []
|
||||
|
||||
manager_status = redis_connection.hgetall('redash:status')
|
||||
status['manager'] = manager_status
|
||||
status['manager']['outdated_queries_count'] = len(models.Query.outdated_queries())
|
||||
|
||||
queues = {}
|
||||
for ds in models.DataSource.select():
|
||||
for queue in (ds.queue_name, ds.scheduled_queue_name):
|
||||
queues.setdefault(queue, set())
|
||||
queues[queue].add(ds.name)
|
||||
|
||||
status['manager']['queues'] = {}
|
||||
for queue, sources in queues.iteritems():
|
||||
status['manager']['queues'][queue] = {
|
||||
'data_sources': ', '.join(sources),
|
||||
'size': redis_connection.llen(queue)
|
||||
}
|
||||
|
||||
return status
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user