mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Compare commits
383 Commits
0.7.0
...
v0.8.3.b11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32c0d3eb3d | ||
|
|
1bee22a578 | ||
|
|
6bb57508e1 | ||
|
|
2d34bf1c54 | ||
|
|
7e3856b4f5 | ||
|
|
189e105c68 | ||
|
|
378459d64f | ||
|
|
ab72531889 | ||
|
|
51deb8f75d | ||
|
|
68f6e9b5e5 | ||
|
|
fbfa76f4d6 | ||
|
|
28e8e049eb | ||
|
|
f1f9597998 | ||
|
|
0b389d51aa | ||
|
|
46f3e82571 | ||
|
|
5b64918379 | ||
|
|
7549f32d9a | ||
|
|
6f51776cbb | ||
|
|
ad0afd8f3e | ||
|
|
8863282e58 | ||
|
|
9c1fda488c | ||
|
|
995659ee0d | ||
|
|
ad2642e9e5 | ||
|
|
740b305910 | ||
|
|
ca8cca0a8c | ||
|
|
7c4410ac63 | ||
|
|
91a209ae82 | ||
|
|
60cdb85cc4 | ||
|
|
becb4decf1 | ||
|
|
5f33e7ea18 | ||
|
|
7675de4ec7 | ||
|
|
fe2aa71349 | ||
|
|
b7720f7001 | ||
|
|
3b24f56eba | ||
|
|
52b8e98b1a | ||
|
|
5fe9c2fcf0 | ||
|
|
816142aa54 | ||
|
|
f737be272f | ||
|
|
0343fa7980 | ||
|
|
0f9f9a24a0 | ||
|
|
5b9b18639b | ||
|
|
ce46295dd3 | ||
|
|
3781b0758e | ||
|
|
8d20180d40 | ||
|
|
a7b41327c6 | ||
|
|
4d415c0246 | ||
|
|
5331008e78 | ||
|
|
80783feda6 | ||
|
|
2f308c3fa6 | ||
|
|
a63055f7f0 | ||
|
|
ce884ba6d3 | ||
|
|
63765281fe | ||
|
|
47e79003e5 | ||
|
|
541060c62e | ||
|
|
3ba19fa80f | ||
|
|
f3ec0448f5 | ||
|
|
654349a7ae | ||
|
|
2b32de184e | ||
|
|
1fb57edd1f | ||
|
|
f6c65d139a | ||
|
|
4e59472238 | ||
|
|
feabc46da4 | ||
|
|
51a10e5a20 | ||
|
|
5bf370d0f0 | ||
|
|
5beec581d8 | ||
|
|
70080df534 | ||
|
|
0d4c3c329e | ||
|
|
76dfbad971 | ||
|
|
45a85c110f | ||
|
|
f77c0aeb1d | ||
|
|
b23e328f69 | ||
|
|
165d782b98 | ||
|
|
1bdc1bef73 | ||
|
|
e3b41b15d7 | ||
|
|
7a95dec33b | ||
|
|
a3d059041c | ||
|
|
3a6c1599f3 | ||
|
|
f92aa7b15f | ||
|
|
d823506e5b | ||
|
|
fc93de7aa2 | ||
|
|
a0cc25d174 | ||
|
|
df24bc3aae | ||
|
|
60c2cb0a75 | ||
|
|
ad19f2d304 | ||
|
|
3aa59a8152 | ||
|
|
32638aebed | ||
|
|
346ea66c9d | ||
|
|
d14b74b683 | ||
|
|
5d879ce358 | ||
|
|
b4da4359a8 | ||
|
|
7e08518a31 | ||
|
|
bea0e9aad0 | ||
|
|
a87179b68b | ||
|
|
91806eda44 | ||
|
|
d1fe3d63fd | ||
|
|
8408409ce2 | ||
|
|
6bbdd5eb44 | ||
|
|
34ba54397d | ||
|
|
ec79ce74d0 | ||
|
|
f324f1bf6f | ||
|
|
47cfb7d620 | ||
|
|
dab1a21b40 | ||
|
|
aa04a6e4a5 | ||
|
|
e0a43a32ab | ||
|
|
68001ae0f1 | ||
|
|
9d9501b158 | ||
|
|
67aecc0201 | ||
|
|
0bc9fc1ed5 | ||
|
|
b548cb1d8f | ||
|
|
eb5c4dd5f3 | ||
|
|
a07a9b9390 | ||
|
|
56ade4735c | ||
|
|
b8a9f1048a | ||
|
|
5b3bcff4f5 | ||
|
|
b41b21c69e | ||
|
|
172d57e82c | ||
|
|
f507da9df7 | ||
|
|
2e27e43357 | ||
|
|
8a0c287d05 | ||
|
|
664a1806bc | ||
|
|
9a0ccd1bb5 | ||
|
|
076fca0c5a | ||
|
|
59f099418a | ||
|
|
b9a0760d7e | ||
|
|
a0c26c64f0 | ||
|
|
5f47689553 | ||
|
|
a5bc90c816 | ||
|
|
39b8f40ad4 | ||
|
|
070caa6976 | ||
|
|
56b51f68bc | ||
|
|
799ce3e718 | ||
|
|
9b47f0d08a | ||
|
|
4f4dc135f5 | ||
|
|
4eb490a839 | ||
|
|
410c5671f0 | ||
|
|
fad8bd47e8 | ||
|
|
89f5074054 | ||
|
|
5826fbd05f | ||
|
|
ddab1c9493 | ||
|
|
f9d5fe235b | ||
|
|
afe64fe981 | ||
|
|
99efe497ee | ||
|
|
9e183f1500 | ||
|
|
4b17b9869e | ||
|
|
872d58688f | ||
|
|
37272dc2d9 | ||
|
|
1a3df37940 | ||
|
|
ddbf264020 | ||
|
|
e93b71af85 | ||
|
|
13184519c3 | ||
|
|
0f8da884f9 | ||
|
|
21de1d90e3 | ||
|
|
ed9eb691c1 | ||
|
|
d6c229759f | ||
|
|
f0b8dfb449 | ||
|
|
6f335d34b9 | ||
|
|
bed63083a7 | ||
|
|
9886f5b13b | ||
|
|
f0ee7a67d2 | ||
|
|
9c43e1540e | ||
|
|
b0cb2d3f1c | ||
|
|
b525ad0622 | ||
|
|
602b9128a7 | ||
|
|
45d3b18c0c | ||
|
|
b1918743f2 | ||
|
|
716f36ef9c | ||
|
|
62aa21cdc8 | ||
|
|
4e30fc1054 | ||
|
|
5a1d38c572 | ||
|
|
360b0da159 | ||
|
|
cc91981845 | ||
|
|
e19962d4e3 | ||
|
|
99b6f8955e | ||
|
|
cf6ce0599b | ||
|
|
a699c04ee1 | ||
|
|
a8d7547dc7 | ||
|
|
72804e6d80 | ||
|
|
e51db087c5 | ||
|
|
0e9607205b | ||
|
|
9f799f4bfe | ||
|
|
17e0bd4cd2 | ||
|
|
102038b129 | ||
|
|
c01d88cbea | ||
|
|
9d6d88ebff | ||
|
|
3f429ebcb7 | ||
|
|
c854ce3c10 | ||
|
|
ab6cc3f146 | ||
|
|
97d0035f4a | ||
|
|
8108bc7cb1 | ||
|
|
690cb2fccd | ||
|
|
515c45776e | ||
|
|
fc44dba2ef | ||
|
|
5329fe547c | ||
|
|
d6bb6d33a3 | ||
|
|
9832b7f72a | ||
|
|
2a6ed3ca52 | ||
|
|
2e78ef0128 | ||
|
|
d2d52d44f7 | ||
|
|
987f4bd356 | ||
|
|
0c8c196d65 | ||
|
|
9d703b44de | ||
|
|
fb00350c58 | ||
|
|
6cccd30553 | ||
|
|
0bbcb69197 | ||
|
|
b0eaffdf6c | ||
|
|
407a649d17 | ||
|
|
73bd83a527 | ||
|
|
72e48a191b | ||
|
|
11682b3779 | ||
|
|
a15d7964fa | ||
|
|
2feb8b81f5 | ||
|
|
6286024350 | ||
|
|
0b5dce0ebf | ||
|
|
32311c55e6 | ||
|
|
2ac795d6f7 | ||
|
|
d50af7dec9 | ||
|
|
20159a1c2a | ||
|
|
06400ed840 | ||
|
|
0ddc6cf135 | ||
|
|
46a008346f | ||
|
|
21c413f699 | ||
|
|
e7222944a5 | ||
|
|
f49839eadf | ||
|
|
aa1b72908b | ||
|
|
5dd457e5f1 | ||
|
|
a471134e07 | ||
|
|
8a8f91ee8f | ||
|
|
59aa218b24 | ||
|
|
5fd8dbe523 | ||
|
|
a08f3c7cd0 | ||
|
|
824d053ddd | ||
|
|
b6e61deb24 | ||
|
|
4f40b28120 | ||
|
|
5d1c75df1c | ||
|
|
28ccaedfff | ||
|
|
1ee05e12fd | ||
|
|
6f91849419 | ||
|
|
65cc67d1dd | ||
|
|
a8f6d9e45b | ||
|
|
2c39a2faae | ||
|
|
1052528a5f | ||
|
|
92cd2f1367 | ||
|
|
990717a43d | ||
|
|
a2608d6a44 | ||
|
|
dedae03c8c | ||
|
|
61f2be02b7 | ||
|
|
9eca43801a | ||
|
|
bcaefda600 | ||
|
|
42b0430866 | ||
|
|
445dbb5ade | ||
|
|
40ee0d8a6e | ||
|
|
a5b738a035 | ||
|
|
e893ab4519 | ||
|
|
8b569379bc | ||
|
|
bff3e7c3b2 | ||
|
|
3fbd0d9579 | ||
|
|
00f4ec16f8 | ||
|
|
6f24b31858 | ||
|
|
7a8844180b | ||
|
|
aefaf204a3 | ||
|
|
1527ea36b1 | ||
|
|
a71b83d98a | ||
|
|
7add6287dc | ||
|
|
d37b5ed075 | ||
|
|
23b8b77feb | ||
|
|
46f1478e0d | ||
|
|
ec46312bf6 | ||
|
|
7c308bee09 | ||
|
|
5f656f3868 | ||
|
|
4e27331d56 | ||
|
|
8f28c52b8d | ||
|
|
47e6960b83 | ||
|
|
0990d93b03 | ||
|
|
bf88d8b578 | ||
|
|
384e756817 | ||
|
|
d2c46c99eb | ||
|
|
9c2858191f | ||
|
|
0473de7392 | ||
|
|
faece4f2c4 | ||
|
|
c9e74104b1 | ||
|
|
d100c915f4 | ||
|
|
ef3636145c | ||
|
|
6bd7dc9237 | ||
|
|
6210d6ab80 | ||
|
|
864a12a3be | ||
|
|
f48c47712d | ||
|
|
2c90fb3fa9 | ||
|
|
176fd16e95 | ||
|
|
75d3a63070 | ||
|
|
8c4a5a644e | ||
|
|
5b024a3518 | ||
|
|
d474267934 | ||
|
|
9429314b6e | ||
|
|
7cd132b47d | ||
|
|
89661990e7 | ||
|
|
01564d7e10 | ||
|
|
98307aec0d | ||
|
|
5de3de12f0 | ||
|
|
dea64734d6 | ||
|
|
98857ea64c | ||
|
|
3181f28509 | ||
|
|
37745ad1c0 | ||
|
|
5fe5c94b3d | ||
|
|
59cbafa724 | ||
|
|
1d99da5a32 | ||
|
|
8dfa1ca7bd | ||
|
|
1fb6860ee2 | ||
|
|
99c50c1f64 | ||
|
|
b1576b5a91 | ||
|
|
6f2ee2c0bb | ||
|
|
eec5e3290b | ||
|
|
aaac5928c4 | ||
|
|
b97b35d9b5 | ||
|
|
6955514ec3 | ||
|
|
c8d5267bc7 | ||
|
|
993a861c78 | ||
|
|
a11e100050 | ||
|
|
470ec4924c | ||
|
|
cdb6aaac6e | ||
|
|
580d33a6f8 | ||
|
|
8686694be9 | ||
|
|
795a9fe011 | ||
|
|
4b08a3a5f2 | ||
|
|
d9e8a81655 | ||
|
|
7000547419 | ||
|
|
e0100543cd | ||
|
|
7ea640927f | ||
|
|
db26cafc41 | ||
|
|
100b9e7c71 | ||
|
|
d3391db8f0 | ||
|
|
1ad01d8394 | ||
|
|
3ef3f2c01b | ||
|
|
371422a9ae | ||
|
|
f4af650292 | ||
|
|
5f38e87f01 | ||
|
|
b98e4a27ce | ||
|
|
9ff8db31d2 | ||
|
|
446148d07f | ||
|
|
2d6ca50568 | ||
|
|
650ccac501 | ||
|
|
ab507f0fd5 | ||
|
|
7187b5ffee | ||
|
|
5e73da1df4 | ||
|
|
244d25b12c | ||
|
|
2dcf676cf2 | ||
|
|
e07af676a5 | ||
|
|
3dea6302de | ||
|
|
b1ceb60360 | ||
|
|
1ef94b77e9 | ||
|
|
292d31e490 | ||
|
|
6f0ac1e730 | ||
|
|
9f82e5850d | ||
|
|
4a18fa07ec | ||
|
|
05d1886467 | ||
|
|
6e45706825 | ||
|
|
464402a233 | ||
|
|
3a56b9ded7 | ||
|
|
142295671b | ||
|
|
0e46a24112 | ||
|
|
a3cb698be0 | ||
|
|
08730ad113 | ||
|
|
d155f166d7 | ||
|
|
ca95e9252f | ||
|
|
d078e80e79 | ||
|
|
8ad1d2672c | ||
|
|
735130efc9 | ||
|
|
7e6b7398a4 | ||
|
|
edf8f5b1fd | ||
|
|
08c09d896a | ||
|
|
58403634cf | ||
|
|
2eb171e40d | ||
|
|
3753f58980 | ||
|
|
fe1cc78ab3 | ||
|
|
c140668648 | ||
|
|
41ca1321cf | ||
|
|
d88340158a | ||
|
|
52f335edd5 | ||
|
|
22200ec7b2 | ||
|
|
e458ed03c8 | ||
|
|
e9f1e3a189 | ||
|
|
d202570b0d | ||
|
|
9b6edde5c8 | ||
|
|
975c92d40d |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
rd_ui/.tmp/
|
||||
rd_ui/node_modules/
|
||||
.git/
|
||||
.vagrant/
|
||||
12
.env.example
12
.env.example
@@ -1,6 +1,6 @@
|
||||
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=
|
||||
REDASH_STATIC_ASSETS_PATH="../rd_ui/app/"
|
||||
REDASH_LOG_LEVEL="INFO"
|
||||
REDASH_REDIS_URL=redis://localhost:6379/1
|
||||
REDASH_DATABASE_URL="postgresql://redash"
|
||||
REDASH_COOKIE_SECRET=veryverysecret
|
||||
REDASH_GOOGLE_APPS_DOMAIN=
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,3 +19,6 @@ redash/dump.rdb
|
||||
venv
|
||||
|
||||
dump.rdb
|
||||
|
||||
# Docker related
|
||||
docker-compose.yml
|
||||
|
||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
FROM ubuntu:trusty
|
||||
MAINTAINER Di Wu <diwu@yelp.com>
|
||||
|
||||
# Ubuntu packages
|
||||
RUN apt-get update && \
|
||||
apt-get install -y python-pip python-dev curl build-essential pwgen libffi-dev sudo git-core wget \
|
||||
# Postgres client
|
||||
libpq-dev \
|
||||
# Additional packages required for data sources:
|
||||
libssl-dev libmysqlclient-dev
|
||||
|
||||
# Users creation
|
||||
RUN useradd --system --comment " " --create-home redash
|
||||
|
||||
# Pip requirements for all data source types
|
||||
RUN pip install -U setuptools && \
|
||||
pip install supervisor==3.1.2
|
||||
|
||||
COPY . /opt/redash/current
|
||||
|
||||
# Setting working directory
|
||||
WORKDIR /opt/redash/current
|
||||
|
||||
# Install project specific dependencies
|
||||
RUN pip install -r requirements_all_ds.txt && \
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Setup supervisord
|
||||
RUN mkdir -p /opt/redash/supervisord && \
|
||||
mkdir -p /opt/redash/logs && \
|
||||
cp /opt/redash/current/setup/docker/supervisord/supervisord.conf /opt/redash/supervisord/supervisord.conf
|
||||
|
||||
# Fix permissions
|
||||
RUN chown -R redash /opt/redash
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 5000
|
||||
EXPOSE 9001
|
||||
|
||||
# Startup script
|
||||
CMD ["supervisord", "-c", "/opt/redash/supervisord/supervisord.conf"]
|
||||
2
Makefile
2
Makefile
@@ -19,5 +19,5 @@ upload:
|
||||
python bin/release_manager.py $(CIRCLE_SHA1) $(BASE_VERSION) $(FILENAME)
|
||||
|
||||
test:
|
||||
nosetests --with-coverage --cover-package=redash tests/*.py
|
||||
nosetests --with-coverage --cover-package=redash tests/
|
||||
#cd rd_ui && grunt test
|
||||
|
||||
30
README.md
30
README.md
@@ -1,8 +1,16 @@
|
||||
Some of you read the news about EverythingMe closing down. While more detailed announcement will come later (once more details are clear), **I just wanted to reassure you that you shouldn't worry -- this won't affect the future of re:dash.** I will keep maintaining re:dash, and might even be able to work more on it.
|
||||
|
||||
If you still have concerns, you're welcome to reach out to me directly -- arik@arikfr.com.
|
||||
|
||||
Arik.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img title="re:dash" src='http://redash.io/static/img/redash_logo.png' width="200px"/>
|
||||
<img title="re:dash" src='http://redash.io/static/old_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'/>
|
||||
<img title="Build Status" src='https://circleci.com/gh/getredash/redash.png?circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040'/>
|
||||
</p>
|
||||
|
||||
**_re:dash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns.
|
||||
@@ -11,7 +19,7 @@ Prior to **_re:dash_**, we tried to use traditional BI suites and discovered a s
|
||||
|
||||
**_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,
|
||||
Presto, Google Spreadsheets, Cloudera Impala and custom scripts.
|
||||
Presto, Google Spreadsheets, Cloudera Impala, Hive and custom scripts.
|
||||
|
||||
**_re:dash_** consists of two parts:
|
||||
|
||||
@@ -22,31 +30,27 @@ Presto, Google Spreadsheets, Cloudera Impala and custom scripts.
|
||||
|
||||
## Demo
|
||||
|
||||

|
||||

|
||||
|
||||
You can try out the demo instance: http://demo.redash.io/ (login with any Google account).
|
||||
|
||||
## Getting Started
|
||||
|
||||
* [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).
|
||||
* [Documentation](http://docs.redash.io).
|
||||
|
||||
|
||||
## Getting help
|
||||
|
||||
* [Google Group (mailing list)](https://groups.google.com/forum/#!forum/redash-users): the best place to get updates about new releases or ask general questions.
|
||||
* Find us [on gitter](https://gitter.im/EverythingMe/redash#) (chat).
|
||||
* Contact Arik, the maintainer directly: arik@everything.me.
|
||||
|
||||
## Roadmap
|
||||
|
||||
TBD.
|
||||
* Find us [on gitter](https://gitter.im/getredash/redash#) (chat).
|
||||
* Contact Arik, the maintainer directly: arik@redash.io.
|
||||
|
||||
## Reporting Bugs and Contributing Code
|
||||
|
||||
* Want to report a bug or request a feature? Please open [an issue](https://github.com/everythingme/redash/issues/new).
|
||||
* Want to report a bug or request a feature? Please open [an issue](https://github.com/getredash/redash/issues/new).
|
||||
* Want to help us build **_re:dash_**? Fork the project and make a pull request. We need all the help we can get!
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](https://github.com/EverythingMe/redash/blob/master/LICENSE) file.
|
||||
See [LICENSE](https://github.com/getredash/redash/blob/master/LICENSE) file.
|
||||
|
||||
@@ -7,7 +7,7 @@ import requests
|
||||
|
||||
github_token = os.environ['GITHUB_TOKEN']
|
||||
auth = (github_token, 'x-oauth-basic')
|
||||
repo = 'EverythingMe/redash'
|
||||
repo = 'getredash/redash'
|
||||
|
||||
def _github_request(method, path, params=None, headers={}):
|
||||
if not path.startswith('https://api.github.com'):
|
||||
@@ -56,7 +56,7 @@ def create_release(version, commit_sha):
|
||||
return response.json()
|
||||
|
||||
def upload_asset(release, filepath):
|
||||
upload_url = release['upload_url'].replace('{?name}', '')
|
||||
upload_url = release['upload_url'].replace('{?name,label}', '')
|
||||
filename = filepath.split('/')[-1]
|
||||
|
||||
with open(filepath) as file_content:
|
||||
|
||||
15
circle.yml
15
circle.yml
@@ -1,17 +1,16 @@
|
||||
machine:
|
||||
services:
|
||||
- docker
|
||||
node:
|
||||
version:
|
||||
0.10.24
|
||||
0.12.4
|
||||
python:
|
||||
version:
|
||||
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_dev.txt
|
||||
- pip install -r requirements.txt
|
||||
cache_directories:
|
||||
- rd_ui/node_modules/
|
||||
@@ -22,10 +21,14 @@ test:
|
||||
post:
|
||||
- make pack
|
||||
deployment:
|
||||
github:
|
||||
github_and_docker:
|
||||
branch: master
|
||||
commands:
|
||||
- make upload
|
||||
- echo "rd_ui/app" >> .dockerignore
|
||||
- docker build -t redash/redash:$(./manage.py version | sed -e "s/\+/./") .
|
||||
- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS
|
||||
- docker push redash/redash:$(./manage.py version | sed -e "s/\+/./")
|
||||
notify:
|
||||
webhooks:
|
||||
- url: https://webhooks.gitter.im/e/895d09c3165a0913ac2f
|
||||
|
||||
28
docker-compose-example.yml
Normal file
28
docker-compose-example.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
redash:
|
||||
image: redash
|
||||
ports:
|
||||
- "5000:5000"
|
||||
links:
|
||||
- redis
|
||||
- postgres
|
||||
environment:
|
||||
REDASH_STATIC_ASSETS_PATH:"../rd_ui/app/"
|
||||
REDASH_LOG_LEVEL:"INFO"
|
||||
REDASH_REDIS_URL:redis://localhost:6379/0
|
||||
REDASH_DATABASE_URL:"postgresql://redash"
|
||||
REDASH_COOKIE_SECRET:veryverysecret
|
||||
REDASH_GOOGLE_APPS_DOMAIN:
|
||||
redis:
|
||||
image: redis:2.8
|
||||
postgres:
|
||||
image: postgres:9.3
|
||||
volumes:
|
||||
- /opt/postgres-data:/var/lib/postgresql/data
|
||||
redash-nginx:
|
||||
image: redash-nginx:1.0
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- "../redash-nginx/nginx.conf:/etc/nginx/nginx.conf"
|
||||
links:
|
||||
- redash
|
||||
@@ -1,162 +1,132 @@
|
||||
Supported Data Sources
|
||||
######################
|
||||
|
||||
re:dash supports several types of data sources (see below the full list)
|
||||
and their management is done with the CLI (``manage.py``):
|
||||
re:dash supports several types of data sources, and if you set it up using the provided images, it should already have
|
||||
the needed dependencies to use them all. Starting from version 0.7 and newer, you can manage data sources from the UI
|
||||
by browsing to ``/data_sources`` on your instance.
|
||||
|
||||
Create new data source
|
||||
======================
|
||||
If one of the listed data source types isn't available when trying to create a new data source, make sure that:
|
||||
|
||||
.. code:: bash
|
||||
1. You installed required dependencies.
|
||||
2. If you've set custom value for the ``REDASH_ENABLED_QUERY_RUNNERS`` setting, it's included in the list.
|
||||
|
||||
$ cd /opt/redash/current
|
||||
$ sudo -u redash bin/run ./manage.py ds new -n {name} -t {type} -o {options}
|
||||
PostgreSQL / Redshift / Greenplum
|
||||
---------------------------------
|
||||
|
||||
If you omit any of the options (-n, -t, -o) it will show a prompt asking
|
||||
for it. Options is a JSON string with the connection parameters. Unless
|
||||
you're doing some sort of automation, it's probably easier to leave it
|
||||
empty and fill out the prompt.
|
||||
|
||||
See below for the different supported data sources types and the
|
||||
relevant options string format.
|
||||
|
||||
Listing existing data sources
|
||||
=============================
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ sudo -u redash bin/run ./manage.py ds list
|
||||
|
||||
Supported data sources
|
||||
======================
|
||||
|
||||
PostgreSQL / Redshift
|
||||
---------------------
|
||||
|
||||
- **Type**: pg
|
||||
- **Options**:
|
||||
|
||||
- User (user)
|
||||
- Password (password)
|
||||
- Host (host)
|
||||
- Port (port)
|
||||
- Database name (dbname) (mandatory)
|
||||
- Database name (mandatory)
|
||||
- User
|
||||
- Password
|
||||
- Host
|
||||
- Port
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- None
|
||||
|
||||
- **Options string format (for v0.5 and older)**: "user= password=
|
||||
host= port=5439 dbname="
|
||||
|
||||
MySQL
|
||||
-----
|
||||
|
||||
- **Type**: mysql
|
||||
- **Options**:
|
||||
|
||||
- User (user)
|
||||
- Password (passwd)
|
||||
- Host (host)
|
||||
- Port (port)
|
||||
- Database name (db) (mandatory)
|
||||
- Database name (mandatory)
|
||||
- User
|
||||
- Password
|
||||
- Host
|
||||
- Port
|
||||
|
||||
- **Options string format (for v0.5 and older)**:
|
||||
"Server=localhost;User=;Pwd=;Database="
|
||||
- **Additional requirements**:
|
||||
|
||||
Note that you need to install the MySQLDb package as it is not included
|
||||
in the ``requirements.txt`` file.
|
||||
- ``MySQL-python`` python package
|
||||
|
||||
Graphite
|
||||
--------
|
||||
|
||||
- **Type**: graphite
|
||||
- **Options**:
|
||||
|
||||
- Url (url) (mandatory)
|
||||
- User (username)
|
||||
- Password (password)
|
||||
- Verify SSL ceritficate (verify)
|
||||
|
||||
- **Options string format**: '{"url":
|
||||
"https://graphite.yourcompany.com", "auth": ["user", "password"],
|
||||
"verify": true}'
|
||||
|
||||
Google BigQuery
|
||||
---------------
|
||||
|
||||
- **Type**: bigquery
|
||||
- **Options**:
|
||||
|
||||
- Service Account (serviceAccount) (mandatory)
|
||||
- Project ID (projectId) (mandatory)
|
||||
- Private Key filename (privateKey) (mandatory)
|
||||
- Project ID (mandatory)
|
||||
- JSON key file, generated when creating a service account (see `instructions <https://developers.google.com/console/help/new/#serviceaccounts>`__).
|
||||
|
||||
- **Options string format (for v0.5 and older)**: {"serviceAccount" :
|
||||
"43242343247-fjdfakljr3r2@developer.gserviceaccount.com",
|
||||
"privateKey" : "/somewhere/23fjkfjdsfj21312-privatekey.p12",
|
||||
"projectId" : "myproject-123" }
|
||||
|
||||
Notes:
|
||||
- **Additional requirements**:
|
||||
|
||||
1. To obtain BigQuery credentials follow the guidelines at:
|
||||
https://developers.google.com/bigquery/authorization#service-accounts
|
||||
2. You need to install the ``google-api-python-client``,
|
||||
``oauth2client`` and ``pyopenssl`` packages (PyOpenSSL requires
|
||||
``libffi-dev`` and ``libssl-dev`` packages), as they are not included
|
||||
in the ``requirements.txt`` file.
|
||||
- ``google-api-python-client``, ``oauth2client`` and ``pyopenssl`` python packages (on Ubuntu it might require installing ``libffi-dev`` and ``libssl-dev`` as well).
|
||||
|
||||
Google Spreadsheets
|
||||
-------------------
|
||||
|
||||
(supported from v0.6.4)
|
||||
Graphite
|
||||
--------
|
||||
|
||||
- **Type**: google\_spreadsheets
|
||||
- **Options**:
|
||||
|
||||
- Credentials filename (credentialsFilePath) (mandatory)
|
||||
- Url (mandatory)
|
||||
- User
|
||||
- Password
|
||||
- Verify SSL certificate
|
||||
|
||||
Notes:
|
||||
|
||||
1. To obtain Google ServiceAccount credentials follow the guidelines at:
|
||||
https://developers.google.com/console/help/new/#serviceaccounts (save
|
||||
the JSON version of the credentials file)
|
||||
2. To be able to load the spreadsheet in re:dash - share your it with
|
||||
your ServiceAccount's email (it can be found in the credentials json
|
||||
file, for example
|
||||
43242343247-fjdfakljr3r2@developer.gserviceaccount.com) Note: all the
|
||||
service account details can be seen inside the json file you should
|
||||
obtain following step #1
|
||||
3. The query format is "DOC\_UUID\|SHEET\_NUM" (for example
|
||||
"kjsdfhkjh4rsEFSDFEWR232jkddsfh\|0")
|
||||
4. You (might) need to install the ``gspread``, ``oauth2client`` and
|
||||
``dateutil`` packages as they are not included in the
|
||||
``requirements.txt`` file.
|
||||
|
||||
MongoDB
|
||||
-------
|
||||
|
||||
- **Type**: mongo
|
||||
- **Options**:
|
||||
|
||||
- Connection String (connectionString) (mandatory)
|
||||
- Database name (dbName)
|
||||
- Replica set name (replicaSetName)
|
||||
- Connection String (mandatory)
|
||||
- Database name
|
||||
- Replica set name
|
||||
|
||||
- **Options string format (for v0.5 and older)**: { "connectionString"
|
||||
: "mongodb://user:password@localhost:27017/mydb", "dbName" : "mydb" }
|
||||
- **Additional requirements**:
|
||||
|
||||
For ReplicaSet databases use the following connection string: \*
|
||||
**Options string format**: { "connectionString" :
|
||||
"mongodb://user:pasword@server1:27017,server2:27017/mydb", "dbName" :
|
||||
"mydb", "replicaSetName" : "myreplicaSet" }
|
||||
- ``pymongo`` python package.
|
||||
|
||||
Notes:
|
||||
For information on how to write MongoDB queries, see :doc:`documentation </usage/mongodb_querying>`.
|
||||
|
||||
1. You need to install ``pymongo``, as it is not included in the
|
||||
``requirements.txt`` file.
|
||||
|
||||
ElasticSearch
|
||||
-------------
|
||||
|
||||
...
|
||||
|
||||
InfluxDB
|
||||
--------
|
||||
|
||||
...
|
||||
|
||||
Presto
|
||||
------
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Host (mandatory)
|
||||
- Address to a Presto coordinator.
|
||||
- Port
|
||||
- Port to a Presto coordinator. `8080` is the default port.
|
||||
- Schema
|
||||
- Default schema name of Presto. You can read other schemas by qualified name like `FROM myschema.table1`.
|
||||
- Catalog
|
||||
- Catalog (connector) name of Presto such as `hive-cdh4`, `hive-hadoop1`, etc.
|
||||
- Username
|
||||
- User name to connect to a Presto.
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- ``pyhive`` python package.
|
||||
|
||||
Hive
|
||||
----
|
||||
|
||||
...
|
||||
|
||||
Impala
|
||||
------
|
||||
|
||||
...
|
||||
|
||||
URL
|
||||
---
|
||||
|
||||
A URL based data source which requests URLs that conforms to the
|
||||
supported :doc:`results JSON
|
||||
A URL based data source which requests URLs that return the :doc:`results JSON
|
||||
format </dev/results_format>`.
|
||||
|
||||
Very useful in situations where you want to expose the data without
|
||||
@@ -165,81 +135,84 @@ connecting directly to the database.
|
||||
The query itself inside re:dash will simply contain the URL to be
|
||||
executed (i.e. http://myserver/path/myquery)
|
||||
|
||||
- **Type**: url
|
||||
- **Options**:
|
||||
|
||||
- Url (url)
|
||||
- Url - set this if you want to limit queries to certain base path.
|
||||
|
||||
- **Options string format (optional) (for v0.5 and older)**:
|
||||
http://myserver/path/
|
||||
|
||||
Google Spreadsheets
|
||||
-------------------
|
||||
|
||||
- **Options**:
|
||||
|
||||
- JSON key file, generated when creating a service account (see `instructions <https://developers.google.com/console/help/new/#serviceaccounts>`__).
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- ``gspread`` and ``oauth2client`` python packages.
|
||||
|
||||
Notes:
|
||||
|
||||
1. All URLs must return the supported :doc:`results JSON
|
||||
format </dev/results_format>`.
|
||||
2. If the Options string is set, only URLs that are part of the supplied
|
||||
path can be executed using this data source. Not setting the options
|
||||
path allows any URL to be executed as long as it returns the
|
||||
supported :doc:`results JSON
|
||||
format </dev/results_format>`.
|
||||
1. To be able to load the spreadsheet in re:dash - share your it with
|
||||
your ServiceAccount's email (it can be found in the credentials json
|
||||
file, for example
|
||||
43242343247-fjdfakljr3r2@developer.gserviceaccount.com).
|
||||
2. The query format is "DOC\_UUID\|SHEET\_NUM" (for example
|
||||
"kjsdfhkjh4rsEFSDFEWR232jkddsfh\|0")
|
||||
|
||||
Script
|
||||
------
|
||||
|
||||
Allows executing any executable script residing on the server as long as
|
||||
its standard output conforms to the supported :doc:`results JSON
|
||||
format </dev/results_format>`.
|
||||
|
||||
This integration is useful in situations where you need more than just a
|
||||
query and requires some processing to happen.
|
||||
|
||||
Once the path to scripts is configured in the datasource the query needs
|
||||
to contain the file name of the script as well as any command line
|
||||
parameters the script requires (i.e. myscript.py param1 param2
|
||||
--param3=value)
|
||||
|
||||
- **Type**: script
|
||||
- **Options**:
|
||||
|
||||
- Scripts Path (path) (mandatory)
|
||||
|
||||
- **Options string format (for v0.5 and older)**: /path/to/scripts/
|
||||
|
||||
Notes:
|
||||
|
||||
1. You MUST set a path to execute the scripts, otherwise the data source
|
||||
will not work.
|
||||
2. All scripts must be executable, otherwise results won't return
|
||||
3. The script data source does not allow relative paths in the form of
|
||||
"../". You may use a relative sub path such as "./mydir/myscript".
|
||||
4. All scripts must output to the standard output the supported :doc:`results
|
||||
JSON format </dev/results_format>` and
|
||||
only that, otherwise the data source will not be able to load the
|
||||
data.
|
||||
|
||||
Python
|
||||
------
|
||||
|
||||
Execute other queries, manipulate and compute with Python code
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
**Execute other queries, manipulate and compute with Python code**
|
||||
|
||||
The Python data source allows running Python code in a secure and safe
|
||||
environment. It won't allow writing files to disk, importing modules
|
||||
that were not pre-approved in the configuration etc.
|
||||
This is a special query runner, that will execute provided Python code as the query. Useful for various scenarios such as
|
||||
merging data from different data sources, doing data transformation/manipulation that isn't trivial with SQL, merging
|
||||
with remote data or using data analysis libraries such as Pandas (see `example query <https://gist.github.com/arikfr/be7c2888520c44cf4f0f>`__).
|
||||
|
||||
One of the benefits of using the Python data source is its ability to
|
||||
execute queries (or saved queries) which you can store in a variable and
|
||||
then manipulate/transform/merge with other data and queries.
|
||||
While the Python query runner uses a sandbox (RestrictedPython), it's not 100% secure and the security depends on the
|
||||
modules you allow to import. We recommend enabling the Python query runner only in a trusted environment (meaning: behind
|
||||
VPN and with users you trust).
|
||||
|
||||
You can import data analysis libraries such as Pandas, NumPy and SciPy.
|
||||
|
||||
This saved the trouble of having outside scripts do the synthesis of
|
||||
data from multiple sources to create a single data set that can then be
|
||||
used in dashboards.
|
||||
|
||||
- **Type**: Python
|
||||
- **Options**:
|
||||
|
||||
- Allowed Modules in a comma separated list (optional). **NOTE:**
|
||||
You MUST make sure these modules are installed on the machine
|
||||
running the Celery workers
|
||||
running the Celery workers.
|
||||
|
||||
Notes:
|
||||
|
||||
- For security, the python query runner is disabled by default.
|
||||
To enable, add ``redash.query_runner.python`` to the ``REDASH_ADDITIONAL_QUERY_RUNNERS`` environmental variable. If you used
|
||||
the bootstrap script, or one of the provided images, add to ``/opt/redash/.env`` file the line: ``export REDASH_ADDITIONAL_QUERY_RUNNERS=redash.query_runner.python``.
|
||||
|
||||
|
||||
Vertica
|
||||
-----
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Database (mandatory)
|
||||
- User
|
||||
- Password
|
||||
- Host
|
||||
- Port
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- ``vertica-python`` python package
|
||||
|
||||
Oracle
|
||||
------
|
||||
|
||||
- **Options**
|
||||
|
||||
- DSN Service name
|
||||
- User
|
||||
- Password
|
||||
- Host
|
||||
- Port
|
||||
|
||||
- **Additional requirements**
|
||||
|
||||
- ``cx_Oracle`` python package. This requires the installation of the Oracle `instant client <http://www.oracle.com/technetwork/database/features/instant-client/index-097480.html>`__.
|
||||
|
||||
@@ -34,7 +34,7 @@ When query execution is done, the result gets stored to
|
||||
``query_results`` table. Also we check for all queries in the
|
||||
``queries`` table that have the same query hash and update their
|
||||
reference to the query result we just saved
|
||||
(`code <https://github.com/EverythingMe/redash/blob/master/redash/models.py#L235>`__).
|
||||
(`code <https://github.com/getredash/redash/blob/master/redash/models.py#L235>`__).
|
||||
|
||||
Client
|
||||
------
|
||||
@@ -69,7 +69,7 @@ Ideas on how to implement query parameters
|
||||
Client side only implementation
|
||||
-------------------------------
|
||||
|
||||
(This was actually implemented in. See pull request `#363 <https://github.com/EverythingMe/redash/pull/363>`__ for details.)
|
||||
(This was actually implemented in. See pull request `#363 <https://github.com/getredash/redash/pull/363>`__ for details.)
|
||||
|
||||
The basic idea of how to implement parametized queries is to treat the
|
||||
query as a template and merge it with parameters taken from query string
|
||||
|
||||
@@ -9,22 +9,22 @@ All data sources in re:dash return the following results in JSON format:
|
||||
"columns" : [
|
||||
{
|
||||
// Required: a unique identifier of the column name in this result
|
||||
"name" : "COLUMN_NAME",
|
||||
"name" : "COLUMN_NAME",
|
||||
// Required: friendly name of the column that will appear in the results
|
||||
"friendly_name" : "FRIENDLY_NAME",
|
||||
// Optional: If not specified sort might not work well.
|
||||
"friendly_name" : "FRIENDLY_NAME",
|
||||
// Optional: If not specified sort might not work well.
|
||||
// Supported types: integer, float, boolean, string (default), datetime (ISO-8601 text format)
|
||||
"type" : "VALUE_TYPE"
|
||||
"type" : "VALUE_TYPE"
|
||||
},
|
||||
...
|
||||
],
|
||||
"rows" : [
|
||||
{
|
||||
// name is the column name as it appears in the columns above.
|
||||
// name is the column name as it appears in the columns above.
|
||||
// VALUE is a valid JSON value. For dates its an ISO-8601 string.
|
||||
"name" : VALUE,
|
||||
"name2" : VALUE2
|
||||
},
|
||||
...
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ To get started with this box:
|
||||
1. Make sure you have recent version of
|
||||
`Vagrant <https://www.vagrantup.com/>`__ installed.
|
||||
2. Clone the re:dash repository:
|
||||
``git clone https://github.com/EverythingMe/redash.git``.
|
||||
``git clone https://github.com/getredash/redash.git``.
|
||||
3. Change dir into the repository (``cd redash``) and run run
|
||||
``vagrant up``. This might take some time the first time you run it,
|
||||
as it downloads the Vagrant virtual box.
|
||||
@@ -30,18 +30,7 @@ To get started with this box:
|
||||
|
||||
::
|
||||
|
||||
PYTHONPATH=. bin/run python migrations/0001_allow_delete_query.py
|
||||
PYTHONPATH=. bin/run python migrations/0002_fix_timestamp_fields.py
|
||||
PYTHONPATH=. bin/run python migrations/0003_update_data_source_config.py
|
||||
PYTHONPATH=. bin/run python migrations/0004_allow_null_in_event_user.py
|
||||
PYTHONPATH=. bin/run python migrations/0005_add_updated_at.py
|
||||
PYTHONPATH=. bin/run python migrations/0006_queries_last_edit_by.py
|
||||
PYTHONPATH=. bin/run python migrations/0007_add_schedule_to_queries.py
|
||||
PYTHONPATH=. bin/run python migrations/0008_make_ds_name_unique.py
|
||||
PYTHONPATH=. bin/run python migrations/0009_add_api_key_to_user.py
|
||||
PYTHONPATH=. bin/run python migrations/0010_create_alerts.py
|
||||
PYTHONPATH=. bin/run python migrations/0010_allow_deleting_datasources.py
|
||||
PYTHONPATH=. bin/run python migrations/0011_migrate_bigquery_to_json.py
|
||||
export PYTHONPATH=. && find migrations/ -type f | grep 00 --null | xargs -I file bin/run python file
|
||||
|
||||
9. Start the server and background workers with
|
||||
``bin/run honcho start -f Procfile.dev``.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.. image:: http://redash.io/static/img/redash_logo.png
|
||||
.. image:: http://redash.io/static/old_img/redash_logo.png
|
||||
:width: 200px
|
||||
|
||||
Open Source Data Collaboration and Visualization Platform
|
||||
@@ -21,7 +21,7 @@ Features
|
||||
Demo
|
||||
####
|
||||
|
||||
.. figure:: https://raw.github.com/EverythingMe/redash/screenshots/screenshots.gif
|
||||
.. figure:: https://raw.github.com/getredash/redash/screenshots/screenshots.gif
|
||||
:alt: Screenshots
|
||||
|
||||
You can try out the demo instance: `http://demo.redash.io`_ (login with any Google account).
|
||||
@@ -37,11 +37,11 @@ Getting Started
|
||||
Getting Help
|
||||
############
|
||||
|
||||
* Source: https://github.com/everythingme/redash
|
||||
* Issues: https://github.com/everythingme/redash/issues
|
||||
* Source: https://github.com/getredash/redash
|
||||
* Issues: https://github.com/getredash/redash/issues
|
||||
* Mailing List: https://groups.google.com/forum/#!forum/redash-users
|
||||
* Gitter (chat): https://gitter.im/EverythingMe/redash
|
||||
* Contact Arik, the maintainer directly: arik@everything.me.
|
||||
* Gitter (chat): https://gitter.im/getredash/redash
|
||||
* Contact Arik, the maintainer directly: arik@redash.io.
|
||||
|
||||
TOC
|
||||
###
|
||||
|
||||
@@ -18,12 +18,12 @@ How To: Create a Google Developers Project
|
||||
list of Google web services appears.
|
||||
4. Find the **Google+ API** service and set its status to **ON**—notice
|
||||
that this action moves the service to the top of the list.
|
||||
5. In the sidebar under "APIs & auth", select **Consent screen**.
|
||||
5. In the sidebar under "APIs & auth", select **Credentials** and in that screen choose the **OAuth consent screen** tab
|
||||
|
||||
- Choose an Email Address and specify a Product Name.
|
||||
|
||||
6. In the sidebar under "APIs & auth", select **Credentials**.
|
||||
7. Click **Create a new Client ID** — a dialog box appears.
|
||||
7. Click **Add Credentials** button and choose **OAuth 20 Client ID**.
|
||||
|
||||
- In the **Application type** section of the dialog, select **Web
|
||||
application**.
|
||||
@@ -44,7 +44,7 @@ How To: Create a Google Developers Project
|
||||
|
||||
http://redash.example.com/oauth/google_callback
|
||||
|
||||
- Click the ``Create Client ID`` button.
|
||||
- Click the ``Create`` button.
|
||||
|
||||
8. In the resulting **Client ID for web application** section, copy the
|
||||
**Client ID** and **Client secret** to your ``.env`` file.
|
||||
|
||||
105
docs/setup.rst
105
docs/setup.rst
@@ -2,7 +2,7 @@ Setting up re:dash instance
|
||||
###########################
|
||||
|
||||
The `provisioning
|
||||
script <https://github.com/EverythingMe/redash/blob/master/setup/bootstrap.sh>`__
|
||||
script <https://raw.githubusercontent.com/getredash/redash/master/setup/ubuntu/bootstrap.sh>`__
|
||||
works on Ubuntu 12.04, Ubuntu 14.04 and Debian Wheezy. This script
|
||||
installs all needed dependencies and creates basic setup.
|
||||
|
||||
@@ -12,6 +12,25 @@ Cloud. These images created with the same provision script using Packer.
|
||||
Create an instance
|
||||
==================
|
||||
|
||||
AWS
|
||||
---
|
||||
|
||||
Launch the instance with from the pre-baked AMI (for small deployments
|
||||
t2.micro should be enough):
|
||||
|
||||
- us-east-1: `ami-752c7f10 <https://console.aws.amazon.com/ec2/home?region=us-east-1#LaunchInstanceWizard:ami=ami-752c7f10>`__
|
||||
- us-west-1: `ami-b36babf7 <https://console.aws.amazon.com/ec2/home?region=us-west-1#LaunchInstanceWizard:ami=ami-b36babf7>`__
|
||||
- us-west-2: `ami-a0a04393 <https://console.aws.amazon.com/ec2/home?region=us-west-2#LaunchInstanceWizard:ami=ami-a0a04393>`__
|
||||
- eu-west-1: `ami-198cb16e <https://console.aws.amazon.com/ec2/home?region=eu-west-1#LaunchInstanceWizard:ami=ami-198cb16e>`__
|
||||
- eu-central-1: `ami-a81418b5 <https://console.aws.amazon.com/ec2/home?region=eu-central-1#LaunchInstanceWizard:ami=ami-a81418b5>`__
|
||||
- sa-east-1: `ami-2b52c336 <https://console.aws.amazon.com/ec2/home?region=sa-east-1#LaunchInstanceWizard:ami=ami-2b52c336>`__
|
||||
- ap-northeast-1: `ami-4898fb48 <https://console.aws.amazon.com/ec2/home?region=ap-northeast-1#LaunchInstanceWizard:ami=ami-4898fb48>`__
|
||||
- ap-southeast-2: `ami-7559134f <https://console.aws.amazon.com/ec2/home?region=ap-southeast-2#LaunchInstanceWizard:ami=ami-7559134f>`__
|
||||
- ap-southeast-1: `ami-a0786bf2 <https://console.aws.amazon.com/ec2/home?region=ap-southeast-1#LaunchInstanceWizard:ami=ami-a0786bf2>`__
|
||||
|
||||
|
||||
Now proceed to `"Setup" <#setup>`__.
|
||||
|
||||
Google Compute Engine
|
||||
---------------------
|
||||
|
||||
@@ -19,7 +38,7 @@ First, you need to add the images to your account:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ gcloud compute images add redash-063-b906 gs://redash-images/redash.0.6.3.b906.tar.gz
|
||||
$ gcloud compute images create "redash-081-b1110" --source-uri gs://redash-images/redash.0.8.1.b1110.tar.gz
|
||||
|
||||
Next you need to launch an instance using this image (n1-standard-1
|
||||
instance type is recommended). If you plan using re:dash with BigQuery,
|
||||
@@ -28,45 +47,19 @@ you can use a dedicated image which comes with BigQuery preconfigured
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ gcloud compute images add redash-063-b906-bq gs://redash-images/redash.0.6.3.b906-bq.tar.gz
|
||||
$ gcloud compute images create "redash-081-b1110-bq" --source-uri gs://redash-images/redash.0.8.1.b1110-bq.tar.gz
|
||||
|
||||
Note that you need to launch this instance with BigQuery access:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ gcloud compute instances create <your_instance_name> --image redash-060-b812-bq --scopes storage-ro bigquery
|
||||
$ gcloud compute instances create <your_instance_name> --image redash-081-b1110-bq --scopes storage-ro,bigquery
|
||||
|
||||
(the same can be done from the web interface, just make sure to enable
|
||||
BigQuery access)
|
||||
|
||||
Now proceed to `"Setup" <#setup>`__.
|
||||
|
||||
AWS
|
||||
---
|
||||
|
||||
Launch the instance with from the pre-baked AMI (for small deployments
|
||||
t2.micro should be enough):
|
||||
|
||||
- us-east-1:
|
||||
`ami-47b4612c <https://console.aws.amazon.com/ec2/home?region=us-east-1#LaunchInstanceWizard:ami=ami-47b4612c>`__
|
||||
- us-west-1:
|
||||
`ami-a72edde3 <https://console.aws.amazon.com/ec2/home?region=us-west-1#LaunchInstanceWizard:ami=ami-a72edde3>`__
|
||||
- us-west-2:
|
||||
`ami-f9d6d5c9 <https://console.aws.amazon.com/ec2/home?region=us-west-2#LaunchInstanceWizard:ami=ami-f9d6d5c9>`__
|
||||
- eu-central-1:
|
||||
`ami-72eed46f <https://console.aws.amazon.com/ec2/home?region=eu-central-1#LaunchInstanceWizard:ami=ami-72eed46f>`__
|
||||
- eu-west-1:
|
||||
`ami-5a135c2d <https://console.aws.amazon.com/ec2/home?region=eu-west-1#LaunchInstanceWizard:ami=ami-5a135c2d>`__
|
||||
- sa-east-1:
|
||||
`ami-2b78f436 <https://console.aws.amazon.com/ec2/home?region=sa-east-1#LaunchInstanceWizard:ami=ami-2b78f436>`__
|
||||
- ap-northeast-1:
|
||||
`ami-0a55fd0a <https://console.aws.amazon.com/ec2/home?region=ap-northeast-1#LaunchInstanceWizard:ami=ami-0a55fd0a>`__
|
||||
- ap-southeast-2:
|
||||
`ami-9f793ea5 <https://console.aws.amazon.com/ec2/home?region=ap-southeast-2#LaunchInstanceWizard:ami=ami-9f793ea5>`__
|
||||
- ap-southeast-1:
|
||||
`ami-12545740 <https://console.aws.amazon.com/ec2/home?region=ap-southeast-1#LaunchInstanceWizard:ami=ami-12545740>`__
|
||||
|
||||
Now proceed to `"Setup" <#setup>`__.
|
||||
|
||||
Other
|
||||
-----
|
||||
@@ -75,13 +68,14 @@ Download the provision script and run it on your machine. Note that:
|
||||
|
||||
1. You need to run the script as root.
|
||||
2. It was tested only on Ubuntu 12.04, Ubuntu 14.04 and Debian Wheezy.
|
||||
3. It's designed to run on a "clean" machine. If you're running this script on a machine that is used for other purposes, you might want to tweak it to your needs (like removing the ``apt-get dist-upgrade`` call at the beginning of it).
|
||||
|
||||
Setup
|
||||
=====
|
||||
|
||||
Once you created the instance with either the image or the script, you
|
||||
should have a running re:dash instance with everything you need to get
|
||||
started. You can even login to it with the user "admin" (password:
|
||||
started. You can now login to it with the user "admin" (password:
|
||||
"admin"). But to make it useful, there are a few more steps that you
|
||||
need to manually do to complete the setup:
|
||||
|
||||
@@ -100,10 +94,7 @@ file.
|
||||
can use ``pwgen 32 -1`` to generate random string).
|
||||
|
||||
2. By default we create an admin user with the password "admin". You
|
||||
need to change the password:
|
||||
|
||||
- ``cd /opt/redash/current``
|
||||
- ``sudo -u redash bin/run ./manage.py users password admin {new password}``
|
||||
can change this password at: ``/users/me#password``.
|
||||
|
||||
3. If you want to use Google OAuth to authenticate users, you need to
|
||||
create a Google Developers project (see :doc:`instructions </misc/google_developers_project>`)
|
||||
@@ -124,26 +115,46 @@ file.
|
||||
|
||||
5. Once you have Google OAuth enabled, you can login using your Google
|
||||
Apps account. If you want to grant admin permissions to some users,
|
||||
you can do it with the ``users grant_admin`` command:
|
||||
``sudo -u redash bin/run ./manage.py users grant_admin {email}``.
|
||||
you can do this by editing the user profile and enabling admin
|
||||
permission for it.
|
||||
|
||||
6. If you don't use Google OAuth or just need username/password logins,
|
||||
you can create additional users using the CLI (see :doc:`documentation </usage/users>`).
|
||||
you can create additional users at: ``/users/new``.
|
||||
|
||||
Datasources
|
||||
-----------
|
||||
|
||||
To make re:dash truly useful, you need to setup your data sources in it.
|
||||
Currently all data sources management is done with the CLI.
|
||||
To make re:dash truly useful, you need to setup your data sources in it. Browse to ``/data_sources`` on your instance,
|
||||
to create new data source connection.
|
||||
|
||||
See
|
||||
:doc:`documentation </datasources>`
|
||||
for the different options. Your instance comes ready with dependencies
|
||||
needed to setup supported sources.
|
||||
See :doc:`documentation </datasources>` for the different options.
|
||||
Your instance comes ready with dependencies needed to setup supported sources.
|
||||
|
||||
Follow issue
|
||||
`#193 <https://github.com/EverythingMe/redash/issues/193>`__ to know
|
||||
when UI was implemented to manage data sources.
|
||||
Mail Configuration
|
||||
------------------
|
||||
|
||||
For the system to be able to send emails (for example when alerts trigger), you need to set the mail server to use and the
|
||||
host name of your re:dash server. If you're using one of our images, you can do this by editing the `.env` file:
|
||||
|
||||
.. code::
|
||||
|
||||
# Note that not all values are required, as they have default values.
|
||||
|
||||
export REDASH_MAIL_SERVER="" # default: localhost
|
||||
export REDASH_MAIL_PORT="" # default: 25
|
||||
export REDASH_MAIL_USE_TLS="" # default: False
|
||||
export REDASH_MAIL_USE_SSL="" # default: False
|
||||
export REDASH_MAIL_USERNAME="" # default: None
|
||||
export REDASH_MAIL_PASSWORD="" # default: None
|
||||
export REDASH_MAIL_DEFAULT_SENDER="" # Email address to send from
|
||||
|
||||
export REDASH_HOST="" # base address of your re:dash instance, for example: "https://demo.redash.io"
|
||||
|
||||
- Note that not all values are required, as there are default values.
|
||||
- It's recommended to use some mail service, like `Amazon SES <https://aws.amazon.com/ses/>`__, `Mailgun <http://www.mailgun.com/>`__
|
||||
or `Mandrill <http://mandrillapp.com>`__ to send emails to ensure deliverability.
|
||||
|
||||
To test email configuration, you can run `bin/run ./manage.py send_test_mail` (from `/opt/redash/current`).
|
||||
|
||||
How to upgrade?
|
||||
---------------
|
||||
|
||||
@@ -14,7 +14,9 @@ How to run the Fabric script
|
||||
1. Install Fabric: ``pip install fabric requests`` (needed only once)
|
||||
2. Download the ``fabfile.py`` from the gist.
|
||||
3. Run the script:
|
||||
``fab -H{your re:dash host} -u{the ssh user for this host} deploy_latest_release``
|
||||
``fab -H{your re:dash host} -u{the ssh user for this host} -i{path to key file for passwordless login} deploy_latest_release``
|
||||
|
||||
``-i`` is optional and it is only needed in case you're using private-key based authentication (and didn't add the key file to your authentication agent or set its path in your SSH config).
|
||||
|
||||
What the Fabric script does
|
||||
===========================
|
||||
@@ -23,7 +25,7 @@ Even if you didn't use the image, it's very likely you can reuse most of
|
||||
this script with small modifications. What this script does is:
|
||||
|
||||
1. Find the URL of the latest release tarball (from `GitHub releases
|
||||
page <github.com/everythingme/redash/releases>`__).
|
||||
page <github.com/getredash/redash/releases>`__).
|
||||
2. Download it.
|
||||
3. Create new directory for this version (for example:
|
||||
``/opt/redash/redash.0.5.0.b685``).
|
||||
|
||||
@@ -6,7 +6,6 @@ Usage
|
||||
:glob:
|
||||
|
||||
usage/maintenance.rst
|
||||
usage/users.rst
|
||||
usage/*
|
||||
|
||||
|
||||
|
||||
@@ -46,3 +46,27 @@ Simple query on a logstash ElasticSearch instance:
|
||||
"size" : 250,
|
||||
"sort" : "@timestamp:asc"
|
||||
}
|
||||
|
||||
Simple query on a ElasticSearch instance:
|
||||
==================================================
|
||||
|
||||
|
||||
- Query the index named "twitter"
|
||||
- Filter by user equal "kimchy"
|
||||
- Return the fields: "@timestamp", "tweet" and "user"
|
||||
- Return up to 15 results
|
||||
- Sort by @timestamp ascending
|
||||
|
||||
.. code:: json
|
||||
|
||||
{
|
||||
"index" : "twitter",
|
||||
"query" : {
|
||||
"match": {
|
||||
"user" : "kimchy"
|
||||
}
|
||||
},
|
||||
"fields" : ["@timestamp", "tweet", "user"],
|
||||
"size" : 15,
|
||||
"sort" : "@timestamp:asc"
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ running queries and reset the queue, follow the steps below.
|
||||
1. Stop celery: ``sudo supervisorctl stop redash_celery`` (celery might
|
||||
take some time to stop, if it's in the middle of running a query)
|
||||
|
||||
2. Flush redis: ``redis-cli flushdb``
|
||||
2. Flush redis: ``redis-cli flushall``.
|
||||
|
||||
3. Start celery: ``sudo supervisorctl start redash_celery``
|
||||
|
||||
@@ -57,30 +57,6 @@ queries.
|
||||
DB
|
||||
==
|
||||
|
||||
Show the Currently Configured Data Source
|
||||
-----------------------------------------
|
||||
|
||||
This varies based on the redash version and personal preferences. You
|
||||
can do one of the following:
|
||||
|
||||
Using the CLI
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
In ``/opt/redash/current``, run:
|
||||
``sudo -u redash bin/run ./manage.py ds list``
|
||||
|
||||
Using the Admin
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
(available from version 0.6b797). Browse to ``/admin/datasource``
|
||||
|
||||
View the Definition Directly in the DB
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
1. Open psql: ``sudo -u redash psql``
|
||||
|
||||
2. Run the query: ``SELECT * from data_sources;``
|
||||
|
||||
Backup re:dash's DB:
|
||||
--------------------
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
Users' Management
|
||||
#################
|
||||
|
||||
If you use Google OpenID authentication, then each user from the domains
|
||||
you allowed will automatically be logged in and have the default
|
||||
permissions.
|
||||
|
||||
If you want to give some user different permissions or you want to
|
||||
create password based users (make sure you enabled this options in
|
||||
settings first), you need to use the CLI (``manage.py``).
|
||||
|
||||
Create a new user
|
||||
=================
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ bin/run ./manage.py users create --help
|
||||
usage: users create [-h] [--permissions PERMISSIONS] [--password PASSWORD]
|
||||
[--google] [--admin]
|
||||
name email
|
||||
|
||||
positional arguments:
|
||||
name User's full name
|
||||
email User's email
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--permissions PERMISSIONS
|
||||
Comma seperated list of permissions (leave blank for
|
||||
default).
|
||||
--password PASSWORD Password for users who don't use Google Auth (leave
|
||||
blank for prompt).
|
||||
--google user uses Google Auth to login
|
||||
--admin set user as admin
|
||||
|
||||
Grant admin permissions
|
||||
=======================
|
||||
|
||||
``sudo -u redash bin/run ./manage.py users grant_admin {email}``
|
||||
6
migrations/0012_add_list_users_permission.py
Normal file
6
migrations/0012_add_list_users_permission.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
default_group = models.Group.get(models.Group.name=='default')
|
||||
default_group.permissions.append('list_users')
|
||||
default_group.save()
|
||||
24
migrations/0013_update_counter_options.py
Normal file
24
migrations/0013_update_counter_options.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import json
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
for vis in models.Visualization.select():
|
||||
if vis.type == 'COUNTER':
|
||||
options = json.loads(vis.options)
|
||||
print "Before: ", options
|
||||
if 'rowNumber' in options and options['rowNumber'] is not None:
|
||||
options['rowNumber'] += 1
|
||||
else:
|
||||
options['rowNumber'] = 1
|
||||
|
||||
if 'counterColName' not in options:
|
||||
options['counterColName'] = 'counter'
|
||||
|
||||
if 'targetColName' not in options:
|
||||
options['targetColName'] = 'target'
|
||||
options['targetRowNumber'] = options['rowNumber']
|
||||
|
||||
print "After: ", options
|
||||
vis.options = json.dumps(options)
|
||||
vis.save()
|
||||
|
||||
10
migrations/0014_migrate_existing_es_to_kibana.py
Normal file
10
migrations/0014_migrate_existing_es_to_kibana.py
Normal file
@@ -0,0 +1,10 @@
|
||||
__author__ = 'lior'
|
||||
|
||||
from redash.models import DataSource
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
for ds in DataSource.all():
|
||||
if ds.type == 'elasticsearch':
|
||||
ds.type = 'kibana'
|
||||
ds.save()
|
||||
6
migrations/0015_add_schedule_query_permission.py
Normal file
6
migrations/0015_add_schedule_query_permission.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
default_group = models.Group.get(models.Group.name=='default')
|
||||
default_group.permissions.append('schedule_query')
|
||||
default_group.save()
|
||||
10
migrations/0016_add_alert_subscriber.py
Normal file
10
migrations/0016_add_alert_subscriber.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from redash.models import db, Alert, AlertSubscription
|
||||
|
||||
if __name__ == '__main__':
|
||||
with db.database.transaction():
|
||||
# There was an AWS/GCE image created without this table, to make sure this exists we run this migration.
|
||||
if not AlertSubscription.table_exists():
|
||||
AlertSubscription.create_table()
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
@@ -236,17 +236,6 @@ module.exports = function (grunt) {
|
||||
// dist: {}
|
||||
// },
|
||||
|
||||
imagemin: {
|
||||
dist: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: '<%= yeoman.app %>/images',
|
||||
src: '{,*/}*.{png,jpg,jpeg,gif}',
|
||||
dest: '<%= yeoman.dist %>/images'
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
svgmin: {
|
||||
dist: {
|
||||
files: [{
|
||||
@@ -313,6 +302,11 @@ module.exports = function (grunt) {
|
||||
'images/{,*/}*.{webp}',
|
||||
'fonts/*'
|
||||
]
|
||||
}, {
|
||||
expand: true,
|
||||
cwd: '<%= yeoman.app %>/images',
|
||||
dest: '<%= yeoman.dist %>/images',
|
||||
src: ['*']
|
||||
}, {
|
||||
expand: true,
|
||||
cwd: '.tmp/images',
|
||||
@@ -348,7 +342,6 @@ module.exports = function (grunt) {
|
||||
],
|
||||
dist: [
|
||||
'copy:styles',
|
||||
'imagemin',
|
||||
'svgmin'
|
||||
]
|
||||
},
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
<link rel="stylesheet" href="/bower_components/gridster/dist/jquery.gridster.css">
|
||||
<link rel="stylesheet" href="/bower_components/pivottable/dist/pivot.css">
|
||||
<link rel="stylesheet" href="/bower_components/cornelius/src/cornelius.css">
|
||||
<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">
|
||||
@@ -45,9 +44,8 @@
|
||||
{% 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')" dropdown>
|
||||
<a href="#" class="dropdown-toggle" dropdown-toggle><span class="glyphicon glyphicon-th-large"></span> <b class="caret"></b></a>
|
||||
<a href="#" class="dropdown-toggle" dropdown-toggle><span class="fa fa-tachometer"></span> <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu" dropdown-menu>
|
||||
<span ng-repeat="(name, group) in groupedDashboards">
|
||||
<li class="dropdown-submenu">
|
||||
@@ -76,9 +74,6 @@
|
||||
<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">
|
||||
@@ -87,12 +82,34 @@
|
||||
<button type="submit" class="btn btn-default"><span class="glyphicon glyphicon-search"></span></button>
|
||||
</form>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<p class="navbar-text avatar" ng-show="currentUser.id" ng-cloak>
|
||||
<img ng-src="{{currentUser.gravatar_url}}" class="img-circle" alt="{{currentUser.name}}"/>
|
||||
<a target="_self" href="/logout" id="logout" title="Logout">
|
||||
<span class="glyphicon glyphicon-log-out"></span>
|
||||
</a>
|
||||
</p>
|
||||
<li ng-show="currentUser.hasPermission('admin')">
|
||||
<a href="/data_sources" title="Data Sources"><i class="fa fa-database"></i></a>
|
||||
</li>
|
||||
<li ng-show="currentUser.hasPermission('list_users')">
|
||||
<a href="/users" title="Users"><i class="fa fa-users"></i></a>
|
||||
</li>
|
||||
<li class="dropdown" dropdown>
|
||||
<a href="#" class="dropdown-toggle" dropdown-toggle><span ng-bind="currentUser.name"></span> <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu" dropdown-menu>
|
||||
<li style="width:300px">
|
||||
<a ng-href="/users/{{currentUser.id}}">
|
||||
<div class="row">
|
||||
<div class="col-sm-2">
|
||||
<img ng-src="{{currentUser.gravatar_url}}" size="40px" class="img-circle"/>
|
||||
</div>
|
||||
<div class="col-sm-10">
|
||||
<p><strong>{{currentUser.name}}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="divider">
|
||||
</li>
|
||||
<li>
|
||||
<a href="/logout" target="_self">Log out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endraw %}
|
||||
@@ -103,10 +120,27 @@
|
||||
<edit-dashboard-form dashboard="newDashboard" id="new_dashboard_dialog"></edit-dashboard-form>
|
||||
<div ng-view></div>
|
||||
|
||||
{% raw %}
|
||||
<div class="container-fluid footer">
|
||||
<hr/>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<a href="http://redash.io">re:dash</a> <span ng-bind="version"></span>
|
||||
<small ng-if="newVersionAvailable" ng-cloak class="ng-cloak"><a href="http://version.redash.io/">(new re:dash version available)</a></small>
|
||||
<div class="pull-right">
|
||||
<a href="http://docs.redash.io/">Docs</a>
|
||||
<a href="http://github.com/getredash/redash">Contribute</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
|
||||
<script src="/bower_components/jquery/jquery.js"></script>
|
||||
|
||||
<!-- build:js /scripts/plugins.js -->
|
||||
<script src="/bower_components/angular/angular.js"></script>
|
||||
<script src="/bower_components/angular-sanitize/angular-sanitize.js"></script>
|
||||
<script src="/bower_components/jquery-ui/ui/jquery-ui.js"></script>
|
||||
<script src="/bower_components/bootstrap/js/collapse.js"></script>
|
||||
<script src="/bower_components/bootstrap/js/modal.js"></script>
|
||||
@@ -131,8 +165,6 @@
|
||||
<script src="/bower_components/cornelius/src/cornelius.js"></script>
|
||||
<script src="/bower_components/mousetrap/mousetrap.js"></script>
|
||||
<script src="/bower_components/mousetrap/plugins/global-bind/mousetrap-global-bind.js"></script>
|
||||
<script src="/bower_components/select2/select2.js"></script>
|
||||
<script src="/bower_components/angular-ui-select2/src/select2.js"></script>
|
||||
<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>
|
||||
@@ -143,10 +175,13 @@
|
||||
<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/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>
|
||||
<script src="/bower_components/angular-bootstrap-show-errors/src/showErrors.js"></script>
|
||||
<script src="/bower_components/d3/d3.min.js"></script>
|
||||
<script src="/bower_components/angular-ui-sortable/sortable.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<!-- build:js({.tmp,app}) /scripts/scripts.js -->
|
||||
@@ -161,13 +196,17 @@
|
||||
<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/controllers/users.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/boxplot.js"></script>
|
||||
<script src="/scripts/visualizations/box.js"></script>
|
||||
<script src="/scripts/visualizations/table.js"></script>
|
||||
<script src="/scripts/visualizations/pivot.js"></script>
|
||||
<script src="/scripts/visualizations/date_range_selector.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>
|
||||
@@ -178,12 +217,12 @@
|
||||
|
||||
<script>
|
||||
// TODO: move currentUser & features to be an Angular service
|
||||
var featureFlags = {{ features|safe }};
|
||||
var clientConfig = {{ client_config|safe }};
|
||||
var currentUser = {{ user|safe }};
|
||||
|
||||
currentUser.canEdit = function(object) {
|
||||
var user_id = object.user_id || (object.user && object.user.id);
|
||||
return user_id && (user_id == currentUser.id);
|
||||
return this.hasPermission('admin') || (user_id && (user_id == currentUser.id));
|
||||
};
|
||||
|
||||
currentUser.hasPermission = function(permission) {
|
||||
|
||||
@@ -74,8 +74,8 @@
|
||||
|
||||
<form role="form" method="post" name="login">
|
||||
<div class="form-group">
|
||||
<label for="inputUsernameEmail">Username or email</label>
|
||||
<input type="text" class="form-control" id="inputUsernameEmail" name="username" value="{{username}}">
|
||||
<label for="inputEmail">Email</label>
|
||||
<input type="text" class="form-control" id="inputEmail" name="email" value="{{email}}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<!--<a class="pull-right" href="#">Forgot password?</a>-->
|
||||
|
||||
@@ -7,18 +7,20 @@ angular.module('redash', [
|
||||
'redash.renderers',
|
||||
'redash.visualization',
|
||||
'highchart',
|
||||
'ui.select2',
|
||||
'angular-growl',
|
||||
'angularMoment',
|
||||
'ui.bootstrap',
|
||||
'ui.sortable',
|
||||
'smartTable.table',
|
||||
'ngResource',
|
||||
'ngRoute',
|
||||
'ui.select',
|
||||
'naif.base64'
|
||||
]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider',
|
||||
function ($routeProvider, $locationProvider, $compileProvider, growlProvider) {
|
||||
if (featureFlags.clientSideMetrics) {
|
||||
'naif.base64',
|
||||
'ui.bootstrap.showErrors',
|
||||
'ngSanitize'
|
||||
]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider', 'uiSelectConfig',
|
||||
function ($routeProvider, $locationProvider, $compileProvider, growlProvider, uiSelectConfig) {
|
||||
if (clientConfig.clientSideMetrics) {
|
||||
Bucky.setOptions({
|
||||
host: '/api/metrics'
|
||||
});
|
||||
@@ -32,6 +34,8 @@ angular.module('redash', [
|
||||
return query.$promise;
|
||||
};
|
||||
|
||||
uiSelectConfig.theme = "bootstrap";
|
||||
|
||||
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|http|data):/);
|
||||
$locationProvider.html5Mode(true);
|
||||
growlProvider.globalTimeToLive(2000);
|
||||
@@ -100,10 +104,25 @@ angular.module('redash', [
|
||||
controller: 'DataSourcesCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/', {
|
||||
templateUrl: '/views/index.html',
|
||||
controller: 'IndexCtrl'
|
||||
$routeProvider.when('/users/new', {
|
||||
templateUrl: '/views/users/new.html',
|
||||
controller: 'NewUserCtrl'
|
||||
});
|
||||
$routeProvider.when('/users/:userId', {
|
||||
templateUrl: '/views/users/show.html',
|
||||
reloadOnSearch: false,
|
||||
controller: 'UserCtrl'
|
||||
});
|
||||
$routeProvider.when('/users', {
|
||||
templateUrl: '/views/users/list.html',
|
||||
controller: 'UsersCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/', {
|
||||
templateUrl: '/views/personal.html',
|
||||
controller: 'PersonalIndexCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/personal', {
|
||||
templateUrl: '/views/personal.html',
|
||||
controller: 'PersonalIndexCtrl'
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return value.toDate().toLocaleString();
|
||||
|
||||
return value.format(clientConfig.dateTimeFormat);
|
||||
};
|
||||
|
||||
var QuerySearchCtrl = function($scope, $location, $filter, Events, Query) {
|
||||
@@ -150,14 +151,16 @@
|
||||
}
|
||||
|
||||
var MainCtrl = function ($scope, $location, Dashboard, notifications) {
|
||||
if (featureFlags.clientSideMetrics) {
|
||||
$scope.version = clientConfig.version;
|
||||
$scope.newVersionAvailable = clientConfig.newVersionAvailable && currentUser.hasPermission("admin");
|
||||
|
||||
if (clientConfig.clientSideMetrics) {
|
||||
$scope.$on('$locationChangeSuccess', function(event, newLocation, oldLocation) {
|
||||
// This will be called once per actual page load.
|
||||
Bucky.sendPagePerformance();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
$scope.dashboards = [];
|
||||
$scope.reloadDashboards = function () {
|
||||
Dashboard.query(function (dashboards) {
|
||||
@@ -194,15 +197,6 @@
|
||||
var IndexCtrl = function ($scope, Events, Dashboard) {
|
||||
Events.record(currentUser, "view", "page", "homepage");
|
||||
$scope.$parent.pageTitle = "Home";
|
||||
|
||||
$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();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var PersonalIndexCtrl = function ($scope, Events, Dashboard, Query) {
|
||||
@@ -211,15 +205,6 @@
|
||||
|
||||
$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', [])
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
var w = new Widget(widget);
|
||||
|
||||
if (w.visualization) {
|
||||
promises.push(w.getQuery().getQueryResult().toPromise());
|
||||
var queryResult = w.getQuery().getQueryResult();
|
||||
if (angular.isDefined(queryResult))
|
||||
promises.push(queryResult.toPromise());
|
||||
}
|
||||
|
||||
return w;
|
||||
@@ -94,6 +96,15 @@
|
||||
}
|
||||
};
|
||||
|
||||
$scope.archiveDashboard = function () {
|
||||
if (confirm('Are you sure you want to archive the "' + $scope.dashboard.name + '" dashboard?')) {
|
||||
Events.record(currentUser, "archive", "dashboard", $scope.dashboard.id);
|
||||
$scope.dashboard.$delete(function () {
|
||||
$scope.$parent.reloadDashboards();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$scope.triggerRefresh = function() {
|
||||
$scope.refreshEnabled = !$scope.refreshEnabled;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
saveQuery = $scope.saveQuery;
|
||||
|
||||
$scope.sourceMode = true;
|
||||
$scope.canEdit = true;
|
||||
$scope.canEdit = currentUser.canEdit($scope.query) || clientConfig.allowAllToEditQueries;
|
||||
$scope.isDirty = false;
|
||||
|
||||
$scope.newVisualization = undefined;
|
||||
|
||||
@@ -19,14 +19,35 @@
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
|
||||
}
|
||||
|
||||
var getDataSourceId = function() {
|
||||
// Try to get the query's data source id
|
||||
var dataSourceId = $scope.query.data_source_id;
|
||||
|
||||
// If there is no source yet, then parse what we have in localStorage
|
||||
// e.g. `null` -> `NaN`, malformed data -> `NaN`, "1" -> 1
|
||||
if (dataSourceId === undefined) {
|
||||
dataSourceId = parseInt(localStorage.lastSelectedDataSourceId, 10);
|
||||
}
|
||||
|
||||
// If we had an invalid value in localStorage (e.g. nothing, deleted source), then use the first data source
|
||||
var isValidDataSourceId = !isNaN(dataSourceId) && _.some($scope.dataSources, function(ds) {
|
||||
return ds.id == dataSourceId;
|
||||
});
|
||||
if (!isValidDataSourceId) {
|
||||
dataSourceId = $scope.dataSources[0].id;
|
||||
}
|
||||
|
||||
// Return our data source id
|
||||
return dataSourceId;
|
||||
}
|
||||
|
||||
$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) {
|
||||
DataSource.getSchema({id: getDataSourceId()}, function(data) {
|
||||
if (data && data.length > 0) {
|
||||
$scope.schema = data;
|
||||
_.each(data, function(table) {
|
||||
@@ -48,12 +69,14 @@
|
||||
|
||||
$scope.isQueryOwner = (currentUser.id === $scope.query.user.id) || currentUser.hasPermission('admin');
|
||||
$scope.canViewSource = currentUser.hasPermission('view_source');
|
||||
$scope.canExecuteQuery = currentUser.hasPermission('execute_query');
|
||||
$scope.canScheduleQuery = currentUser.hasPermission('schedule_query');
|
||||
|
||||
$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.query.data_source_id = getDataSourceId();
|
||||
$scope.dataSource = _.find(dataSources, function(ds) { return ds.id == $scope.query.data_source_id; });
|
||||
}
|
||||
});
|
||||
@@ -104,9 +127,14 @@
|
||||
};
|
||||
|
||||
$scope.executeQuery = function() {
|
||||
if (!$scope.canExecuteQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$scope.query.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
getQueryResult(0);
|
||||
$scope.lockButton(true);
|
||||
$scope.cancelling = false;
|
||||
@@ -146,6 +174,7 @@
|
||||
|
||||
$scope.updateDataSource = function() {
|
||||
Events.record(currentUser, 'update_data_source', 'query', $scope.query.id);
|
||||
localStorage.lastSelectedDataSourceId = $scope.query.data_source_id;
|
||||
|
||||
$scope.query.latest_query_data = null;
|
||||
$scope.query.latest_query_data_id = null;
|
||||
@@ -212,7 +241,7 @@
|
||||
});
|
||||
|
||||
$scope.openScheduleForm = function() {
|
||||
if (!$scope.isQueryOwner) {
|
||||
if (!$scope.isQueryOwner || !$scope.canScheduleQuery) {
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
160
rd_ui/app/scripts/controllers/users.js
Normal file
160
rd_ui/app/scripts/controllers/users.js
Normal file
@@ -0,0 +1,160 @@
|
||||
(function () {
|
||||
var UsersCtrl = function ($scope, $location, growl, Events, User) {
|
||||
Events.record(currentUser, "view", "page", "users");
|
||||
$scope.$parent.pageTitle = "Users";
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 50,
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "",
|
||||
"map": "gravatar_url",
|
||||
"cellTemplate": '<img src="{{dataRow.gravatar_url}}" height="40px"/>'
|
||||
},
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplate": '<a href="/users/{{dataRow.id}}">{{dataRow.name}}</a>'
|
||||
},
|
||||
{
|
||||
'label': 'Joined',
|
||||
'cellTemplate': '<span am-time-ago="dataRow.created_at"></span>'
|
||||
}
|
||||
];
|
||||
|
||||
$scope.users = [];
|
||||
User.query(function(users) {
|
||||
$scope.users = users;
|
||||
});
|
||||
};
|
||||
|
||||
var UserCtrl = function ($scope, $routeParams, $http, $location, growl, Events, User) {
|
||||
$scope.$parent.pageTitle = "Users";
|
||||
|
||||
$scope.userId = $routeParams.userId;
|
||||
|
||||
if ($scope.userId === 'me') {
|
||||
$scope.userId = currentUser.id;
|
||||
}
|
||||
Events.record(currentUser, "view", "user", $scope.userId);
|
||||
$scope.canEdit = currentUser.hasPermission("admin") || currentUser.id === parseInt($scope.userId);
|
||||
$scope.showSettings = false;
|
||||
$scope.showPasswordSettings = false;
|
||||
|
||||
$scope.selectTab = function(tab) {
|
||||
_.each($scope.tabs, function(v, k) {
|
||||
$scope.tabs[k] = (k === tab);
|
||||
});
|
||||
};
|
||||
|
||||
$scope.setTab = function(tab) {
|
||||
$location.hash(tab);
|
||||
}
|
||||
|
||||
$scope.tabs = {
|
||||
profile: false,
|
||||
apiKey: false,
|
||||
settings: false,
|
||||
password: false
|
||||
};
|
||||
|
||||
$scope.selectTab($location.hash() || 'profile');
|
||||
|
||||
$scope.user = User.get({id: $scope.userId}, function(user) {
|
||||
if (user.auth_type == 'password') {
|
||||
$scope.showSettings = $scope.canEdit;
|
||||
$scope.showPasswordSettings = $scope.canEdit;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.password = {
|
||||
current: '',
|
||||
new: '',
|
||||
newRepeat: ''
|
||||
};
|
||||
|
||||
$scope.savePassword = function(form) {
|
||||
$scope.$broadcast('show-errors-check-validity');
|
||||
|
||||
if (!form.$valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
var data = {
|
||||
id: $scope.user.id,
|
||||
password: $scope.password.new,
|
||||
old_password: $scope.password.current
|
||||
};
|
||||
|
||||
User.save(data, function() {
|
||||
growl.addSuccessMessage("Password Saved.")
|
||||
$scope.password = {
|
||||
current: '',
|
||||
new: '',
|
||||
newRepeat: ''
|
||||
};
|
||||
}, function(error) {
|
||||
var message = error.data.message || "Failed saving password.";
|
||||
growl.addErrorMessage(message);
|
||||
});
|
||||
};
|
||||
|
||||
$scope.updateUser = function(form) {
|
||||
$scope.$broadcast('show-errors-check-validity');
|
||||
|
||||
if (!form.$valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
var data = {
|
||||
id: $scope.user.id,
|
||||
name: $scope.user.name,
|
||||
email: $scope.user.email
|
||||
};
|
||||
|
||||
if ($scope.user.admin === true && $scope.user.groups.indexOf("admin") === -1) {
|
||||
data.groups = $scope.user.groups.concat("admin");
|
||||
} else if ($scope.user.admin === false && $scope.user.groups.indexOf("admin") !== -1) {
|
||||
data.groups = _.without($scope.user.groups, "admin");
|
||||
}
|
||||
|
||||
User.save(data, function(user) {
|
||||
growl.addSuccessMessage("Saved.")
|
||||
$scope.user = user;
|
||||
}, function(error) {
|
||||
var message = error.data.message || "Failed saving.";
|
||||
growl.addErrorMessage(message);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
var NewUserCtrl = function ($scope, $location, growl, Events, User) {
|
||||
Events.record(currentUser, "view", "page", "users/new");
|
||||
|
||||
$scope.user = new User({});
|
||||
$scope.saveUser = function() {
|
||||
$scope.$broadcast('show-errors-check-validity');
|
||||
|
||||
if (!$scope.userForm.$valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.user.$save(function(user) {
|
||||
growl.addSuccessMessage("Saved.")
|
||||
$location.path('/users/' + user.id).replace();
|
||||
}, function(error) {
|
||||
var message = error.data.message || "Failed saving.";
|
||||
growl.addErrorMessage(message);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('UsersCtrl', ['$scope', '$location', 'growl', 'Events', 'User', UsersCtrl])
|
||||
.controller('UserCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'User', UserCtrl])
|
||||
.controller('NewUserCtrl', ['$scope', '$location', 'growl', 'Events', 'User', NewUserCtrl])
|
||||
})();
|
||||
@@ -142,6 +142,11 @@
|
||||
|
||||
$scope.setType = function (type) {
|
||||
$scope.type = type;
|
||||
if (type == 'textbox') {
|
||||
$scope.widgetSizes.push({name: 'Hidden', value: 0});
|
||||
} else if ($scope.widgetSizes.length > 2) {
|
||||
$scope.widgetSizes.pop();
|
||||
}
|
||||
};
|
||||
|
||||
var reset = function() {
|
||||
@@ -186,7 +191,6 @@
|
||||
|
||||
$scope.saveWidget = function() {
|
||||
$scope.saveInProgress = true;
|
||||
|
||||
var widget = new Widget({
|
||||
'visualization_id': $scope.selectedVis && $scope.selectedVis.id,
|
||||
'dashboard_id': $scope.dashboard.id,
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
prop.type = 'file';
|
||||
}
|
||||
|
||||
if (prop.type == 'boolean') {
|
||||
prop.type = 'checkbox';
|
||||
}
|
||||
|
||||
prop.required = _.contains(type.configuration_schema.required, name);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,4 +247,68 @@
|
||||
};
|
||||
}]
|
||||
);
|
||||
|
||||
directives.directive('compareTo', function () {
|
||||
return {
|
||||
require: "ngModel",
|
||||
scope: {
|
||||
otherModelValue: "=compareTo"
|
||||
},
|
||||
link: function (scope, element, attributes, ngModel) {
|
||||
var validate = function(value) {
|
||||
ngModel.$setValidity("compareTo", value === scope.otherModelValue);
|
||||
};
|
||||
|
||||
scope.$watch("otherModelValue", function() {
|
||||
validate(ngModel.$modelValue);
|
||||
});
|
||||
|
||||
ngModel.$parsers.push(function(value) {
|
||||
validate(value);
|
||||
return value;
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
directives.directive('inputErrors', function () {
|
||||
return {
|
||||
restrict: "E",
|
||||
templateUrl: "/views/directives/input_errors.html",
|
||||
replace: true,
|
||||
scope: {
|
||||
errors: "="
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
directives.directive('onDestroy', function () {
|
||||
/* This directive can be used to invoke a callback when an element is destroyed,
|
||||
A useful example is the following:
|
||||
<div ng-if="includeText" on-destroy="form.text = null;">
|
||||
<input type="text" ng-model="form.text">
|
||||
</div>
|
||||
*/
|
||||
return {
|
||||
restrict: "A",
|
||||
scope: {
|
||||
onDestroy: "&",
|
||||
},
|
||||
link: function(scope, elem, attrs) {
|
||||
console.log(scope.onDestroy);
|
||||
scope.$on('$destroy', function() {
|
||||
scope.onDestroy();
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
directives.directive('colorBox', function () {
|
||||
return {
|
||||
restrict: "E",
|
||||
scope: {color: "="},
|
||||
template: "<span style='width: 12px; height: 12px; background-color: {{color}}; display: inline-block; margin-right: 5px;'></span>"
|
||||
};
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@@ -48,6 +48,9 @@ angular.module('redash.filters', []).
|
||||
|
||||
.filter('colWidth', function () {
|
||||
return function (widgetWidth) {
|
||||
if (widgetWidth == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (widgetWidth == 1) {
|
||||
return 6;
|
||||
}
|
||||
@@ -77,7 +80,13 @@ angular.module('redash.filters', []).
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
return $sce.trustAsHtml(marked(text));
|
||||
|
||||
var html = marked(text);
|
||||
if (clientConfig.allowScriptsInUserInput) {
|
||||
html = $sce.trustAsHtml(html);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
}])
|
||||
|
||||
@@ -88,4 +97,21 @@ angular.module('redash.filters', []).
|
||||
}
|
||||
return $sce.trustAsHtml(text);
|
||||
}
|
||||
}]);
|
||||
}])
|
||||
|
||||
.filter('remove', function() {
|
||||
return function(items, item) {
|
||||
if (items == undefined)
|
||||
return items;
|
||||
if (item instanceof Array) {
|
||||
var notEquals = function(other) { return item.indexOf(other) == -1; }
|
||||
} else {
|
||||
var notEquals = function(other) { return item != other; }
|
||||
}
|
||||
var filtered = [];
|
||||
for (var i = 0; i < items.length; i++)
|
||||
if (notEquals(items[i]))
|
||||
filtered.push(items[i])
|
||||
return filtered;
|
||||
};
|
||||
});
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
'Light Blue': '#92A8CD',
|
||||
'Lilac': '#A47D7C',
|
||||
'Light Green': '#B5CA92',
|
||||
'Brown':'#A52A2A',
|
||||
'Black':'#000000',
|
||||
'Gray':'#808080',
|
||||
'Pink':'#FFC0CB',
|
||||
'Dark Blue':'#00008b'
|
||||
};
|
||||
|
||||
Highcharts.setOptions({
|
||||
@@ -50,7 +55,7 @@
|
||||
;
|
||||
|
||||
if (moment.isMoment(this.x)) {
|
||||
var s = '<b>' + this.x.toDate().toLocaleString() + '</b>',
|
||||
var s = '<b>' + this.x.format(clientConfig.dateTimeFormat) + '</b>',
|
||||
pointsCount = this.points.length;
|
||||
|
||||
$.each(this.points, function (i, point) {
|
||||
@@ -92,19 +97,6 @@
|
||||
buttons: {
|
||||
contextButton: {
|
||||
menuItems: [
|
||||
{
|
||||
text: 'Toggle % Stacking',
|
||||
onclick: function () {
|
||||
var newStacking = "normal";
|
||||
if (this.series[0].options.stacking == "normal") {
|
||||
newStacking = "percent";
|
||||
}
|
||||
|
||||
_.each(this.series, function (series) {
|
||||
series.update({stacking: newStacking}, true);
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Select All',
|
||||
onclick: function () {
|
||||
@@ -266,6 +258,9 @@
|
||||
};
|
||||
|
||||
var chartOptions = $.extend(true, {}, defaultOptions, chartsDefaults);
|
||||
chartOptions.plotOptions.series = {
|
||||
turboThreshold: clientConfig.highChartsTurboThreshold
|
||||
}
|
||||
|
||||
// $timeout makes sure that this function invoked after the DOM ready. When draw/init
|
||||
// invoked after the DOM is ready, we see first an empty HighCharts objects and later
|
||||
|
||||
@@ -10,7 +10,7 @@ function getNestedValue (obj, keys) {
|
||||
function getKeyFromObject(obj, key) {
|
||||
var value = obj[key];
|
||||
|
||||
if ((!_.include(obj, key) && _.string.include(key, '.'))) {
|
||||
if ((!_.has(obj, key) && _.string.include(key, '.'))) {
|
||||
var keys = key.split(".");
|
||||
|
||||
value = getNestedValue(obj, keys);
|
||||
@@ -221,7 +221,7 @@ function getKeyFromObject(obj, key) {
|
||||
}])
|
||||
//a customisable cell (see templateUrl) and editable
|
||||
//TODO check with the ng-include strategy
|
||||
.directive('smartTableDataCell', ['$filter', '$http', '$templateCache', '$compile', '$parse', function (filter, http, templateCache, compile, parse) {
|
||||
.directive('smartTableDataCell', ['$filter', '$http', '$templateCache', '$compile', '$parse', '$sanitize', function (filter, http, templateCache, compile, parse, sanitize) {
|
||||
return {
|
||||
restrict: 'C',
|
||||
link: function (scope, element) {
|
||||
@@ -248,7 +248,12 @@ function getKeyFromObject(obj, key) {
|
||||
element.html(column.cellTemplate);
|
||||
compile(element.contents())(childScope);
|
||||
} else {
|
||||
element.html(scope.formatedValue);
|
||||
if (typeof scope.formatedValue === 'string' || scope.formatedValue instanceof String) {
|
||||
element.html(sanitize(scope.formatedValue));
|
||||
} else {
|
||||
element.text(scope.formatedValue);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,7 +718,7 @@ angular.module("partials/smartTable.html", []).run(["$templateCache", function (
|
||||
" </tbody>\n" +
|
||||
" <tfoot ng-show=\"isPaginationEnabled\">\n" +
|
||||
" <tr class=\"smart-table-footer-row\">\n" +
|
||||
" <td colspan=\"{{columns.length}}\">\n" +
|
||||
" <td class=\"text-center\" colspan=\"{{columns.length}}\">\n" +
|
||||
" <div pagination-smart-table=\"\" num-pages=\"numberOfPages\" max-size=\"maxSize\" current-page=\"currentPage\"></div>\n" +
|
||||
" </td>\n" +
|
||||
" </tr>\n" +
|
||||
|
||||
@@ -43,10 +43,10 @@
|
||||
if (angular.isNumber(v)) {
|
||||
columnTypes[k] = 'float';
|
||||
} else if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}T/)) {
|
||||
row[k] = moment(v);
|
||||
row[k] = moment.utc(v);
|
||||
columnTypes[k] = 'datetime';
|
||||
} else if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
row[k] = moment(v);
|
||||
} else if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
row[k] = moment.utc(v);
|
||||
columnTypes[k] = 'date';
|
||||
} else if (typeof(v) == 'object' && v !== null) {
|
||||
row[k] = JSON.stringify(v);
|
||||
@@ -186,9 +186,38 @@
|
||||
}
|
||||
|
||||
return this.filteredData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to add a point into a series, also checks whether the point is within dateRange
|
||||
*/
|
||||
QueryResult.prototype._addPointToSeriesIfInDateRange = function (point, seriesCollection, seriesName, dateRange) {
|
||||
if (dateRange && moment.isMoment(point.x)) {
|
||||
// if dateRange is provided and x Axis is of type datetime
|
||||
if (point.x.isBefore(dateRange.min) || point.x.isAfter(dateRange.max)) {
|
||||
// if the point's date isn't within dateRange, then we will not add this point to series
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._addPointToSeries(point, seriesCollection, seriesName);
|
||||
}
|
||||
|
||||
QueryResult.prototype.getChartData = function (mapping) {
|
||||
/**
|
||||
* Helper function to add a point into a series
|
||||
*/
|
||||
QueryResult.prototype._addPointToSeries = function (point, seriesCollection, seriesName) {
|
||||
if (seriesCollection[seriesName] == undefined) {
|
||||
seriesCollection[seriesName] = {
|
||||
name: seriesName,
|
||||
type: 'column',
|
||||
data: []
|
||||
};
|
||||
}
|
||||
|
||||
seriesCollection[seriesName]['data'].push(point);
|
||||
};
|
||||
|
||||
QueryResult.prototype.getChartData = function (mapping, dateRange) {
|
||||
var series = {};
|
||||
|
||||
_.each(this.getData(), function (row) {
|
||||
@@ -198,8 +227,8 @@
|
||||
var yValues = {};
|
||||
|
||||
_.each(row, function (value, definition) {
|
||||
var name = definition.split("::")[0];
|
||||
var type = definition.split("::")[1];
|
||||
var name = definition.split("::")[0] || definition.split("__")[0];
|
||||
var type = definition.split("::")[1] || definition.split("__")[1];
|
||||
if (mapping) {
|
||||
type = mapping[definition];
|
||||
}
|
||||
@@ -224,31 +253,20 @@
|
||||
seriesName = String(value);
|
||||
}
|
||||
|
||||
if (type == 'multi-filter') {
|
||||
if (type == 'multiFilter' || type == 'multi-filter') {
|
||||
seriesName = String(value);
|
||||
}
|
||||
});
|
||||
|
||||
var addPointToSeries = function (seriesName, point) {
|
||||
if (series[seriesName] == undefined) {
|
||||
series[seriesName] = {
|
||||
name: seriesName,
|
||||
type: 'column',
|
||||
data: []
|
||||
}
|
||||
}
|
||||
|
||||
series[seriesName]['data'].push(point);
|
||||
}
|
||||
|
||||
if (seriesName === undefined) {
|
||||
_.each(yValues, function (yValue, seriesName) {
|
||||
addPointToSeries(seriesName, {'x': xValue, 'y': yValue});
|
||||
});
|
||||
} else {
|
||||
addPointToSeries(seriesName, point);
|
||||
this._addPointToSeriesIfInDateRange({'x': xValue, 'y': yValue}, series, seriesName, dateRange);
|
||||
}.bind(this));
|
||||
}
|
||||
});
|
||||
else {
|
||||
this._addPointToSeriesIfInDateRange(point, series, seriesName, dateRange);
|
||||
}
|
||||
}.bind(this));
|
||||
|
||||
return _.values(series);
|
||||
};
|
||||
@@ -272,7 +290,16 @@
|
||||
}
|
||||
|
||||
QueryResult.prototype.getColumnNameWithoutType = function (column) {
|
||||
var parts = column.split('::');
|
||||
var typeSplit;
|
||||
if (column.indexOf("::") != -1) {
|
||||
typeSplit = "::";
|
||||
} else if (column.indexOf("__" != -1)) {
|
||||
typeSplit = "__";
|
||||
} else {
|
||||
return column;
|
||||
}
|
||||
|
||||
var parts = column.split(typeSplit);
|
||||
if (parts[0] == "" && parts.length == 2) {
|
||||
return parts[1];
|
||||
}
|
||||
@@ -313,16 +340,16 @@
|
||||
|
||||
QueryResult.prototype.prepareFilters = function () {
|
||||
var filters = [];
|
||||
var filterTypes = ['filter', 'multi-filter'];
|
||||
var filterTypes = ['filter', 'multi-filter', 'multiFilter'];
|
||||
_.each(this.getColumnNames(), function (col) {
|
||||
var type = col.split('::')[1]
|
||||
var type = col.split('::')[1] || col.split('__')[1];
|
||||
if (_.contains(filterTypes, type)) {
|
||||
// filter found
|
||||
var filter = {
|
||||
name: col,
|
||||
friendlyName: this.getColumnFriendlyName(col),
|
||||
values: [],
|
||||
multiple: (type=='multi-filter')
|
||||
multiple: (type=='multiFilter') || (type=='multi-filter')
|
||||
}
|
||||
filters.push(filter);
|
||||
}
|
||||
@@ -534,10 +561,37 @@
|
||||
|
||||
var DataSourceResource = $resource('/api/data_sources/:id', {id: '@id'}, actions);
|
||||
|
||||
|
||||
return DataSourceResource;
|
||||
};
|
||||
|
||||
var User = function ($resource, $http) {
|
||||
var transformSingle = function(user) {
|
||||
if (user.groups !== undefined) {
|
||||
user.admin = user.groups.indexOf("admin") != -1;
|
||||
}
|
||||
};
|
||||
|
||||
var transform = $http.defaults.transformResponse.concat(function(data, headers) {
|
||||
if (_.isArray(data)) {
|
||||
_.each(data, transformSingle);
|
||||
} else {
|
||||
transformSingle(data);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
var actions = {
|
||||
'get': {method: 'GET', transformResponse: transform},
|
||||
'save': {method: 'POST', transformResponse: transform},
|
||||
'query': {method: 'GET', isArray: true, transformResponse: transform},
|
||||
'delete': {method: 'DELETE', transformResponse: transform}
|
||||
};
|
||||
|
||||
var UserResource = $resource('/api/users/:id', {id: '@id'}, actions);
|
||||
|
||||
return UserResource;
|
||||
};
|
||||
|
||||
var AlertSubscription = function ($resource) {
|
||||
var resource = $resource('/api/alerts/:alertId/subscriptions/:userId', {alertId: '@alert_id', userId: '@user.id'});
|
||||
return resource;
|
||||
@@ -590,5 +644,6 @@
|
||||
.factory('DataSource', ['$resource', DataSource])
|
||||
.factory('Alert', ['$resource', '$http', Alert])
|
||||
.factory('AlertSubscription', ['$resource', AlertSubscription])
|
||||
.factory('Widget', ['$resource', 'Query', Widget]);
|
||||
.factory('Widget', ['$resource', 'Query', Widget])
|
||||
.factory('User', ['$resource', '$http', User]);
|
||||
})();
|
||||
|
||||
@@ -84,10 +84,6 @@
|
||||
template: '<filters></filters>\n' + Visualization.renderVisualizationsTemplate,
|
||||
replace: false,
|
||||
link: function (scope) {
|
||||
scope.select2Options = {
|
||||
width: '50%'
|
||||
};
|
||||
|
||||
scope.$watch('queryResult && queryResult.getFilters()', function (filters) {
|
||||
if (filters) {
|
||||
scope.filters = filters;
|
||||
|
||||
307
rd_ui/app/scripts/visualizations/box.js
Normal file
307
rd_ui/app/scripts/visualizations/box.js
Normal file
@@ -0,0 +1,307 @@
|
||||
(function() {
|
||||
|
||||
// Inspired by http://informationandvisualization.de/blog/box-plot
|
||||
d3.box = function() {
|
||||
var width = 1,
|
||||
height = 1,
|
||||
duration = 0,
|
||||
domain = null,
|
||||
value = Number,
|
||||
whiskers = boxWhiskers,
|
||||
quartiles = boxQuartiles,
|
||||
tickFormat = null;
|
||||
|
||||
// For each small multiple…
|
||||
function box(g) {
|
||||
g.each(function(d, i) {
|
||||
d = d.map(value).sort(d3.ascending);
|
||||
var g = d3.select(this),
|
||||
n = d.length,
|
||||
min = d[0],
|
||||
max = d[n - 1];
|
||||
|
||||
// Compute quartiles. Must return exactly 3 elements.
|
||||
var quartileData = d.quartiles = quartiles(d);
|
||||
|
||||
// Compute whiskers. Must return exactly 2 elements, or null.
|
||||
var whiskerIndices = whiskers && whiskers.call(this, d, i),
|
||||
whiskerData = whiskerIndices && whiskerIndices.map(function(i) { return d[i]; });
|
||||
|
||||
// Compute outliers. If no whiskers are specified, all data are "outliers".
|
||||
// We compute the outliers as indices, so that we can join across transitions!
|
||||
var outlierIndices = whiskerIndices
|
||||
? d3.range(0, whiskerIndices[0]).concat(d3.range(whiskerIndices[1] + 1, n))
|
||||
: d3.range(n);
|
||||
|
||||
// Compute the new x-scale.
|
||||
var x1 = d3.scale.linear()
|
||||
.domain(domain && domain.call(this, d, i) || [min, max])
|
||||
.range([height, 0]);
|
||||
|
||||
// Retrieve the old x-scale, if this is an update.
|
||||
var x0 = this.__chart__ || d3.scale.linear()
|
||||
.domain([0, Infinity])
|
||||
.range(x1.range());
|
||||
|
||||
// Stash the new scale.
|
||||
this.__chart__ = x1;
|
||||
|
||||
// Note: the box, median, and box tick elements are fixed in number,
|
||||
// so we only have to handle enter and update. In contrast, the outliers
|
||||
// and other elements are variable, so we need to exit them! Variable
|
||||
// elements also fade in and out.
|
||||
|
||||
// Update center line: the vertical line spanning the whiskers.
|
||||
var center = g.selectAll("line.center")
|
||||
.data(whiskerData ? [whiskerData] : []);
|
||||
|
||||
center.enter().insert("line", "rect")
|
||||
.attr("class", "center")
|
||||
.attr("x1", width / 2)
|
||||
.attr("y1", function(d) { return x0(d[0]); })
|
||||
.attr("x2", width / 2)
|
||||
.attr("y2", function(d) { return x0(d[1]); })
|
||||
.style("opacity", 1e-6)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.style("opacity", 1)
|
||||
.attr("y1", function(d) { return x1(d[0]); })
|
||||
.attr("y2", function(d) { return x1(d[1]); });
|
||||
|
||||
center.transition()
|
||||
.duration(duration)
|
||||
.style("opacity", 1)
|
||||
.attr("y1", function(d) { return x1(d[0]); })
|
||||
.attr("y2", function(d) { return x1(d[1]); });
|
||||
|
||||
center.exit().transition()
|
||||
.duration(duration)
|
||||
.style("opacity", 1e-6)
|
||||
.attr("y1", function(d) { return x1(d[0]); })
|
||||
.attr("y2", function(d) { return x1(d[1]); })
|
||||
.remove();
|
||||
|
||||
// Update innerquartile box.
|
||||
var box = g.selectAll("rect.box")
|
||||
.data([quartileData]);
|
||||
|
||||
box.enter().append("rect")
|
||||
.attr("class", "box")
|
||||
.attr("x", 0)
|
||||
.attr("y", function(d) { return x0(d[2]); })
|
||||
.attr("width", width)
|
||||
.attr("height", function(d) { return x0(d[0]) - x0(d[2]); })
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr("y", function(d) { return x1(d[2]); })
|
||||
.attr("height", function(d) { return x1(d[0]) - x1(d[2]); });
|
||||
|
||||
box.transition()
|
||||
.duration(duration)
|
||||
.attr("y", function(d) { return x1(d[2]); })
|
||||
.attr("height", function(d) { return x1(d[0]) - x1(d[2]); });
|
||||
|
||||
box.exit().remove()
|
||||
|
||||
// Update median line.
|
||||
var medianLine = g.selectAll("line.median")
|
||||
.data([quartileData[1]]);
|
||||
|
||||
medianLine.enter().append("line")
|
||||
.attr("class", "median")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", x0)
|
||||
.attr("x2", width)
|
||||
.attr("y2", x0)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr("y1", x1)
|
||||
.attr("y2", x1);
|
||||
|
||||
medianLine.transition()
|
||||
.duration(duration)
|
||||
.attr("y1", x1)
|
||||
.attr("y2", x1);
|
||||
|
||||
medianLine.exit().remove()
|
||||
|
||||
// Update whiskers.
|
||||
var whisker = g.selectAll("line.whisker")
|
||||
.data(whiskerData || []);
|
||||
|
||||
whisker.enter().insert("line", "circle, text")
|
||||
.attr("class", "whisker")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", x0)
|
||||
.attr("x2", width)
|
||||
.attr("y2", x0)
|
||||
.style("opacity", 1e-6)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr("y1", x1)
|
||||
.attr("y2", x1)
|
||||
.style("opacity", 1);
|
||||
|
||||
whisker.transition()
|
||||
.duration(duration)
|
||||
.attr("y1", x1)
|
||||
.attr("y2", x1)
|
||||
.style("opacity", 1);
|
||||
|
||||
whisker.exit().transition()
|
||||
.duration(duration)
|
||||
.attr("y1", x1)
|
||||
.attr("y2", x1)
|
||||
.style("opacity", 1e-6)
|
||||
.remove();
|
||||
|
||||
// Update outliers.
|
||||
var outlier = g.selectAll("circle.outlier")
|
||||
.data(outlierIndices, Number);
|
||||
|
||||
outlier.enter().insert("circle", "text")
|
||||
.attr("class", "outlier")
|
||||
.attr("r", 5)
|
||||
.attr("cx", width / 2)
|
||||
.attr("cy", function(i) { return x0(d[i]); })
|
||||
.style("opacity", 1e-6)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr("cy", function(i) { return x1(d[i]); })
|
||||
.style("opacity", 1);
|
||||
|
||||
outlier.transition()
|
||||
.duration(duration)
|
||||
.attr("cy", function(i) { return x1(d[i]); })
|
||||
.style("opacity", 1);
|
||||
|
||||
outlier.exit().transition()
|
||||
.duration(duration)
|
||||
.attr("cy", function(i) { return x1(d[i]); })
|
||||
.style("opacity", 1e-6)
|
||||
.remove();
|
||||
|
||||
// Compute the tick format.
|
||||
var format = tickFormat || x1.tickFormat(8);
|
||||
|
||||
// Update box ticks.
|
||||
var boxTick = g.selectAll("text.box")
|
||||
.data(quartileData);
|
||||
|
||||
boxTick.enter().append("text")
|
||||
.attr("class", "box")
|
||||
.attr("dy", ".3em")
|
||||
.attr("dx", function(d, i) { return i & 1 ? 6 : -6 })
|
||||
.attr("x", function(d, i) { return i & 1 ? width : 0 })
|
||||
.attr("y", x0)
|
||||
.attr("text-anchor", function(d, i) { return i & 1 ? "start" : "end"; })
|
||||
.text(format)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr("y", x1);
|
||||
|
||||
boxTick.transition()
|
||||
.duration(duration)
|
||||
.text(format)
|
||||
.attr("y", x1);
|
||||
|
||||
boxTick.exit().remove()
|
||||
|
||||
// Update whisker ticks. These are handled separately from the box
|
||||
// ticks because they may or may not exist, and we want don't want
|
||||
// to join box ticks pre-transition with whisker ticks post-.
|
||||
var whiskerTick = g.selectAll("text.whisker")
|
||||
.data(whiskerData || []);
|
||||
|
||||
whiskerTick.enter().append("text")
|
||||
.attr("class", "whisker")
|
||||
.attr("dy", ".3em")
|
||||
.attr("dx", 6)
|
||||
.attr("x", width)
|
||||
.attr("y", x0)
|
||||
.text(format)
|
||||
.style("opacity", 1e-6)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr("y", x1)
|
||||
.style("opacity", 1);
|
||||
|
||||
whiskerTick.transition()
|
||||
.duration(duration)
|
||||
.text(format)
|
||||
.attr("y", x1)
|
||||
.style("opacity", 1);
|
||||
|
||||
whiskerTick.exit().transition()
|
||||
.duration(duration)
|
||||
.attr("y", x1)
|
||||
.style("opacity", 1e-6)
|
||||
.remove();
|
||||
});
|
||||
d3.timer.flush();
|
||||
}
|
||||
|
||||
box.width = function(x) {
|
||||
if (!arguments.length) return width;
|
||||
width = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.height = function(x) {
|
||||
if (!arguments.length) return height;
|
||||
height = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.tickFormat = function(x) {
|
||||
if (!arguments.length) return tickFormat;
|
||||
tickFormat = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.duration = function(x) {
|
||||
if (!arguments.length) return duration;
|
||||
duration = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.domain = function(x) {
|
||||
if (!arguments.length) return domain;
|
||||
domain = x == null ? x : d3.functor(x);
|
||||
return box;
|
||||
};
|
||||
|
||||
box.value = function(x) {
|
||||
if (!arguments.length) return value;
|
||||
value = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.whiskers = function(x) {
|
||||
if (!arguments.length) return whiskers;
|
||||
whiskers = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.quartiles = function(x) {
|
||||
if (!arguments.length) return quartiles;
|
||||
quartiles = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
return box;
|
||||
};
|
||||
|
||||
function boxWhiskers(d) {
|
||||
return [0, d.length - 1];
|
||||
}
|
||||
|
||||
function boxQuartiles(d) {
|
||||
return [
|
||||
d3.quantile(d, .25),
|
||||
d3.quantile(d, .5),
|
||||
d3.quantile(d, .75)
|
||||
];
|
||||
}
|
||||
|
||||
})();
|
||||
173
rd_ui/app/scripts/visualizations/boxplot.js
Normal file
173
rd_ui/app/scripts/visualizations/boxplot.js
Normal file
@@ -0,0 +1,173 @@
|
||||
(function() {
|
||||
var module = angular.module('redash.visualization');
|
||||
|
||||
module.config(['VisualizationProvider', function(VisualizationProvider) {
|
||||
var renderTemplate =
|
||||
'<boxplot-renderer ' +
|
||||
'options="visualization.options" query-result="queryResult">' +
|
||||
'</boxplot-renderer>';
|
||||
|
||||
var editTemplate = '<boxplot-editor></boxplot-editor>';
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'BOXPLOT',
|
||||
name: 'Boxplot',
|
||||
renderTemplate: renderTemplate,
|
||||
editorTemplate: editTemplate
|
||||
});
|
||||
}
|
||||
]);
|
||||
module.directive('boxplotRenderer', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/boxplot.html',
|
||||
link: function($scope, elm, attrs) {
|
||||
|
||||
function iqr(k) {
|
||||
return function(d, i) {
|
||||
var q1 = d.quartiles[0],
|
||||
q3 = d.quartiles[2],
|
||||
iqr = (q3 - q1) * k,
|
||||
i = -1,
|
||||
j = d.length;
|
||||
while (d[++i] < q1 - iqr);
|
||||
while (d[--j] > q3 + iqr);
|
||||
return [i, j];
|
||||
};
|
||||
};
|
||||
|
||||
$scope.$watch('[queryResult && queryResult.getData(), visualization.options]', function () {
|
||||
|
||||
var data = $scope.queryResult.getData();
|
||||
var parentWidth = d3.select(elm[0].parentNode).node().getBoundingClientRect().width;
|
||||
var margin = {top: 10, right: 50, bottom: 40, left: 50, inner: 25},
|
||||
width = parentWidth - margin.right - margin.left
|
||||
height = 500 - margin.top - margin.bottom;
|
||||
|
||||
var min = Infinity,
|
||||
max = -Infinity;
|
||||
var mydata = [];
|
||||
var value = 0;
|
||||
var d = [];
|
||||
var xAxisLabel = $scope.visualization.options.xAxisLabel;
|
||||
var yAxisLabel = $scope.visualization.options.yAxisLabel;
|
||||
|
||||
var columns = $scope.queryResult.columnNames;
|
||||
var xscale = d3.scale.ordinal()
|
||||
.domain(columns)
|
||||
.rangeBands([0, parentWidth-margin.left-margin.right]);
|
||||
|
||||
if (columns.length > 1){
|
||||
boxWidth = Math.min(xscale(columns[1]),120.0);
|
||||
} else {
|
||||
boxWidth=120.0;
|
||||
};
|
||||
margin.inner = boxWidth/3.0;
|
||||
|
||||
_.each(columns, function(column, i){
|
||||
d = mydata[i] = [];
|
||||
_.each(data, function (row) {
|
||||
value = row[column];
|
||||
d.push(value);
|
||||
if (value > max) max = Math.ceil(value);
|
||||
if (value < min) min = Math.floor(value);
|
||||
});
|
||||
});
|
||||
|
||||
var yscale = d3.scale.linear()
|
||||
.domain([min*0.99,max*1.01])
|
||||
.range([height, 0]);
|
||||
|
||||
var chart = d3.box()
|
||||
.whiskers(iqr(1.5))
|
||||
.width(boxWidth-2*margin.inner)
|
||||
.height(height)
|
||||
.domain([min*0.99,max*1.01]);
|
||||
var xAxis = d3.svg.axis()
|
||||
.scale(xscale)
|
||||
.orient("bottom");
|
||||
|
||||
|
||||
var yAxis = d3.svg.axis()
|
||||
.scale(yscale)
|
||||
.orient("left");
|
||||
|
||||
var xLines = d3.svg.axis()
|
||||
.scale(xscale)
|
||||
.tickSize(height)
|
||||
.orient("bottom");
|
||||
|
||||
var yLines = d3.svg.axis()
|
||||
.scale(yscale)
|
||||
.tickSize(width)
|
||||
.orient("right");
|
||||
|
||||
var barOffset = function(i){
|
||||
return xscale(columns[i]) + (xscale(columns[1]) - margin.inner)/2.0;
|
||||
};
|
||||
|
||||
d3.select(elm[0]).selectAll("svg").remove();
|
||||
|
||||
var plot = d3.select(elm[0])
|
||||
.append("svg")
|
||||
.attr("width",parentWidth)
|
||||
.attr("height",height + margin.bottom + margin.top)
|
||||
.append("g")
|
||||
.attr("width",parentWidth-margin.left-margin.right)
|
||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
|
||||
|
||||
d3.select("svg").append("text")
|
||||
.attr("class", "box")
|
||||
.attr("x", parentWidth/2.0)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", height+margin.bottom)
|
||||
.text(xAxisLabel)
|
||||
|
||||
d3.select("svg").append("text")
|
||||
.attr("class", "box")
|
||||
.attr("transform","translate(10,"+(height+margin.top+margin.bottom)/2.0+")rotate(-90)")
|
||||
.attr("text-anchor", "middle")
|
||||
.text(yAxisLabel)
|
||||
|
||||
plot.append("rect")
|
||||
.attr("class", "grid-background")
|
||||
.attr("width", width)
|
||||
.attr("height", height);
|
||||
|
||||
plot.append("g")
|
||||
.attr("class","grid")
|
||||
.call(yLines)
|
||||
|
||||
plot.append("g")
|
||||
.attr("class","grid")
|
||||
.call(xLines)
|
||||
|
||||
plot.append("g")
|
||||
.attr("class", "x axis")
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.call(xAxis);
|
||||
|
||||
plot.append("g")
|
||||
.attr("class", "y axis")
|
||||
.call(yAxis);
|
||||
|
||||
plot.selectAll(".box").data(mydata)
|
||||
.enter().append("g")
|
||||
.attr("class", "box")
|
||||
.attr("width", boxWidth)
|
||||
.attr("height", height)
|
||||
.attr("transform", function(d,i) { return "translate(" + barOffset(i) + "," + 0 + ")"; } )
|
||||
.call(chart);
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.directive('boxplotEditor', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/boxplot_editor.html'
|
||||
};
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -3,12 +3,16 @@
|
||||
|
||||
chartVisualization.config(['VisualizationProvider', function (VisualizationProvider) {
|
||||
var renderTemplate = '<chart-renderer options="visualization.options" query-result="queryResult"></chart-renderer>';
|
||||
var editTemplate = '<chart-editor></chart-editor>';
|
||||
var editTemplate = '<chart-editor options="visualization.options" query-result="queryResult"></chart-editor>';
|
||||
|
||||
var defaultOptions = {
|
||||
'series': {
|
||||
// 'type': 'column',
|
||||
'stacking': null
|
||||
}
|
||||
globalSeriesType: 'column',
|
||||
sortX: true,
|
||||
yAxis: [{type: 'linear'}, {type: 'linear', opposite: true}],
|
||||
xAxis: {type: 'datetime', labels: {enabled: true}},
|
||||
series: {stacking: null},
|
||||
seriesOptions: {},
|
||||
columnMapping: {}
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
@@ -27,30 +31,62 @@
|
||||
queryResult: '=',
|
||||
options: '=?'
|
||||
},
|
||||
template: "<chart options='chartOptions' series='chartSeries' class='graph'></chart>",
|
||||
templateUrl: '/views/visualizations/chart.html',
|
||||
replace: false,
|
||||
controller: ['$scope', function ($scope) {
|
||||
$scope.chartSeries = [];
|
||||
$scope.chartOptions = {};
|
||||
$scope.dateRangeEnabled = function() {
|
||||
return $scope.options.xAxis && $scope.options.xAxis.type === 'datetime';
|
||||
}
|
||||
$scope.dateRange = { min: moment('1970-01-01'), max: moment() };
|
||||
|
||||
var reloadData = function(data) {
|
||||
/**
|
||||
* Update date range by finding date extremes
|
||||
*
|
||||
* ISSUE: chart.getExtreme() does not support getting Moment object out of box
|
||||
* TODO: Find a faster way to do this
|
||||
*/
|
||||
var setDateRangeToExtreme = function (allSeries) {
|
||||
if ($scope.dateRangeEnabled() && allSeries && allSeries.length > 0) {
|
||||
$scope.dateRange = {
|
||||
min: moment.min.apply(undefined, _.map(allSeries, function (series) {
|
||||
return moment.min(_.pluck(series.data, 'x'));
|
||||
})),
|
||||
max: moment.max.apply(undefined, _.map(allSeries, function (series) {
|
||||
return moment.max(_.pluck(series.data, 'x'));
|
||||
}))
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
var reloadData = function(data, options) {
|
||||
options = options || {};
|
||||
if (!data || ($scope.queryResult && $scope.queryResult.getData()) == null) {
|
||||
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
||||
} else {
|
||||
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
||||
var allSeries = $scope.queryResult.getChartData($scope.options.columnMapping);
|
||||
if (!options.preventSetExtreme) {
|
||||
setDateRangeToExtreme(allSeries);
|
||||
}
|
||||
var allSeries = $scope.queryResult.getChartData(
|
||||
$scope.options.columnMapping,
|
||||
$scope.dateRangeEnabled() ? $scope.dateRange : null
|
||||
);
|
||||
|
||||
_.each($scope.queryResult.getChartData($scope.options.columnMapping), function (s) {
|
||||
_.each(allSeries, function (series) {
|
||||
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 ($scope.options.seriesOptions && $scope.options.seriesOptions[series.name]) {
|
||||
additional = $scope.options.seriesOptions[series.name];
|
||||
if (!additional.name || additional.name == "") {
|
||||
additional.name = s.name;
|
||||
additional.name = series.name;
|
||||
}
|
||||
}
|
||||
$scope.chartSeries.push(_.extend(s, additional));
|
||||
$scope.chartSeries.push(_.extend(series, additional));
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -73,6 +109,22 @@
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||
reloadData(data);
|
||||
});
|
||||
|
||||
$scope.$watch('dateRange.min', function(minDateRange, oldMinDateRange) {
|
||||
if (!minDateRange.isSame(oldMinDateRange)) {
|
||||
reloadData(true, {
|
||||
preventSetExtreme: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('dateRange.max', function (maxDateRange, oldMaxDateRange) {
|
||||
if (!maxDateRange.isSame(oldMaxDateRange)) {
|
||||
reloadData(true, {
|
||||
preventSetExtreme: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}]
|
||||
};
|
||||
});
|
||||
@@ -81,178 +133,135 @@
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/chart_editor.html',
|
||||
scope: {
|
||||
queryResult: '=',
|
||||
options: '=?'
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
scope.palette = ColorPalette;
|
||||
|
||||
scope.seriesTypes = {
|
||||
'Line': 'line',
|
||||
'Column': 'column',
|
||||
'Area': 'area',
|
||||
'Scatter': 'scatter',
|
||||
'Pie': 'pie'
|
||||
};
|
||||
|
||||
scope.globalSeriesType = scope.visualization.options.globalSeriesType || 'column';
|
||||
scope.colors = _.extend({'Automatic': null}, ColorPalette);
|
||||
|
||||
scope.stackingOptions = {
|
||||
"None": "none",
|
||||
"Normal": "normal",
|
||||
"Percent": "percent"
|
||||
'Disabled': null,
|
||||
'Enabled': 'normal',
|
||||
'Percent': 'percent'
|
||||
};
|
||||
|
||||
scope.xAxisOptions = {
|
||||
"Date/Time": "datetime",
|
||||
"Linear": "linear",
|
||||
"Category": "category"
|
||||
scope.chartTypes = {
|
||||
'line': {name: 'Line', icon: 'line-chart'},
|
||||
'column': {name: 'Bar', icon: 'bar-chart'},
|
||||
'area': {name: 'Area', icon: 'area-chart'},
|
||||
'pie': {name: 'Pie', icon: 'pie-chart'},
|
||||
'scatter': {name: 'Scatter', icon: 'circle-o'}
|
||||
};
|
||||
|
||||
scope.xAxisType = "datetime";
|
||||
scope.stacking = "none";
|
||||
scope.chartTypeChanged = function() {
|
||||
_.each(scope.options.seriesOptions, function(options) {
|
||||
options.type = scope.options.globalSeriesType;
|
||||
});
|
||||
}
|
||||
|
||||
scope.xAxisScales = ['datetime', 'linear', 'logarithmic', 'category'];
|
||||
scope.yAxisScales = ['linear', 'logarithmic'];
|
||||
|
||||
scope.columnTypes = {
|
||||
"X": "x",
|
||||
"Y": "y",
|
||||
"Series": "series",
|
||||
"Unused": "unused"
|
||||
};
|
||||
|
||||
scope.series = [];
|
||||
|
||||
scope.columnTypeSelection = {};
|
||||
|
||||
var chartOptionsUnwatch = null,
|
||||
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;
|
||||
var refreshColumns = function() {
|
||||
scope.columns = scope.queryResult.getColumns();
|
||||
scope.columnNames = _.pluck(scope.columns, 'name');
|
||||
if (scope.columnNames.length > 0)
|
||||
_.each(_.difference(_.keys(scope.options.columnMapping), scope.columnNames), function(column) {
|
||||
delete scope.options.columnMapping[column];
|
||||
});
|
||||
};
|
||||
refreshColumns();
|
||||
|
||||
var refreshColumnsAndForm = function() {
|
||||
refreshColumns();
|
||||
if (!scope.queryResult.getData() || scope.queryResult.getData().length == 0 || scope.columns.length == 0)
|
||||
return;
|
||||
scope.form.yAxisColumns = _.intersection(scope.form.yAxisColumns, scope.columnNames);
|
||||
if (!_.contains(scope.columnNames, scope.form.xAxisColumn))
|
||||
scope.form.xAxisColumn = undefined;
|
||||
if (!_.contains(scope.columnNames, scope.form.groupby))
|
||||
scope.form.groupby = undefined;
|
||||
}
|
||||
|
||||
var refreshSeries = function() {
|
||||
var seriesNames = _.pluck(scope.queryResult.getChartData(scope.options.columnMapping), 'name');
|
||||
var existing = _.keys(scope.options.seriesOptions);
|
||||
_.each(_.difference(seriesNames, existing), function(name) {
|
||||
scope.options.seriesOptions[name] = {
|
||||
'type': scope.options.globalSeriesType,
|
||||
'yAxis': 0,
|
||||
};
|
||||
scope.form.seriesList.push(name);
|
||||
});
|
||||
_.each(_.difference(existing, seriesNames), function(name) {
|
||||
scope.form.seriesList = _.without(scope.form.seriesList, name)
|
||||
delete scope.options.seriesOptions[name];
|
||||
});
|
||||
};
|
||||
|
||||
scope.$watch('options.columnMapping', refreshSeries, true);
|
||||
|
||||
scope.$watch(function() {return [scope.queryResult.getId(), scope.queryResult.status]}, function(changed) {
|
||||
if (!changed[0]) {
|
||||
return;
|
||||
}
|
||||
refreshColumnsAndForm();
|
||||
refreshSeries();
|
||||
}, true);
|
||||
|
||||
scope.form = {
|
||||
yAxisColumns: [],
|
||||
seriesList: _.sortBy(_.keys(scope.options.seriesOptions), function(name) {
|
||||
return scope.options.seriesOptions[name].zIndex;
|
||||
})
|
||||
};
|
||||
|
||||
scope.$watchCollection('form.seriesList', function(value, old) {
|
||||
_.each(value, function(name, index) {
|
||||
scope.options.seriesOptions[name].zIndex = index;
|
||||
scope.options.seriesOptions[name].index = 0; // is this needed?
|
||||
});
|
||||
});
|
||||
|
||||
var setColumnRole = function(role, column) {
|
||||
scope.options.columnMapping[column] = role;
|
||||
}
|
||||
var unsetColumn = function(column) {
|
||||
setColumnRole('unused', column);
|
||||
}
|
||||
|
||||
scope.$watchCollection('form.yAxisColumns', function(value, old) {
|
||||
_.each(old, unsetColumn);
|
||||
_.each(value, _.partial(setColumnRole, 'y'));
|
||||
});
|
||||
|
||||
scope.$watch('form.xAxisColumn', function(value, old) {
|
||||
if (old !== undefined)
|
||||
unsetColumn(old);
|
||||
if (value !== undefined)
|
||||
setColumnRole('x', value);
|
||||
});
|
||||
|
||||
scope.$watch('form.groupby', function(value, old) {
|
||||
if (old !== undefined)
|
||||
unsetColumn(old)
|
||||
if (value !== undefined) {
|
||||
setColumnRole('series', value);
|
||||
}
|
||||
});
|
||||
|
||||
scope.$watch('visualization.type', function (visualizationType) {
|
||||
if (visualizationType == 'CHART') {
|
||||
if (scope.visualization.options.series.stacking === null) {
|
||||
scope.stacking = "none";
|
||||
} else if (scope.visualization.options.series.stacking === undefined) {
|
||||
scope.stacking = "normal";
|
||||
} else {
|
||||
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; });
|
||||
|
||||
// TODO: remove uneeded ones?
|
||||
if (scope.visualization.options.seriesOptions == undefined) {
|
||||
scope.visualization.options.seriesOptions = {
|
||||
type: scope.globalSeriesType
|
||||
};
|
||||
};
|
||||
|
||||
_.each(scope.series, function(s, i) {
|
||||
if (scope.visualization.options.seriesOptions[s] == undefined) {
|
||||
scope.visualization.options.seriesOptions[s] = {'type': scope.visualization.options.globalSeriesType, 'yAxis': 0};
|
||||
}
|
||||
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']];
|
||||
};
|
||||
|
||||
var initColumnMapping = function() {
|
||||
scope.columns = scope.queryResult.getColumns();
|
||||
|
||||
if (scope.visualization.options.columnMapping == undefined) {
|
||||
scope.visualization.options.columnMapping = {};
|
||||
}
|
||||
|
||||
scope.columnTypeSelection = scope.visualization.options.columnMapping;
|
||||
|
||||
_.each(scope.columns, function(column) {
|
||||
var definition = column.name.split("::"),
|
||||
definedColumns = _.keys(scope.visualization.options.columnMapping);
|
||||
|
||||
if (_.indexOf(definedColumns, column.name) != -1) {
|
||||
// Skip already defined columns.
|
||||
return;
|
||||
};
|
||||
|
||||
if (definition.length == 1) {
|
||||
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = 'unused';
|
||||
} else if (definition == 'multi-filter') {
|
||||
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = 'series';
|
||||
} else if (_.indexOf(_.values(scope.columnTypes), definition[1]) != -1) {
|
||||
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = definition[1];
|
||||
} else {
|
||||
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = 'unused';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
columnsWatch = scope.$watch('queryResult.getId()', function(id) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
initColumnMapping();
|
||||
refreshSeries();
|
||||
});
|
||||
|
||||
scope.$watchCollection('columnTypeSelection', function(selections) {
|
||||
_.each(scope.columnTypeSelection, function(type, name) {
|
||||
scope.visualization.options.columnMapping[name] = type;
|
||||
});
|
||||
|
||||
refreshSeries();
|
||||
});
|
||||
|
||||
chartOptionsUnwatch = scope.$watch("stacking", function (stacking) {
|
||||
if (stacking == "none") {
|
||||
scope.visualization.options.series.stacking = null;
|
||||
} else {
|
||||
scope.visualization.options.series.stacking = stacking;
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
scope.visualization.options.xAxis = scope.visualization.options.xAxis || {};
|
||||
scope.visualization.options.xAxis.type = xAxisType;
|
||||
});
|
||||
} else {
|
||||
if (chartOptionsUnwatch) {
|
||||
chartOptionsUnwatch();
|
||||
chartOptionsUnwatch = null;
|
||||
}
|
||||
|
||||
if (columnsWatch) {
|
||||
columnWatch();
|
||||
columnWatch = null;
|
||||
}
|
||||
|
||||
if (xAxisUnwatch) {
|
||||
xAxisUnwatch();
|
||||
xAxisUnwatch = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (scope.columnNames)
|
||||
_.each(scope.options.columnMapping, function(value, key) {
|
||||
if (scope.columnNames.length > 0 && !_.contains(scope.columnNames, key))
|
||||
return;
|
||||
if (value == 'x')
|
||||
scope.form.xAxisColumn = key;
|
||||
else if (value == 'y')
|
||||
scope.form.yAxisColumns.push(key);
|
||||
else if (value == 'series')
|
||||
scope.form.groupby = key;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,67 +1,87 @@
|
||||
(function () {
|
||||
var cohortVisualization = angular.module('redash.visualization');
|
||||
var cohortVisualization = angular.module('redash.visualization');
|
||||
|
||||
cohortVisualization.config(['VisualizationProvider', function(VisualizationProvider) {
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'COHORT',
|
||||
name: 'Cohort',
|
||||
renderTemplate: '<cohort-renderer options="visualization.options" query-result="queryResult"></cohort-renderer>'
|
||||
});
|
||||
}]);
|
||||
cohortVisualization.config(['VisualizationProvider', function (VisualizationProvider) {
|
||||
|
||||
cohortVisualization.directive('cohortRenderer', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
queryResult: '='
|
||||
},
|
||||
template: "",
|
||||
replace: false,
|
||||
link: function($scope, element, attrs) {
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
var editTemplate = '<cohort-editor></cohort-editor>';
|
||||
var defaultOptions = {
|
||||
'timeInterval': 'daily'
|
||||
};
|
||||
|
||||
if ($scope.queryResult.getData() == null) {
|
||||
|
||||
} else {
|
||||
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;
|
||||
}, 0);
|
||||
var data = _.map(grouped, function(values, date) {
|
||||
var row = [values[0].total];
|
||||
_.each(values, function(value) { row.push(value.value); });
|
||||
_.each(_.range(values.length, maxColumns), function() { row.push(null); });
|
||||
return row;
|
||||
});
|
||||
|
||||
var initialDate = moment(sortedData[0].date).toDate(),
|
||||
container = angular.element(element)[0];
|
||||
|
||||
Cornelius.draw({
|
||||
initialDate: initialDate,
|
||||
container: container,
|
||||
cohort: data,
|
||||
title: null,
|
||||
timeInterval: 'daily',
|
||||
labels: {
|
||||
time: 'Activation Day',
|
||||
people: 'Users'
|
||||
},
|
||||
formatHeaderLabel: function (i) {
|
||||
return "Day " + (i - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'COHORT',
|
||||
name: 'Cohort',
|
||||
renderTemplate: '<cohort-renderer options="visualization.options" query-result="queryResult"></cohort-renderer>',
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions: defaultOptions
|
||||
});
|
||||
}]);
|
||||
|
||||
}());
|
||||
cohortVisualization.directive('cohortRenderer', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
queryResult: '=',
|
||||
options: '='
|
||||
},
|
||||
template: "",
|
||||
replace: false,
|
||||
link: function ($scope, element, attrs) {
|
||||
$scope.options.timeInterval = $scope.options.timeInterval || 'daily';
|
||||
|
||||
var updateCohort = function () {
|
||||
if ($scope.queryResult.getData() === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}, 0);
|
||||
|
||||
var data = _.map(grouped, function (values, date) {
|
||||
var row = [values[0].total];
|
||||
_.each(values, function (value) {
|
||||
row.push(value.value);
|
||||
});
|
||||
_.each(_.range(values.length, maxColumns), function () {
|
||||
row.push(null);
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
var initialDate = moment(sortedData[0].date).toDate(),
|
||||
container = angular.element(element)[0];
|
||||
|
||||
Cornelius.draw({
|
||||
initialDate: initialDate,
|
||||
container: container,
|
||||
cohort: data,
|
||||
title: null,
|
||||
timeInterval: $scope.options.timeInterval,
|
||||
labels: {
|
||||
time: 'Time',
|
||||
people: 'Users',
|
||||
weekOf: 'Week of'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', updateCohort);
|
||||
$scope.$watch('options.timeInterval', updateCohort);
|
||||
}
|
||||
}
|
||||
});
|
||||
cohortVisualization.directive('cohortEditor', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/cohort_editor.html'
|
||||
}
|
||||
});
|
||||
|
||||
}());
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
'</counter-renderer>';
|
||||
|
||||
var editTemplate = '<counter-editor></counter-editor>';
|
||||
var defaultOptions = {};
|
||||
var defaultOptions = {
|
||||
counterColName: 'counter',
|
||||
rowNumber: 1,
|
||||
targetRowNumber: 1
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'COUNTER',
|
||||
@@ -27,23 +31,28 @@
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/counter.html',
|
||||
link: function($scope, elm, attrs) {
|
||||
$scope.visualization.options.rowNumber =
|
||||
$scope.visualization.options.rowNumber || 0;
|
||||
|
||||
$scope.$watch('[queryResult && queryResult.getData(), visualization.options]',
|
||||
function() {
|
||||
var queryData = $scope.queryResult.getData();
|
||||
if (queryData) {
|
||||
var rowNumber = $scope.visualization.options.rowNumber || 0;
|
||||
var counterColName = $scope.visualization.options.counterColName || 'counter';
|
||||
var targetColName = $scope.visualization.options.targetColName || 'target';
|
||||
var rowNumber = $scope.visualization.options.rowNumber - 1;
|
||||
var targetRowNumber = $scope.visualization.options.targetRowNumber - 1;
|
||||
var counterColName = $scope.visualization.options.counterColName;
|
||||
var targetColName = $scope.visualization.options.targetColName;
|
||||
|
||||
$scope.counterValue = queryData[rowNumber][counterColName];
|
||||
$scope.targetValue = queryData[rowNumber][targetColName];
|
||||
if (counterColName) {
|
||||
$scope.counterValue = queryData[rowNumber][counterColName];
|
||||
}
|
||||
|
||||
if ($scope.targetValue) {
|
||||
$scope.delta = $scope.counterValue - $scope.targetValue;
|
||||
$scope.trendPositive = $scope.delta >= 0;
|
||||
if (targetColName) {
|
||||
$scope.targetValue = queryData[targetRowNumber][targetColName];
|
||||
|
||||
if ($scope.targetValue) {
|
||||
$scope.delta = $scope.counterValue - $scope.targetValue;
|
||||
$scope.trendPositive = $scope.delta >= 0;
|
||||
}
|
||||
} else {
|
||||
$scope.targetValue = null;
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
43
rd_ui/app/scripts/visualizations/date_range_selector.js
Normal file
43
rd_ui/app/scripts/visualizations/date_range_selector.js
Normal file
@@ -0,0 +1,43 @@
|
||||
(function (window) {
|
||||
var module = angular.module('redash.visualization');
|
||||
|
||||
module.directive('dateRangeSelector', [function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
dateRange: "="
|
||||
},
|
||||
templateUrl: '/views/visualizations/date_range_selector.html',
|
||||
replace: true,
|
||||
controller: ['$scope', function ($scope) {
|
||||
$scope.dateRangeHuman = {
|
||||
min: null,
|
||||
max: null
|
||||
};
|
||||
|
||||
$scope.$watch('dateRange', function (dateRange, oldDateRange, scope) {
|
||||
scope.dateRangeHuman.min = dateRange.min.format('YYYY-MM-DD');
|
||||
scope.dateRangeHuman.max = dateRange.max.format('YYYY-MM-DD');
|
||||
});
|
||||
|
||||
$scope.$watch('dateRangeHuman', function (dateRangeHuman, oldDateRangeHuman, scope) {
|
||||
var newDateRangeMin = moment.utc(dateRangeHuman.min);
|
||||
var newDateRangeMax = moment.utc(dateRangeHuman.max);
|
||||
if (!newDateRangeMin ||
|
||||
!newDateRangeMax ||
|
||||
!newDateRangeMin.isValid() ||
|
||||
!newDateRangeMax.isValid() ||
|
||||
newDateRangeMin.isAfter(newDateRangeMax)) {
|
||||
// Prevent invalid date input
|
||||
// No need to show up a notification to user here, it will be too noisy.
|
||||
// Instead, simply preventing changes to the scope silently.
|
||||
scope.dateRangeHuman = oldDateRangeHuman;
|
||||
return;
|
||||
}
|
||||
scope.dateRange.min = newDateRangeMin;
|
||||
scope.dateRange.max = newDateRangeMax;
|
||||
}, true);
|
||||
}]
|
||||
}
|
||||
}]);
|
||||
})(window);
|
||||
@@ -79,14 +79,14 @@
|
||||
} else if (columnType === 'date') {
|
||||
columnDefinition.formatFunction = function (value) {
|
||||
if (value && moment.isMoment(value)) {
|
||||
return value.toDate().toLocaleDateString();
|
||||
return value.format(clientConfig.dateFormat);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
} else if (columnType === 'datetime') {
|
||||
columnDefinition.formatFunction = function (value) {
|
||||
if (value && moment.isMoment(value)) {
|
||||
return value.toDate().toLocaleString();
|
||||
return value.format(clientConfig.dateTimeFormat);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,10 @@ a.navbar-brand {
|
||||
margin-left: 0px !important;
|
||||
}
|
||||
|
||||
.navbar .fa {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
a.navbar-brand img {
|
||||
height: 40px;
|
||||
}
|
||||
@@ -109,6 +113,19 @@ a.navbar-brand img {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.form-group.required .control-label:after {
|
||||
content:"*";
|
||||
color:red;
|
||||
}
|
||||
|
||||
.form-group .help-block.error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-group.has-error .help-block.error {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* angular-growl */
|
||||
.growl {
|
||||
position: fixed;
|
||||
@@ -137,6 +154,23 @@ a.navbar-brand img {
|
||||
|
||||
}
|
||||
|
||||
/* Visualization Filters */
|
||||
|
||||
.filters-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter {
|
||||
width: 33%;
|
||||
padding-left: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.filter > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Gridster */
|
||||
|
||||
.gridster ul {
|
||||
@@ -179,6 +213,14 @@ li.widget:hover {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Support for Font-Awesome in btn-xs */
|
||||
|
||||
.btn-xs > .fa {
|
||||
font-size: 14px;
|
||||
top: 1px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Because of ng-repeat we add span between the .dropdown-menu element and the li element, so we had
|
||||
to add those CSS styles here. */
|
||||
|
||||
@@ -314,10 +356,56 @@ counter-renderer counter-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.box {
|
||||
font: 10px sans-serif;
|
||||
}
|
||||
|
||||
.box line,
|
||||
.box rect,
|
||||
.box circle {
|
||||
fill: #fff;
|
||||
stroke: #000;
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
.box .center {
|
||||
stroke-dasharray: 3,3;
|
||||
}
|
||||
|
||||
.box .outlier {
|
||||
fill: none;
|
||||
stroke: #000;
|
||||
}
|
||||
.axis text {
|
||||
font: 10px sans-serif;
|
||||
}
|
||||
|
||||
.axis path,
|
||||
.axis line {
|
||||
fill: none;
|
||||
stroke: #000;
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
|
||||
.grid-background {
|
||||
fill: #ddd;
|
||||
}
|
||||
.grid path,
|
||||
.grid line {
|
||||
fill: none;
|
||||
stroke: #fff;
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
.grid .minor line {
|
||||
stroke-opacity: .5;
|
||||
}
|
||||
.grid text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rd-widget-textbox p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.iframe-container {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -344,16 +432,54 @@ div.table-name {
|
||||
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
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.rd-hidden-xs {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.log-container {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
||||
.footer {
|
||||
color: #818d9f;
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #818d9f;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.col-table .missing-value {
|
||||
color: #b94a48;
|
||||
}
|
||||
|
||||
.col-table .super-small-input {
|
||||
padding-left: 3px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.col-table .ui-select-toggle, .col-table .ui-select-search {
|
||||
padding: 2px;
|
||||
padding-left: 5px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.clearable button {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
/* Immediately apply ng-cloak, instead of waiting for angular.js to load: */
|
||||
[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Smart Table */
|
||||
|
||||
.smart-table {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.smart-table .pagination {
|
||||
margin-bottom: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<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 ng-model="alert.query" 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)"
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
<edit-dashboard-form dashboard="dashboard" id="edit_dashboard_dialog"></edit-dashboard-form>
|
||||
|
||||
<div class="container">
|
||||
<p class="alert alert-warning" ng-if="dashboard.is_archived">This dashboard is archived and won't appear in the dashboards list or search results.</p>
|
||||
<h2 id="dashboard_title">
|
||||
{{dashboard.name}}
|
||||
|
||||
<span ng-if="!dashboard.is_archived">
|
||||
<button type="button" class="btn btn-default btn-xs" ng-class="{active: refreshEnabled}" tooltip="Enable/Disable Auto Refresh" ng-click="triggerRefresh()"><span class="glyphicon glyphicon-refresh"></span></button>
|
||||
<span ng-show="dashboard.canEdit()">
|
||||
<div class="btn-group" role="group" ng-show="dashboard.canEdit()">
|
||||
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" href="#edit_dashboard_dialog" tooltip="Edit Dashboard (Name/Layout)"><span
|
||||
class="glyphicon glyphicon-cog"></span></button>
|
||||
<button type="button" class="btn btn-default btn-xs" data-toggle="modal"
|
||||
href="#add_query_dialog" tooltip="Add Widget (Chart/Table)"><span class="glyphicon glyphicon-plus"></span>
|
||||
</button>
|
||||
</span>
|
||||
<butotn class="btn btn-danger btn-xs" ng-click="archiveDashboard()" ng-if="!dashboard.is_archived" tooltip="Archive"><i class="fa fa-archive"></i></butotn>
|
||||
</div>
|
||||
</span>
|
||||
</h2>
|
||||
<filters ng-if="dashboard.dashboard_filters_enabled"></filters>
|
||||
</div>
|
||||
@@ -54,7 +58,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default rd-widget-textbox" ng-if="type=='textbox'" ng-mouseenter="showControls = true" ng-mouseleave="showControls = false">
|
||||
<div class="panel panel-default rd-widget-textbox" ng-hide="widget.width == 0" ng-if="type=='textbox'" ng-mouseenter="showControls = true" ng-mouseleave="showControls = false">
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-11">
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<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">
|
||||
ng-if="input.type !== 'file'" accesskey="tab" placeholder="{{input.default}}">
|
||||
|
||||
<input name="input" type="file" class="form-control" ng-model="files[name]" ng-required="input.required"
|
||||
<input name="input" type="file" class="form-control" ng-model="files[name]" ng-required="input.required && !dataSource.options[name]"
|
||||
base-sixty-four-input
|
||||
ng-if="input.type === 'file'">
|
||||
</div>
|
||||
|
||||
5
rd_ui/app/views/directives/input_errors.html
Normal file
5
rd_ui/app/views/directives/input_errors.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div>
|
||||
<span class="help-block error" ng-if="errors.required">This field is required.</span>
|
||||
<span class="help-block error" ng-if="errors.minlength">This field is too short.</span>
|
||||
<span class="help-block error" ng-if="errors.email">This needs to be a valid email.</span>
|
||||
</div>
|
||||
@@ -17,4 +17,5 @@
|
||||
<a href="/admin/status" class="list-group-item">Status</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<p>
|
||||
<a href="/queries/new" class="btn btn-default">New Query</a>
|
||||
<button ng-show="currentUser.hasPermission('create_dashboard')" type="button" class="btn btn-default" data-toggle="modal" href="#new_dashboard_dialog">New Dashboard</button>
|
||||
<a href="/alerts/new" class="btn btn-default">New Alert</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<a ng-href="/dashboard/{{dashboard.slug}}" class="list-group-item" ng-repeat="dashboard in recentDashboards">
|
||||
{{dashboard.name}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="list-group col-md-6">
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-lg-2">
|
||||
<div class="rd-hidden-xs pull-right">
|
||||
<div class="pull-right">
|
||||
<query-source-link></query-source-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,12 +63,12 @@
|
||||
<div ng-class="editorSize">
|
||||
<div>
|
||||
<p>
|
||||
<button type="button" class="btn btn-primary btn-xs" ng-disabled="queryExecuting" ng-click="executeQuery()">
|
||||
<button type="button" class="btn btn-primary btn-xs" ng-disabled="queryExecuting" ng-click="executeQuery()" ng-if="canExecuteQuery">
|
||||
<span class="glyphicon glyphicon-play"></span> Execute
|
||||
</button>
|
||||
<query-formatter></query-formatter>
|
||||
<span class="pull-right">
|
||||
<button class="btn btn-xs btn-default rd-hidden-xs" ng-click="duplicateQuery()">
|
||||
<button class="btn btn-xs btn-default" ng-click="duplicateQuery()">
|
||||
<span class="glyphicon glyphicon-share-alt"></span> Fork
|
||||
</button>
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
</div>
|
||||
<hr ng-if="sourceMode">
|
||||
<div class="row">
|
||||
<div class="col-lg-3 rd-hidden-xs">
|
||||
<div class="col-lg-3">
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-user"></span>
|
||||
<span class="text-muted">Created By </span>
|
||||
@@ -148,7 +148,7 @@
|
||||
<p>
|
||||
<a class="btn btn-primary btn-sm" ng-disabled="queryExecuting || !queryResult.getData()" query-result-link target="_self">
|
||||
<span class="glyphicon glyphicon-cloud-download"></span>
|
||||
<span class="rd-hidden-xs">Download Dataset</span>
|
||||
<span>Download Dataset</span>
|
||||
</a>
|
||||
|
||||
<a class="btn btn-warning btn-sm" ng-disabled="queryExecuting" data-toggle="modal" data-target="#archive-confirmation-modal"
|
||||
@@ -213,7 +213,7 @@
|
||||
<span class="remove" ng-click="deleteVisualization($event, vis)" ng-show="canEdit"> ×</span>
|
||||
</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>
|
||||
<li ng-if="!sourceMode && canExecuteQuery" 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>
|
||||
</div>
|
||||
|
||||
16
rd_ui/app/views/users/list.html
Normal file
16
rd_ui/app/views/users/list.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li class="active">Users</li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p ng-if="currentUser.hasPermission('admin')">
|
||||
<a href="/users/new" class="btn btn-default"><i class="fa fa-plus"></i> New User</a>
|
||||
</p>
|
||||
|
||||
<smart-table rows="users" columns="gridColumns"
|
||||
config="gridConfig"
|
||||
class="table table-condensed table-hover"></smart-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
32
rd_ui/app/views/users/new.html
Normal file
32
rd_ui/app/views/users/new.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/users">Users</a></li>
|
||||
<li class="active">New</li>
|
||||
</ol>
|
||||
|
||||
<form class="form" name="userForm" ng-submit="saveUser()" novalidate>
|
||||
<div class="form-group required" show-errors>
|
||||
<label class="control-label">Name</label>
|
||||
<input type="text" name="name" class="form-control" ng-model="user.name" required/>
|
||||
<input-errors name="Name" errors="userForm.name.$error"/>
|
||||
</div>
|
||||
<div class="form-group required" show-errors>
|
||||
<label class="control-label">Email</label>
|
||||
<input name="email" type="email" class="form-control" ng-model="user.email" required/>
|
||||
<input-errors name="Email" errors="userForm.email.$error"/>
|
||||
</div>
|
||||
<div class="form-group required" show-errors>
|
||||
<label class="control-label">Password</label>
|
||||
<input class="form-control" type="password" name="password" ng-model="user.password" ng-minlength="6" required/>
|
||||
<input-errors name="Password" errors="userForm.password.$error"/>
|
||||
</div>
|
||||
<div class="form-group required" show-errors>
|
||||
<label class="control-label">Repeat Password</label>
|
||||
<input class="form-control" type="password" name="passwordRepeat" ng-model="user.passwordRepeat" compare-to="user.password"/>
|
||||
<span class="help-block error" ng-if="userForm.passwordRepeat.$error.compareTo">Passwords don't match.</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
76
rd_ui/app/views/users/show.html
Normal file
76
rd_ui/app/views/users/show.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li ng-if="currentUser.hasPermission('list_users')"><a href="/users">Users</a></li>
|
||||
<li ng-if="!currentUser.hasPermission('list_users')">Users</li>
|
||||
<li class="active">{{user.name}}</li>
|
||||
</ol>
|
||||
|
||||
<tabset>
|
||||
<tab heading="Profile" active="tabs['profile']" select="setTab('profile')">
|
||||
<p>
|
||||
<img src="{{user.gravatar_url}}"/>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>{{user.name}}</strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{user.email}}
|
||||
</p>
|
||||
</tab>
|
||||
<tab heading="API Key" ng-if="user.api_key" active="tabs['apiKey']" select="setTab('apiKey')">
|
||||
API Key:
|
||||
<input type="text" value="{{user.api_key}}" size="44" readonly/>
|
||||
</tab>
|
||||
<tab heading="Settings" ng-if="showSettings || currentUser.hasPermission('admin')" active="tabs['settings']" select="setTab('settings')">
|
||||
<div class="col-md-6">
|
||||
<form class="form" name="userSettingsForm" ng-submit="updateUser(userSettingsForm)" novalidate>
|
||||
<div class="form-group required" ng-if="showSettings" show-errors>
|
||||
<label class="control-label">Name</label>
|
||||
<input name="name" type="text" class="form-control" ng-model="user.name" required/>
|
||||
<input-errors errors="userSettingsForm.name.$error"/>
|
||||
</div>
|
||||
<div class="form-group required" ng-if="showSettings" show-errors>
|
||||
<label class="control-label">Email</label>
|
||||
<input name="email" type="email" class="form-control" ng-model="user.email" required/>
|
||||
<input-errors errors="userSettingsForm.email.$error"/>
|
||||
</div>
|
||||
<div class="checkbox" ng-if="currentUser.hasPermission('admin')">
|
||||
<label>
|
||||
<input type="checkbox" ng-model="user.admin"> Admin
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-gruup">
|
||||
<button class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</tab>
|
||||
<tab heading="Password" ng-if="showPasswordSettings" active="tabs['password']" select="setTab('password')">
|
||||
<div class="col-md-6">
|
||||
<form class="form" name="userPasswordForm" ng-submit="savePassword(userPasswordForm)" novalidate>
|
||||
<div class="form-group required" show-errors>
|
||||
<label class="control-label">Current Password</label>
|
||||
<input name="currentPassword" class="form-control" type="password" ng-model="password.current" required/>
|
||||
<input-errors name="Password" errors="userPasswordForm.currentPassword.$error"/>
|
||||
</div>
|
||||
<div class="form-group required" show-errors>
|
||||
<label class="control-label">New Password</label>
|
||||
<input name="newPassword" class="form-control" type="password" ng-model="password.new" ng-minlength="6" required/>
|
||||
<input-errors name="Password" errors="userPasswordForm.newPassword.$error"/>
|
||||
</div>
|
||||
<div class="form-group required" show-errors>
|
||||
<label class="control-label">Repeat New Password</label>
|
||||
<input name="passwordRepeat" class="form-control" type="password" ng-model="password.newRepeat" compare-to="password.new"/>
|
||||
<span class="help-block error" ng-if="userPasswordForm.passwordRepeat.$error.compareTo">Passwords don't match.</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</tab>
|
||||
</tabset>
|
||||
|
||||
</div>
|
||||
2
rd_ui/app/views/visualizations/boxplot.html
Normal file
2
rd_ui/app/views/visualizations/boxplot.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<boxplot>
|
||||
</boxplot>
|
||||
15
rd_ui/app/views/visualizations/boxplot_editor.html
Normal file
15
rd_ui/app/views/visualizations/boxplot_editor.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">X Axis Label</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="text" ng-model="visualization.options.xAxisLabel" class="form-control"></input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Y Axis Label</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="text" ng-model="visualization.options.yAxisLabel" class="form-control"></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
8
rd_ui/app/views/visualizations/chart.html
Normal file
8
rd_ui/app/views/visualizations/chart.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<div>
|
||||
<section class="clearfix">
|
||||
<date-range-selector ng-if="dateRangeEnabled()" date-range='dateRange' class='pull-right'></date-range-selector>
|
||||
</section>
|
||||
<section>
|
||||
<chart options='chartOptions' series='chartSeries' class='graph'></chart>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1,151 +1,236 @@
|
||||
<div class="form-horizontal">
|
||||
<div class="panel panel-default">
|
||||
<form class="form-horizontal" name="chartEditor">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group row">
|
||||
|
||||
<label class="control-label col-sm-5">Chart Type</label>
|
||||
<div class="col-sm-7" ng-if="chartTypes"><!--the if is a weird workaround-->
|
||||
<ui-select ng-model="options.globalSeriesType" on-select="chartTypeChanged()">
|
||||
<ui-select-match placeholder="Choose chart type..."><i class="fa fa-{{$select.selected.value.icon}}"></i> {{$select.selected.value.name}}</ui-select-match>
|
||||
<ui-select-choices repeat="info.chartType as (chartType, info) in chartTypes">
|
||||
<div><i class="fa fa-{{info.value.icon}}"></i><span> </span><span ng-bind-html="info.value.name | highlight: $select.search"></span></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group row">
|
||||
|
||||
<label class="control-label col-sm-5">Stacking</label>
|
||||
|
||||
<div class="col-sm-7" ng-if="stackingOptions"><!--the if is a weird workaround-->
|
||||
<ui-select ng-model="options.series.stacking" ng-disabled="['line', 'area', 'column'].indexOf(options.globalSeriesType) == -1">
|
||||
<ui-select-match placeholder="Choose Stacking...">{{$select.selected.key | capitalize}}</ui-select-match>
|
||||
<ui-select-choices repeat="value.value as (key, value) in stackingOptions">
|
||||
<div ng-bind-html="value.key | highlight: $select.search"></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group row" ng-class="{'has-error': chartEditor.xAxisColumn.$invalid}">
|
||||
|
||||
<label class="control-label col-sm-5">X Column</label>
|
||||
|
||||
<div class="col-sm-7">
|
||||
<ui-select name="xAxisColumn" required ng-model="form.xAxisColumn">
|
||||
<ui-select-match placeholder="Choose column...">{{$select.selected}}</ui-select-match>
|
||||
<ui-select-choices repeat="column in columnNames | remove:form.yAxisColumns | remove:form.groupby">
|
||||
<span ng-bind-html="column | highlight: $select.search"></span><span> </span><small class="text-muted" ng-bind="columns[column].type"></small>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group row">
|
||||
|
||||
<label class="control-label col-sm-5">Group by</label>
|
||||
|
||||
<div class="col-sm-7">
|
||||
|
||||
<ui-select name="groupby" ng-model="form.groupby" class="clearable">
|
||||
<ui-select-match allow-clear="true" placeholder="Choose column...">{{$select.selected}}</ui-select-match>
|
||||
<ui-select-choices repeat="column in columnNames | remove:form.yAxisColumns | remove:form.xAxisColumn">
|
||||
<span ng-bind-html="column | highlight: $select.search"></span><span> </span><small class="text-muted" ng-bind="columns[column].type"></small>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<!-- not using regular validation (chartEditor.yAxisColumns.$invalid) due to a bug in ui-select with multiple choices-->
|
||||
<div class="form-group row" ng-class="{'has-error': !form.yAxisColumns || form.yAxisColumns.length == 0}">
|
||||
|
||||
<label class="control-label col-sm-5">Y Columns</label>
|
||||
|
||||
<div class="col-sm-7">
|
||||
|
||||
<ui-select multiple name="yAxisColumns" required ng-model="form.yAxisColumns">
|
||||
<ui-select-match placeholder="Choose columns...">{{$item}}</ui-select-match>
|
||||
<ui-select-choices repeat="column in columnNames | remove:form.groupby | remove:form.xAxisColumn">
|
||||
<span ng-bind-html="column | highlight: $select.search"></span><span> </span><small class="text-muted" ng-bind="columns[column].type"></small>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">X Axis</h3>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-2">Stacking</label>
|
||||
<div class="row">
|
||||
|
||||
<div class="col-sm-10">
|
||||
<select required ng-model="stacking"
|
||||
ng-options="value as key for (key, value) in stackingOptions"
|
||||
class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-2">X Axis Type</label>
|
||||
|
||||
<div class="col-sm-10">
|
||||
<select required ng-model="xAxisType" ng-options="value as key for (key, value) in xAxisOptions"
|
||||
class="form-control"></select>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group row">
|
||||
<label class="control-label col-sm-3">Scale</label>
|
||||
<div class="col-sm-9">
|
||||
<ui-select ng-model="options.xAxis.type">
|
||||
<ui-select-match placeholder="Choose Scale...">{{$select.selected | capitalize}}</ui-select-match>
|
||||
<ui-select-choices repeat="scaleType in xAxisScales">
|
||||
<div ng-bind-html="scaleType | capitalize | highlight: $select.search"></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-2">Series Type</label>
|
||||
|
||||
<div class="col-sm-10">
|
||||
<select required ng-options="value as key for (key, value) in seriesTypes"
|
||||
ng-model="globalSeriesType" class="form-control"></select>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group row">
|
||||
<label class="control-label col-sm-8">Sort Values</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="checkbox" ng-model="options.sortX">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-2">y Axis min</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
<div class="col-sm-10">
|
||||
<input name="yAxisMin" type="number" class="form-control"
|
||||
ng-model="visualization.options.yAxis.min"
|
||||
placeholder="Auto">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group row">
|
||||
<label class="control-label col-sm-3">Name</label>
|
||||
<div class="col-sm-9">
|
||||
<input ng-model="options.xAxis.title.text" type="text" class="form-control"></input>
|
||||
</div>
|
||||
</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 class="col-md-6">
|
||||
<div class="form-group row">
|
||||
<label class="control-label col-sm-8">Show Labels</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="checkbox" ng-model="options.xAxis.labels.enabled">
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-repeat="yAxis in options.yAxis" class="col-md-3">
|
||||
<div class="panel panel-default">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item active">
|
||||
Columns Mapping
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<div class="form-group" ng-repeat="column in columns">
|
||||
<label class="control-label col-sm-4">{{column.name}}</label>
|
||||
|
||||
<div class="col-sm-8">
|
||||
<select ng-options="value as key for (key, value) in columnTypes" class="form-control"
|
||||
ng-model="columnTypeSelection[column.name]"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{{$index == 0 ? 'Left' : 'Right'}} Y Axis</h3>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6" ng-if="series.length > 0">
|
||||
<div class="list-group" ng-repeat="seriesName in series">
|
||||
<div class="list-group-item active">
|
||||
{{seriesName}}
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">Type</label>
|
||||
|
||||
<div class="col-sm-9">
|
||||
<select required ng-model="visualization.options.seriesOptions[seriesName].type"
|
||||
ng-options="value as key for (key, value) in seriesTypes"
|
||||
class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">zIndex</label>
|
||||
|
||||
<div class="col-sm-9">
|
||||
<select required ng-model="visualization.options.seriesOptions[seriesName].zIndex"
|
||||
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">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>
|
||||
|
||||
<div class="col-sm-9">
|
||||
<select required ng-model="visualization.options.seriesOptions[seriesName].yAxis"
|
||||
ng-options="o[0] as o[1] for o in yAxes"
|
||||
class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">Name</label>
|
||||
|
||||
<div class="col-sm-9">
|
||||
<input name="seriesName" type="text" class="form-control"
|
||||
ng-model="visualization.options.seriesOptions[seriesName].name"
|
||||
placeholder="{{seriesName}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">Color</label>
|
||||
|
||||
<div class="col-sm-9">
|
||||
<select class="form-control" ng-model="visualization.options.seriesOptions[seriesName].color" ng-options="val as key for (key,val) in palette"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group row">
|
||||
<label class="control-label col-sm-3">Scale</label>
|
||||
<div class="col-sm-9">
|
||||
<ui-select ng-model="yAxis.type">
|
||||
<ui-select-match placeholder="Choose Scale...">{{$select.selected | capitalize}}</ui-select-match>
|
||||
<ui-select-choices repeat="scaleType in yAxisScales">
|
||||
<div ng-bind-html="scaleType | capitalize | highlight: $select.search"></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label class="control-label col-sm-3">Name</label>
|
||||
<div class="col-sm-9">
|
||||
<input ng-model="yAxis.title.text" type="text" class="form-control"></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
|
||||
<div class="panel panel-default">
|
||||
|
||||
<div class="panel-heading"><h3 class="panel-title">Series Options</h3></div>
|
||||
|
||||
<div>
|
||||
<table class="table table-condensed col-table">
|
||||
<thead>
|
||||
<th>zIndex</th>
|
||||
<th>Column</th>
|
||||
<th>Left Y Axis</th>
|
||||
<th>Right Y Axis</th>
|
||||
<th>Label</th>
|
||||
<th>Color</th>
|
||||
<th>Type</th>
|
||||
</thead>
|
||||
<tbody ui-sortable ng-model="form.seriesList">
|
||||
<tr ng-repeat="name in form.seriesList">
|
||||
<td style="cursor: move;"><i class="fa fa-arrows-v"></i> <span ng-bind="options.seriesOptions[name].zIndex + 1"></span></td>
|
||||
<td>{{name}}</td>
|
||||
<td>
|
||||
<input type="radio" ng-value="0" ng-model="options.seriesOptions[name].yAxis">
|
||||
</td>
|
||||
<td>
|
||||
<input type="radio" ng-value="1" ng-model="options.seriesOptions[name].yAxis">
|
||||
</td>
|
||||
<td style="padding: 3px; width: 140px;">
|
||||
<input placeholder="{{name}}" class="form-control input-sm super-small-input" type="text" ng-model="options.seriesOptions[name].name">
|
||||
</td>
|
||||
<td style="padding: 3px; width: 35px;">
|
||||
<ui-select ng-model="options.seriesOptions[name].color">
|
||||
<ui-select-match><color-box color="$select.selected.value"></color-box></ui-select-match>
|
||||
<ui-select-choices repeat="color.value as (key, color) in colors">
|
||||
<color-box color="color.value"></color-box><span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</td>
|
||||
<td style="padding: 3px; width: 105px;">
|
||||
<ui-select ng-model="options.seriesOptions[name].type">
|
||||
<ui-select-match placeholder="Chart Type"><i class="fa fa-{{$select.selected.value.icon}}"></i> {{$select.selected.value.name}}</ui-select-match>
|
||||
<ui-select-choices repeat="info.chartType as (chartType, info) in chartTypes">
|
||||
<div><i class="fa fa-{{info.value.icon}}"></i><span> </span><span ng-bind-html="info.value.name | highlight: $select.search"></span></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
<div class="form-group">
|
||||
<label class="control-label">Time Label</label>
|
||||
<input type="text" class="form-control" ng-model="cohortOptions.timeLabel">
|
||||
<label class="control-label">People Label</label>
|
||||
<input type="text" class="form-control" ng-model="cohortOptions.peopleLabel">
|
||||
|
||||
<label class="control-label">Bucket Column</label>
|
||||
<select ng-model="bucket_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
|
||||
<label class="control-label">Bucket Total Value Column</label>
|
||||
<select ng-model="total_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
|
||||
<label class="control-label">Day Number Column</label>
|
||||
<select ng-model="value_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
|
||||
<label class="control-label">Day Value Column</label>
|
||||
<select ng-model="day_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
|
||||
</div>
|
||||
<label class="control-label">Time Interval</label>
|
||||
<select class="form-control" ng-model="visualization.options.timeInterval">
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Row Number</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="number" ng-model="visualization.options.rowNumber" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Counter Column Name</label>
|
||||
<label class="col-lg-6">Counter Value Column Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.counterColName" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Target Column Name</label>
|
||||
<label class="col-lg-6">Counter Value Row Number</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.targetColName" class="form-control"></select>
|
||||
<input type="number" ng-model="visualization.options.rowNumber" min="1" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Target Value Column Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.targetColName" class="form-control">
|
||||
<option value="">No target value</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="visualization.options.targetColName">
|
||||
<label class="col-lg-6">Target Value Row Number</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="number" ng-model="visualization.options.targetRowNumber" min="1" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
8
rd_ui/app/views/visualizations/date_range_selector.html
Normal file
8
rd_ui/app/views/visualizations/date_range_selector.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<div>
|
||||
<span>
|
||||
From <input type="date" ng-model="dateRangeHuman.min">
|
||||
</span>
|
||||
<span>
|
||||
To <input type="date" ng-model="dateRangeHuman.max">
|
||||
</span>
|
||||
</div>
|
||||
@@ -1,25 +1,42 @@
|
||||
<div>
|
||||
<span ng-click="openEditor=!openEditor" class="details-toggle" ng-class="{open: openEditor}">Edit</span>
|
||||
|
||||
<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}}">
|
||||
<form ng-show="openEditor" role="form" name="visForm" ng-submit="submit()" class="form-horizontal">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group row">
|
||||
<label class="control-label col-sm-5">Visualization Type</label>
|
||||
|
||||
<div class="col-sm-7">
|
||||
<select required ng-model="visualization.type" ng-options="value as key for (key, value) in visTypes" class="form-control" ng-change="typeChanged()"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group row">
|
||||
<label class="control-label col-sm-5">Name</label>
|
||||
<div class="col-sm-7">
|
||||
<input name="name" type="text" class="form-control" ng-model="visualization.name" placeholder="{{visualization.type | capitalize}}">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label">Visualization Type</label>
|
||||
<select required ng-model="visualization.type" ng-options="value as key for (key, value) in visTypes" class="form-control" ng-change="typeChanged()"></select>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<visualization-options-editor></visualization-options-editor>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<visualization-options-editor></visualization-options-editor>
|
||||
|
||||
<div class="form-group" ng-if="editRawOptions">
|
||||
<label class="control-label">Advanced</label>
|
||||
<textarea json-text ng-model="visualization.options" class="form-control" rows="10"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group text-center">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<div class="well well-sm" ng-show="filters">
|
||||
<div ng-repeat="filter in filters">
|
||||
{{filter.friendlyName}}:
|
||||
<select ui-select2='select2Options' ng-model="filter.current" ng-multiple="{{filter.multiple}}">
|
||||
<option ng-repeat="value in filter.values" value="{{value}}">{{value}}</option>
|
||||
</select>
|
||||
<div class="well well-sm filters-container" ng-show="filters">
|
||||
<div class="filter" ng-repeat="filter in filters">
|
||||
<ui-select ng-model="filter.current" ng-if="!filter.multiple">
|
||||
<ui-select-match placeholder="Select value for {{filter.friendlyName}}...">{{filter.friendlyName}}: {{$select.selected}}</ui-select-match>
|
||||
<ui-select-choices repeat="value in filter.values | filter: $select.search">
|
||||
{{value}}
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
|
||||
<ui-select ng-model="filter.current" multiple ng-if="filter.multiple">
|
||||
<ui-select-match placeholder="Select value for {{filter.friendlyName}}...">{{filter.friendlyName}}: {{$item}}</ui-select-match>
|
||||
<ui-select-choices repeat="value in filter.values | filter: $select.search">
|
||||
{{value}}
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"jquery": "1.9.1",
|
||||
"bootstrap": "3.0.0",
|
||||
"es5-shim": "2.0.8",
|
||||
"angular-moment": "0.2.0",
|
||||
"moment": "2.1.0",
|
||||
"angular-moment": "0.10.3",
|
||||
"moment": "~2.8.0",
|
||||
"codemirror": "4.8.0",
|
||||
"highcharts": "3.0.10",
|
||||
"underscore": "1.5.1",
|
||||
@@ -19,19 +19,22 @@
|
||||
"cornelius": "https://github.com/restorando/cornelius.git",
|
||||
"gridster": "0.2.0",
|
||||
"mousetrap": "~1.4.6",
|
||||
"angular-ui-select2": "~0.0.5",
|
||||
"jquery-ui": "~1.10.4",
|
||||
"underscore.string": "~2.3.3",
|
||||
"marked": "~0.3.2",
|
||||
"bucky": "~0.2.6",
|
||||
"pace": "~0.5.1",
|
||||
"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"
|
||||
"angular-base64-upload": "~0.1.11",
|
||||
"angular-ui-select": "~0.13.2",
|
||||
"angular-bootstrap-show-errors": "~2.3.0",
|
||||
"angular-sanitize": "1.2.18",
|
||||
"d3": "3.5.6",
|
||||
"angular-ui-sortable": "~0.13.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"angular-mocks": "1.2.18",
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"grunt-contrib-copy": "^0.5.0",
|
||||
"grunt-contrib-cssmin": "^0.9.0",
|
||||
"grunt-contrib-htmlmin": "^0.3.0",
|
||||
"grunt-contrib-imagemin": "^0.7.0",
|
||||
"grunt-contrib-jshint": "^0.10.0",
|
||||
"grunt-contrib-uglify": "^0.4.0",
|
||||
"grunt-contrib-watch": "^0.6.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
featureFlags = [];
|
||||
clientConfig = {};
|
||||
currentUser = {
|
||||
id: 1,
|
||||
name: 'John Mock',
|
||||
|
||||
@@ -7,7 +7,7 @@ from flask_mail import Mail
|
||||
from redash import settings
|
||||
from redash.query_runner import import_query_runners
|
||||
|
||||
__version__ = '0.7.0'
|
||||
__version__ = '0.8.3'
|
||||
|
||||
|
||||
def setup_logging():
|
||||
@@ -38,3 +38,6 @@ 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)
|
||||
|
||||
from redash.version_check import reset_new_version_status
|
||||
reset_new_version_status()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
from flask_admin.contrib.peewee import ModelView
|
||||
from flask.ext.admin import Admin
|
||||
from flask.ext.admin.base import MenuLink
|
||||
from flask_admin.contrib.peewee.form import CustomModelConverter
|
||||
from flask_admin.form.widgets import DateTimePickerWidget
|
||||
from playhouse.postgres_ext import ArrayField, DateTimeTZField
|
||||
@@ -8,7 +9,6 @@ from wtforms import fields
|
||||
from wtforms.widgets import TextInput
|
||||
|
||||
from redash import models
|
||||
from redash import query_runner
|
||||
from redash.permissions import require_permission
|
||||
|
||||
|
||||
@@ -39,16 +39,6 @@ class JSONTextAreaField(fields.TextAreaField):
|
||||
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):
|
||||
@@ -67,6 +57,7 @@ class PgModelConverter(CustomModelConverter):
|
||||
|
||||
|
||||
class BaseModelView(ModelView):
|
||||
column_display_pk = True
|
||||
model_form_converter = PgModelConverter
|
||||
|
||||
@require_permission('admin')
|
||||
@@ -74,17 +65,6 @@ class BaseModelView(ModelView):
|
||||
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',)
|
||||
|
||||
@@ -100,10 +80,12 @@ class DashboardModelView(BaseModelView):
|
||||
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))
|
||||
logout_link = MenuLink('Logout', '/logout', 'logout')
|
||||
|
||||
for m in (models.Visualization, models.Widget, models.ActivityLog, models.Group, models.Event):
|
||||
admin.add_view(BaseModelView(m))
|
||||
|
||||
admin.add_link(logout_link)
|
||||
@@ -52,6 +52,7 @@ def hmac_load_user_from_request(request):
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_user_from_api_key(api_key, query_id):
|
||||
if not api_key:
|
||||
return None
|
||||
@@ -67,8 +68,19 @@ def get_user_from_api_key(api_key, query_id):
|
||||
|
||||
return user
|
||||
|
||||
def api_key_load_user_from_request(request):
|
||||
|
||||
def get_api_key_from_request(request):
|
||||
api_key = request.args.get('api_key', None)
|
||||
|
||||
if api_key is None and request.headers.get('Authorization'):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
api_key = auth_header.replace('Key ', '', 1)
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
def api_key_load_user_from_request(request):
|
||||
api_key = get_api_key_from_request(request)
|
||||
query_id = request.view_args.get('query_id', None)
|
||||
|
||||
user = get_user_from_api_key(api_key, query_id)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
ONE_YEAR = 60 * 60 * 24 * 365.25
|
||||
|
||||
headers = {
|
||||
'Cache-Control': 'max-age=%d' % ONE_YEAR
|
||||
}
|
||||
@@ -1,721 +0,0 @@
|
||||
"""
|
||||
Flask-restful based API implementation for re:dash.
|
||||
|
||||
Currently the Flask server is used to serve the static assets (and the Angular.js app),
|
||||
but this is only due to configuration issues and temporary.
|
||||
"""
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import cStringIO
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import render_template, send_from_directory, make_response, request, jsonify, redirect, \
|
||||
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 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'])
|
||||
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('/')
|
||||
@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
|
||||
|
||||
user = {
|
||||
'gravatar_url': gravatar_url,
|
||||
'id': current_user.id,
|
||||
'name': current_user.name,
|
||||
'email': current_user.email,
|
||||
'groups': current_user.groups,
|
||||
'permissions': current_user.permissions
|
||||
}
|
||||
|
||||
features = {
|
||||
'clientSideMetrics': settings.CLIENT_SIDE_METRICS
|
||||
}
|
||||
|
||||
return render_template("index.html", user=json.dumps(user), name=settings.NAME,
|
||||
features=json.dumps(features),
|
||||
analytics=settings.ANALYTICS)
|
||||
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated():
|
||||
return redirect(request.args.get('next') or '/')
|
||||
|
||||
if not settings.PASSWORD_LOGIN_ENABLED:
|
||||
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':
|
||||
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_saml_login=settings.SAML_LOGIN_ENABLED)
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
logout_user()
|
||||
session.pop('openid', None)
|
||||
|
||||
return redirect('/login')
|
||||
|
||||
@app.route('/status.json')
|
||||
@login_required
|
||||
@require_permission('admin')
|
||||
def status_api():
|
||||
status = get_status()
|
||||
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@app.route('/api/queries/format', methods=['POST'])
|
||||
@login_required
|
||||
def format_sql_query():
|
||||
arguments = request.get_json(force=True)
|
||||
query = arguments.get("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 = [login_required]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(BaseResource, self).__init__(*args, **kwargs)
|
||||
self._user = None
|
||||
|
||||
@property
|
||||
def current_user(self):
|
||||
return current_user._get_current_object()
|
||||
|
||||
def dispatch_request(self, *args, **kwargs):
|
||||
with statsd_client.timer('requests.{}.{}'.format(request.endpoint, request.method.lower())):
|
||||
response = super(BaseResource, self).dispatch_request(*args, **kwargs)
|
||||
return response
|
||||
|
||||
|
||||
class EventAPI(BaseResource):
|
||||
def post(self):
|
||||
events_list = request.get_json(force=True)
|
||||
for event in events_list:
|
||||
record_event.delay(event)
|
||||
|
||||
|
||||
api.add_resource(EventAPI, '/api/events', endpoint='events')
|
||||
|
||||
|
||||
class MetricsAPI(BaseResource):
|
||||
def post(self):
|
||||
for stat_line in request.data.split():
|
||||
stat, value = stat_line.split(':')
|
||||
statsd_client._send_stat('client.{}'.format(stat), value, 1)
|
||||
|
||||
return "OK."
|
||||
|
||||
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):
|
||||
def get(self):
|
||||
dashboards = [d.to_dict() for d in
|
||||
models.Dashboard.select().where(models.Dashboard.is_archived==False)]
|
||||
|
||||
return dashboards
|
||||
|
||||
@require_permission('create_dashboard')
|
||||
def post(self):
|
||||
dashboard_properties = request.get_json(force=True)
|
||||
dashboard = models.Dashboard(name=dashboard_properties['name'],
|
||||
user=self.current_user,
|
||||
layout='[]')
|
||||
dashboard.save()
|
||||
return dashboard.to_dict()
|
||||
|
||||
|
||||
class DashboardAPI(BaseResource):
|
||||
def get(self, dashboard_slug=None):
|
||||
try:
|
||||
dashboard = models.Dashboard.get_by_slug(dashboard_slug)
|
||||
except models.Dashboard.DoesNotExist:
|
||||
abort(404)
|
||||
|
||||
return dashboard.to_dict(with_widgets=True)
|
||||
|
||||
@require_permission('edit_dashboard')
|
||||
def post(self, dashboard_slug):
|
||||
dashboard_properties = request.get_json(force=True)
|
||||
# TODO: either convert all requests to use slugs or ids
|
||||
dashboard = models.Dashboard.get_by_id(dashboard_slug)
|
||||
dashboard.layout = dashboard_properties['layout']
|
||||
dashboard.name = dashboard_properties['name']
|
||||
dashboard.save()
|
||||
|
||||
return dashboard.to_dict(with_widgets=True)
|
||||
|
||||
@require_permission('edit_dashboard')
|
||||
def delete(self, dashboard_slug):
|
||||
dashboard = models.Dashboard.get_by_slug(dashboard_slug)
|
||||
dashboard.is_archived = True
|
||||
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')
|
||||
|
||||
|
||||
class WidgetListAPI(BaseResource):
|
||||
@require_permission('edit_dashboard')
|
||||
def post(self):
|
||||
widget_properties = request.get_json(force=True)
|
||||
widget_properties['options'] = json.dumps(widget_properties['options'])
|
||||
widget_properties.pop('id', None)
|
||||
widget_properties['dashboard'] = widget_properties.pop('dashboard_id')
|
||||
widget_properties['visualization'] = widget_properties.pop('visualization_id')
|
||||
widget = models.Widget(**widget_properties)
|
||||
widget.save()
|
||||
|
||||
layout = json.loads(widget.dashboard.layout)
|
||||
new_row = True
|
||||
|
||||
if len(layout) == 0 or widget.width == 2:
|
||||
layout.append([widget.id])
|
||||
elif len(layout[-1]) == 1:
|
||||
neighbour_widget = models.Widget.get(models.Widget.id == layout[-1][0])
|
||||
if neighbour_widget.width == 1:
|
||||
layout[-1].append(widget.id)
|
||||
new_row = False
|
||||
else:
|
||||
layout.append([widget.id])
|
||||
else:
|
||||
layout.append([widget.id])
|
||||
|
||||
widget.dashboard.layout = json.dumps(layout)
|
||||
widget.dashboard.save()
|
||||
|
||||
return {'widget': widget.to_dict(), 'layout': layout, 'new_row': new_row}
|
||||
|
||||
|
||||
class WidgetAPI(BaseResource):
|
||||
@require_permission('edit_dashboard')
|
||||
def delete(self, widget_id):
|
||||
widget = models.Widget.get(models.Widget.id == widget_id)
|
||||
widget.delete_instance()
|
||||
|
||||
api.add_resource(WidgetListAPI, '/api/widgets', endpoint='widgets')
|
||||
api.add_resource(WidgetAPI, '/api/widgets/<int:widget_id>', endpoint='widget')
|
||||
|
||||
|
||||
class QuerySearchAPI(BaseResource):
|
||||
@require_permission('view_query')
|
||||
def get(self):
|
||||
term = request.args.get('q', '')
|
||||
|
||||
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', 'last_modified_by']:
|
||||
query_def.pop(field, None)
|
||||
|
||||
query_def['user'] = self.current_user
|
||||
query_def['data_source'] = query_def.pop('data_source_id')
|
||||
query = models.Query(**query_def)
|
||||
query.save()
|
||||
|
||||
return query.to_dict()
|
||||
|
||||
@require_permission('view_query')
|
||||
def get(self):
|
||||
return [q.to_dict(with_stats=True) for q in models.Query.all_queries()]
|
||||
|
||||
|
||||
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', 'last_modified_by']:
|
||||
query_def.pop(field, None)
|
||||
|
||||
if 'latest_query_data_id' in query_def:
|
||||
query_def['latest_query_data'] = query_def.pop('latest_query_data_id')
|
||||
|
||||
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)
|
||||
|
||||
return query.to_dict(with_visualizations=True)
|
||||
|
||||
@require_permission('view_query')
|
||||
def get(self, query_id):
|
||||
q = models.Query.get(models.Query.id == query_id)
|
||||
if q:
|
||||
return q.to_dict(with_visualizations=True)
|
||||
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')
|
||||
|
||||
|
||||
class VisualizationListAPI(BaseResource):
|
||||
@require_permission('edit_query')
|
||||
def post(self):
|
||||
kwargs = request.get_json(force=True)
|
||||
kwargs['options'] = json.dumps(kwargs['options'])
|
||||
kwargs['query'] = kwargs.pop('query_id')
|
||||
|
||||
vis = models.Visualization(**kwargs)
|
||||
vis.save()
|
||||
|
||||
return vis.to_dict(with_query=False)
|
||||
|
||||
|
||||
class VisualizationAPI(BaseResource):
|
||||
@require_permission('edit_query')
|
||||
def post(self, visualization_id):
|
||||
kwargs = request.get_json(force=True)
|
||||
if 'options' in kwargs:
|
||||
kwargs['options'] = json.dumps(kwargs['options'])
|
||||
kwargs.pop('id', None)
|
||||
kwargs.pop('query_id', None)
|
||||
|
||||
update = models.Visualization.update(**kwargs).where(models.Visualization.id == visualization_id)
|
||||
update.execute()
|
||||
|
||||
vis = models.Visualization.get_by_id(visualization_id)
|
||||
|
||||
return vis.to_dict(with_query=False)
|
||||
|
||||
@require_permission('edit_query')
|
||||
def delete(self, visualization_id):
|
||||
vis = models.Visualization.get(models.Visualization.id == visualization_id)
|
||||
vis.delete_instance()
|
||||
|
||||
api.add_resource(VisualizationListAPI, '/api/visualizations', endpoint='visualizations')
|
||||
api.add_resource(VisualizationAPI, '/api/visualizations/<visualization_id>', endpoint='visualization')
|
||||
|
||||
|
||||
class QueryResultListAPI(BaseResource):
|
||||
@require_permission('execute_query')
|
||||
def post(self):
|
||||
params = request.get_json(force=True)
|
||||
|
||||
if settings.FEATURE_TABLES_PERMISSIONS:
|
||||
metadata = utils.SQLMetaData(params['query'])
|
||||
|
||||
if metadata.has_non_select_dml_statements or metadata.has_ddl_statements:
|
||||
return {
|
||||
'job': {
|
||||
'error': 'Only SELECT statements are allowed'
|
||||
}
|
||||
}
|
||||
|
||||
if len(metadata.used_tables - current_user.allowed_tables) > 0 and '*' not in current_user.allowed_tables:
|
||||
logging.warning('Permission denied for user %s to table %s', self.current_user.name, metadata.used_tables)
|
||||
return {
|
||||
'job': {
|
||||
'error': 'Access denied for table(s): %s' % (metadata.used_tables)
|
||||
}
|
||||
}
|
||||
|
||||
models.ActivityLog(
|
||||
user=self.current_user,
|
||||
type=models.ActivityLog.QUERY_EXECUTION,
|
||||
activity=params['query']
|
||||
).save()
|
||||
|
||||
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'], max_age)
|
||||
|
||||
if query_result:
|
||||
return {'query_result': query_result.to_dict()}
|
||||
else:
|
||||
data_source = models.DataSource.get_by_id(params['data_source_id'])
|
||||
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()}
|
||||
|
||||
|
||||
class QueryResultAPI(BaseResource):
|
||||
@staticmethod
|
||||
def csv_response(query_result):
|
||||
s = cStringIO.StringIO()
|
||||
|
||||
query_data = json.loads(query_result.data)
|
||||
writer = csv.DictWriter(s, fieldnames=[col['name'] for col in query_data['columns']])
|
||||
writer.writer = utils.UnicodeWriter(s)
|
||||
writer.writeheader()
|
||||
for row in query_data['rows']:
|
||||
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:
|
||||
query = models.Query.get(models.Query.id == query_id)
|
||||
if query:
|
||||
query_result_id = query._data['latest_query_data']
|
||||
|
||||
if query_result_id:
|
||||
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)
|
||||
headers.update(cache_headers)
|
||||
return make_response(data, 200, headers)
|
||||
else:
|
||||
return self.csv_response(query_result)
|
||||
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
|
||||
api.add_resource(QueryResultListAPI, '/api/query_results', endpoint='query_results')
|
||||
api.add_resource(QueryResultAPI,
|
||||
'/api/query_results/<query_result_id>',
|
||||
'/api/queries/<query_id>/results.<filetype>',
|
||||
'/api/queries/<query_id>/results/<query_result_id>.<filetype>',
|
||||
endpoint='query_result')
|
||||
|
||||
|
||||
class JobAPI(BaseResource):
|
||||
def get(self, job_id):
|
||||
# TODO: if finished, include the query result
|
||||
job = QueryTask(job_id=job_id)
|
||||
return {'job': job.to_dict()}
|
||||
|
||||
def delete(self, job_id):
|
||||
job = QueryTask(job_id=job_id)
|
||||
job.cancel()
|
||||
|
||||
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):
|
||||
if current_app.debug:
|
||||
cache_timeout = 0
|
||||
else:
|
||||
cache_timeout = None
|
||||
|
||||
return send_from_directory(settings.STATIC_ASSETS_PATH, filename, cache_timeout=cache_timeout)
|
||||
24
redash/handlers/__init__.py
Normal file
24
redash/handlers/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from flask import jsonify
|
||||
from flask_login import login_required
|
||||
|
||||
from redash.wsgi import app
|
||||
from redash.permissions import require_permission
|
||||
from redash.monitor import get_status
|
||||
|
||||
|
||||
@app.route('/ping', methods=['GET'])
|
||||
def ping():
|
||||
return 'PONG.'
|
||||
|
||||
|
||||
@app.route('/status.json')
|
||||
@login_required
|
||||
@require_permission('admin')
|
||||
def status_api():
|
||||
status = get_status()
|
||||
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
from redash.handlers import alerts, authentication, base, dashboards, data_sources, events, queries, query_results, \
|
||||
static, users, visualizations, widgets
|
||||
105
redash/handlers/alerts.py
Normal file
105
redash/handlers/alerts.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import time
|
||||
|
||||
from flask import request
|
||||
from funcy import project
|
||||
|
||||
from redash import models
|
||||
from redash.wsgi import api
|
||||
from redash.tasks import record_event
|
||||
from redash.handlers.base import BaseResource, require_fields
|
||||
|
||||
|
||||
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)
|
||||
require_fields(req, ('options', 'name', 'query_id'))
|
||||
|
||||
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')
|
||||
44
redash/handlers/authentication.py
Normal file
44
redash/handlers/authentication.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from flask import render_template, request, redirect, session, url_for, flash
|
||||
from flask_login import current_user, login_user, logout_user
|
||||
|
||||
from redash import models, settings
|
||||
from redash.wsgi import app
|
||||
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated():
|
||||
return redirect(request.args.get('next') or '/')
|
||||
|
||||
if not settings.PASSWORD_LOGIN_ENABLED:
|
||||
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':
|
||||
try:
|
||||
user = models.User.get_by_email(request.form['email'])
|
||||
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 email or password.")
|
||||
except models.User.DoesNotExist:
|
||||
flash("Wrong email 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_saml_login=settings.SAML_LOGIN_ENABLED)
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
logout_user()
|
||||
session.pop('openid', None)
|
||||
|
||||
return redirect('/login')
|
||||
29
redash/handlers/base.py
Normal file
29
redash/handlers/base.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from flask import request
|
||||
from flask.ext.restful import Resource, abort
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from redash import statsd_client
|
||||
|
||||
|
||||
class BaseResource(Resource):
|
||||
decorators = [login_required]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(BaseResource, self).__init__(*args, **kwargs)
|
||||
self._user = None
|
||||
|
||||
@property
|
||||
def current_user(self):
|
||||
return current_user._get_current_object()
|
||||
|
||||
def dispatch_request(self, *args, **kwargs):
|
||||
with statsd_client.timer('requests.{}.{}'.format(request.endpoint, request.method.lower())):
|
||||
response = super(BaseResource, self).dispatch_request(*args, **kwargs)
|
||||
return response
|
||||
|
||||
|
||||
def require_fields(req, fields):
|
||||
for f in fields:
|
||||
if f not in req:
|
||||
abort(400)
|
||||
|
||||
73
redash/handlers/dashboards.py
Normal file
73
redash/handlers/dashboards.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from flask import request
|
||||
from flask.ext.restful import abort
|
||||
from flask_login import current_user
|
||||
|
||||
from funcy import distinct, take
|
||||
from itertools import chain
|
||||
|
||||
from redash import models
|
||||
from redash.wsgi import api
|
||||
from redash.permissions import require_permission
|
||||
from redash.handlers.base import BaseResource
|
||||
|
||||
|
||||
class DashboardRecentAPI(BaseResource):
|
||||
def get(self):
|
||||
recent = [d.to_dict() for d in models.Dashboard.recent(current_user.id)]
|
||||
|
||||
global_recent = []
|
||||
if len(recent) < 10:
|
||||
global_recent = [d.to_dict() for d in models.Dashboard.recent()]
|
||||
|
||||
return take(20, distinct(chain(recent, global_recent), key=lambda d: d['id']))
|
||||
|
||||
|
||||
class DashboardListAPI(BaseResource):
|
||||
def get(self):
|
||||
dashboards = [d.to_dict() for d in
|
||||
models.Dashboard.select().where(models.Dashboard.is_archived==False)]
|
||||
|
||||
return dashboards
|
||||
|
||||
@require_permission('create_dashboard')
|
||||
def post(self):
|
||||
dashboard_properties = request.get_json(force=True)
|
||||
dashboard = models.Dashboard(name=dashboard_properties['name'],
|
||||
user=self.current_user,
|
||||
layout='[]')
|
||||
dashboard.save()
|
||||
return dashboard.to_dict()
|
||||
|
||||
|
||||
class DashboardAPI(BaseResource):
|
||||
def get(self, dashboard_slug=None):
|
||||
try:
|
||||
dashboard = models.Dashboard.get_by_slug(dashboard_slug)
|
||||
except models.Dashboard.DoesNotExist:
|
||||
abort(404)
|
||||
|
||||
return dashboard.to_dict(with_widgets=True)
|
||||
|
||||
@require_permission('edit_dashboard')
|
||||
def post(self, dashboard_slug):
|
||||
dashboard_properties = request.get_json(force=True)
|
||||
# TODO: either convert all requests to use slugs or ids
|
||||
dashboard = models.Dashboard.get_by_id(dashboard_slug)
|
||||
dashboard.layout = dashboard_properties['layout']
|
||||
dashboard.name = dashboard_properties['name']
|
||||
dashboard.save()
|
||||
|
||||
return dashboard.to_dict(with_widgets=True)
|
||||
|
||||
@require_permission('edit_dashboard')
|
||||
def delete(self, dashboard_slug):
|
||||
dashboard = models.Dashboard.get_by_slug(dashboard_slug)
|
||||
dashboard.is_archived = True
|
||||
dashboard.save()
|
||||
|
||||
return dashboard.to_dict(with_widgets=True)
|
||||
|
||||
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')
|
||||
|
||||
83
redash/handlers/data_sources.py
Normal file
83
redash/handlers/data_sources.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import json
|
||||
|
||||
from flask import make_response, request
|
||||
from flask.ext.restful import abort
|
||||
|
||||
from redash import models
|
||||
from redash.wsgi import api
|
||||
from redash.permissions import require_permission
|
||||
from redash.query_runner import query_runners, validate_configuration
|
||||
from redash.handlers.base import BaseResource
|
||||
|
||||
|
||||
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)
|
||||
|
||||
data_source.replace_secret_placeholders(req['options'])
|
||||
|
||||
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')
|
||||
27
redash/handlers/events.py
Normal file
27
redash/handlers/events.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from flask import request
|
||||
|
||||
from redash import statsd_client
|
||||
from redash.wsgi import api
|
||||
from redash.tasks import record_event
|
||||
from redash.handlers.base import BaseResource
|
||||
|
||||
|
||||
class EventAPI(BaseResource):
|
||||
def post(self):
|
||||
events_list = request.get_json(force=True)
|
||||
for event in events_list:
|
||||
record_event.delay(event)
|
||||
|
||||
|
||||
api.add_resource(EventAPI, '/api/events', endpoint='events')
|
||||
|
||||
|
||||
class MetricsAPI(BaseResource):
|
||||
def post(self):
|
||||
for stat_line in request.data.split():
|
||||
stat, value = stat_line.split(':')
|
||||
statsd_client._send_stat('client.{}'.format(stat), value, 1)
|
||||
|
||||
return "OK."
|
||||
|
||||
api.add_resource(MetricsAPI, '/api/metrics/v1/send', endpoint='metrics')
|
||||
128
redash/handlers/queries.py
Normal file
128
redash/handlers/queries.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from flask import request, redirect
|
||||
from flask.ext.restful import abort
|
||||
from flask_login import current_user, login_required
|
||||
import sqlparse
|
||||
|
||||
from funcy import distinct, take
|
||||
from itertools import chain
|
||||
|
||||
from redash import models
|
||||
from redash.wsgi import app, api
|
||||
from redash.permissions import require_permission
|
||||
from redash.handlers.base import BaseResource
|
||||
|
||||
|
||||
@app.route('/api/queries/format', methods=['POST'])
|
||||
@login_required
|
||||
def format_sql_query():
|
||||
arguments = request.get_json(force=True)
|
||||
query = arguments.get("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 QuerySearchAPI(BaseResource):
|
||||
@require_permission('view_query')
|
||||
def get(self):
|
||||
term = request.args.get('q', '')
|
||||
|
||||
return [q.to_dict() for q in models.Query.search(term)]
|
||||
|
||||
|
||||
class QueryRecentAPI(BaseResource):
|
||||
@require_permission('view_query')
|
||||
def get(self):
|
||||
recent = [d.to_dict() for d in models.Query.recent(current_user.id)]
|
||||
|
||||
global_recent = []
|
||||
if len(recent) < 10:
|
||||
global_recent = [d.to_dict() for d in models.Query.recent()]
|
||||
|
||||
return take(20, distinct(chain(recent, global_recent), key=lambda d: d['id']))
|
||||
|
||||
|
||||
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', 'last_modified_by']:
|
||||
query_def.pop(field, None)
|
||||
|
||||
query_def['user'] = self.current_user
|
||||
query_def['data_source'] = query_def.pop('data_source_id')
|
||||
query = models.Query(**query_def)
|
||||
query.save()
|
||||
|
||||
return query.to_dict()
|
||||
|
||||
@require_permission('view_query')
|
||||
def get(self):
|
||||
return [q.to_dict(with_stats=True) for q in models.Query.all_queries()]
|
||||
|
||||
|
||||
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', 'last_modified_by']:
|
||||
query_def.pop(field, None)
|
||||
|
||||
if 'latest_query_data_id' in query_def:
|
||||
query_def['latest_query_data'] = query_def.pop('latest_query_data_id')
|
||||
|
||||
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)
|
||||
|
||||
return query.to_dict(with_visualizations=True)
|
||||
|
||||
@require_permission('view_query')
|
||||
def get(self, query_id):
|
||||
q = models.Query.get(models.Query.id == query_id)
|
||||
if q:
|
||||
return q.to_dict(with_visualizations=True)
|
||||
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')
|
||||
174
redash/handlers/query_results.py
Normal file
174
redash/handlers/query_results.py
Normal file
@@ -0,0 +1,174 @@
|
||||
import csv
|
||||
import json
|
||||
import cStringIO
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import make_response, request
|
||||
from flask.ext.restful import abort
|
||||
from flask_login import current_user
|
||||
|
||||
from redash import models, settings, utils
|
||||
from redash.wsgi import api
|
||||
from redash.tasks import QueryTask, record_event
|
||||
from redash.permissions import require_permission
|
||||
from redash.handlers.base import BaseResource
|
||||
|
||||
|
||||
class QueryResultListAPI(BaseResource):
|
||||
@require_permission('execute_query')
|
||||
def post(self):
|
||||
params = request.get_json(force=True)
|
||||
|
||||
if settings.FEATURE_TABLES_PERMISSIONS:
|
||||
metadata = utils.SQLMetaData(params['query'])
|
||||
|
||||
if metadata.has_non_select_dml_statements or metadata.has_ddl_statements:
|
||||
return {
|
||||
'job': {
|
||||
'error': 'Only SELECT statements are allowed'
|
||||
}
|
||||
}
|
||||
|
||||
if len(metadata.used_tables - current_user.allowed_tables) > 0 and '*' not in current_user.allowed_tables:
|
||||
logging.warning('Permission denied for user %s to table %s', self.current_user.name, metadata.used_tables)
|
||||
return {
|
||||
'job': {
|
||||
'error': 'Access denied for table(s): %s' % (metadata.used_tables)
|
||||
}
|
||||
}
|
||||
|
||||
models.ActivityLog(
|
||||
user=self.current_user,
|
||||
type=models.ActivityLog.QUERY_EXECUTION,
|
||||
activity=params['query']
|
||||
).save()
|
||||
|
||||
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'], max_age)
|
||||
|
||||
if query_result:
|
||||
return {'query_result': query_result.to_dict()}
|
||||
else:
|
||||
data_source = models.DataSource.get_by_id(params['data_source_id'])
|
||||
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()}
|
||||
|
||||
|
||||
ONE_YEAR = 60 * 60 * 24 * 365.25
|
||||
|
||||
|
||||
class QueryResultAPI(BaseResource):
|
||||
|
||||
@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'):
|
||||
should_cache = query_result_id is not None
|
||||
if query_result_id is None and query_id is not None:
|
||||
query = models.Query.get(models.Query.id == query_id)
|
||||
if query:
|
||||
query_result_id = query._data['latest_query_data']
|
||||
|
||||
if query_result_id:
|
||||
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)
|
||||
|
||||
if filetype == 'json':
|
||||
response = self.make_json_response(query_result)
|
||||
else:
|
||||
response = self.make_csv_response(query_result)
|
||||
|
||||
if len(settings.ACCESS_CONTROL_ALLOW_ORIGIN) > 0:
|
||||
self.add_cors_headers(response.headers)
|
||||
|
||||
if should_cache:
|
||||
response.headers.add_header('Cache-Control', 'max-age=%d' % ONE_YEAR)
|
||||
|
||||
return response
|
||||
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
def make_json_response(self, query_result):
|
||||
data = json.dumps({'query_result': query_result.to_dict()}, cls=utils.JSONEncoder)
|
||||
return make_response(data, 200, {})
|
||||
|
||||
@staticmethod
|
||||
def make_csv_response(query_result):
|
||||
s = cStringIO.StringIO()
|
||||
|
||||
query_data = json.loads(query_result.data)
|
||||
writer = csv.DictWriter(s, fieldnames=[col['name'] for col in query_data['columns']])
|
||||
writer.writer = utils.UnicodeWriter(s)
|
||||
writer.writeheader()
|
||||
for row in query_data['rows']:
|
||||
writer.writerow(row)
|
||||
|
||||
headers = {'Content-Type': "text/csv; charset=UTF-8"}
|
||||
return make_response(s.getvalue(), 200, headers)
|
||||
|
||||
|
||||
api.add_resource(QueryResultListAPI, '/api/query_results', endpoint='query_results')
|
||||
api.add_resource(QueryResultAPI,
|
||||
'/api/query_results/<query_result_id>',
|
||||
'/api/queries/<query_id>/results.<filetype>',
|
||||
'/api/queries/<query_id>/results/<query_result_id>.<filetype>',
|
||||
endpoint='query_result')
|
||||
|
||||
|
||||
class JobAPI(BaseResource):
|
||||
def get(self, job_id):
|
||||
# TODO: if finished, include the query result
|
||||
job = QueryTask(job_id=job_id)
|
||||
return {'job': job.to_dict()}
|
||||
|
||||
def delete(self, job_id):
|
||||
job = QueryTask(job_id=job_id)
|
||||
job.cancel()
|
||||
|
||||
api.add_resource(JobAPI, '/api/jobs/<job_id>', endpoint='job')
|
||||
|
||||
62
redash/handlers/static.py
Normal file
62
redash/handlers/static.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from flask import render_template, send_from_directory, current_app
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from redash import settings, __version__, redis_connection
|
||||
from redash.wsgi import app
|
||||
from redash.version_check import get_latest_version
|
||||
|
||||
|
||||
@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('/users')
|
||||
@app.route('/users/<pk>')
|
||||
@app.route('/queries/<query_id>')
|
||||
@app.route('/queries/<query_id>/<anything>')
|
||||
@app.route('/personal')
|
||||
@app.route('/')
|
||||
@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
|
||||
|
||||
user = {
|
||||
'gravatar_url': gravatar_url,
|
||||
'id': current_user.id,
|
||||
'name': current_user.name,
|
||||
'email': current_user.email,
|
||||
'groups': current_user.groups,
|
||||
'permissions': current_user.permissions
|
||||
}
|
||||
|
||||
client_config = {
|
||||
'clientSideMetrics': settings.CLIENT_SIDE_METRICS,
|
||||
'allowScriptsInUserInput': settings.ALLOW_SCRIPTS_IN_USER_INPUT,
|
||||
'highChartsTurboThreshold': settings.HIGHCHARTS_TURBO_THRESHOLD,
|
||||
'dateFormat': settings.DATE_FORMAT,
|
||||
'dateTimeFormat': "{0} HH:mm".format(settings.DATE_FORMAT),
|
||||
'newVersionAvailable': get_latest_version(),
|
||||
'version': __version__
|
||||
}
|
||||
|
||||
return render_template("index.html", user=json.dumps(user), name=settings.NAME,
|
||||
client_config=json.dumps(client_config),
|
||||
analytics=settings.ANALYTICS)
|
||||
|
||||
|
||||
@app.route('/<path:filename>')
|
||||
def send_static(filename):
|
||||
if current_app.debug:
|
||||
cache_timeout = 0
|
||||
else:
|
||||
cache_timeout = None
|
||||
|
||||
return send_from_directory(settings.STATIC_ASSETS_PATH, filename, cache_timeout=cache_timeout)
|
||||
100
redash/handlers/users.py
Normal file
100
redash/handlers/users.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import time
|
||||
from flask import request
|
||||
from flask.ext.restful import abort
|
||||
from funcy import project
|
||||
from peewee import IntegrityError
|
||||
|
||||
from redash import models
|
||||
from redash.wsgi import api
|
||||
from redash.tasks import record_event
|
||||
from redash.permissions import require_permission, require_admin_or_owner, is_admin_or_owner, \
|
||||
require_permission_or_owner
|
||||
from redash.handlers.base import BaseResource, require_fields
|
||||
|
||||
|
||||
class UserListResource(BaseResource):
|
||||
@require_permission('list_users')
|
||||
def get(self):
|
||||
return [u.to_dict() for u in models.User.select()]
|
||||
|
||||
@require_permission('admin')
|
||||
def post(self):
|
||||
# TODO: send invite.
|
||||
req = request.get_json(force=True)
|
||||
require_fields(req, ('name', 'email', 'password'))
|
||||
|
||||
user = models.User(name=req['name'], email=req['email'])
|
||||
user.hash_password(req['password'])
|
||||
try:
|
||||
user.save()
|
||||
except IntegrityError as e:
|
||||
if "email" in e.message:
|
||||
abort(400, message='Email already taken.')
|
||||
|
||||
abort(500)
|
||||
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'create',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': user.id,
|
||||
'object_type': 'user'
|
||||
})
|
||||
|
||||
return user.to_dict()
|
||||
|
||||
|
||||
class UserResource(BaseResource):
|
||||
def get(self, user_id):
|
||||
require_permission_or_owner('list_users', user_id)
|
||||
user = models.User.get_by_id(user_id)
|
||||
|
||||
return user.to_dict(with_api_key=is_admin_or_owner(user_id))
|
||||
|
||||
def post(self, user_id):
|
||||
require_admin_or_owner(user_id)
|
||||
user = models.User.get_by_id(user_id)
|
||||
|
||||
req = request.get_json(True)
|
||||
|
||||
params = project(req, ('email', 'name', 'password', 'old_password', 'groups'))
|
||||
|
||||
if 'password' in params and 'old_password' not in params:
|
||||
abort(403, message="Must provide current password to update password.")
|
||||
|
||||
if 'old_password' in params and not user.verify_password(params['old_password']):
|
||||
abort(403, message="Incorrect current password.")
|
||||
|
||||
if 'password' in params:
|
||||
user.hash_password(params.pop('password'))
|
||||
params.pop('old_password')
|
||||
|
||||
if 'groups' in params and not self.current_user.has_permission('admin'):
|
||||
abort(403, message="Must be admin to change groups membership.")
|
||||
|
||||
try:
|
||||
user.update_instance(**params)
|
||||
except IntegrityError as e:
|
||||
if "email" in e.message:
|
||||
message = "Email already taken."
|
||||
else:
|
||||
message = "Error updating record"
|
||||
|
||||
abort(400, message=message)
|
||||
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'edit',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': user.id,
|
||||
'object_type': 'user',
|
||||
'updated_fields': params.keys()
|
||||
})
|
||||
|
||||
return user.to_dict(with_api_key=is_admin_or_owner(user_id))
|
||||
|
||||
|
||||
api.add_resource(UserListResource, '/api/users', endpoint='users')
|
||||
api.add_resource(UserResource, '/api/users/<user_id>', endpoint='user')
|
||||
|
||||
|
||||
45
redash/handlers/visualizations.py
Normal file
45
redash/handlers/visualizations.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import json
|
||||
from flask import request
|
||||
|
||||
from redash import models
|
||||
from redash.wsgi import api
|
||||
from redash.permissions import require_permission
|
||||
from redash.handlers.base import BaseResource
|
||||
|
||||
|
||||
class VisualizationListAPI(BaseResource):
|
||||
@require_permission('edit_query')
|
||||
def post(self):
|
||||
kwargs = request.get_json(force=True)
|
||||
kwargs['options'] = json.dumps(kwargs['options'])
|
||||
kwargs['query'] = kwargs.pop('query_id')
|
||||
|
||||
vis = models.Visualization(**kwargs)
|
||||
vis.save()
|
||||
|
||||
return vis.to_dict(with_query=False)
|
||||
|
||||
|
||||
class VisualizationAPI(BaseResource):
|
||||
@require_permission('edit_query')
|
||||
def post(self, visualization_id):
|
||||
kwargs = request.get_json(force=True)
|
||||
if 'options' in kwargs:
|
||||
kwargs['options'] = json.dumps(kwargs['options'])
|
||||
kwargs.pop('id', None)
|
||||
kwargs.pop('query_id', None)
|
||||
|
||||
update = models.Visualization.update(**kwargs).where(models.Visualization.id == visualization_id)
|
||||
update.execute()
|
||||
|
||||
vis = models.Visualization.get_by_id(visualization_id)
|
||||
|
||||
return vis.to_dict(with_query=False)
|
||||
|
||||
@require_permission('edit_query')
|
||||
def delete(self, visualization_id):
|
||||
vis = models.Visualization.get(models.Visualization.id == visualization_id)
|
||||
vis.delete_instance()
|
||||
|
||||
api.add_resource(VisualizationListAPI, '/api/visualizations', endpoint='visualizations')
|
||||
api.add_resource(VisualizationAPI, '/api/visualizations/<visualization_id>', endpoint='visualization')
|
||||
50
redash/handlers/widgets.py
Normal file
50
redash/handlers/widgets.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
|
||||
from flask import request
|
||||
|
||||
from redash import models
|
||||
from redash.wsgi import api
|
||||
from redash.permissions import require_permission
|
||||
from redash.handlers.base import BaseResource
|
||||
|
||||
|
||||
class WidgetListAPI(BaseResource):
|
||||
@require_permission('edit_dashboard')
|
||||
def post(self):
|
||||
widget_properties = request.get_json(force=True)
|
||||
widget_properties['options'] = json.dumps(widget_properties['options'])
|
||||
widget_properties.pop('id', None)
|
||||
widget_properties['dashboard'] = widget_properties.pop('dashboard_id')
|
||||
widget_properties['visualization'] = widget_properties.pop('visualization_id')
|
||||
widget = models.Widget(**widget_properties)
|
||||
widget.save()
|
||||
|
||||
layout = json.loads(widget.dashboard.layout)
|
||||
new_row = True
|
||||
|
||||
if len(layout) == 0 or widget.width == 2:
|
||||
layout.append([widget.id])
|
||||
elif len(layout[-1]) == 1:
|
||||
neighbour_widget = models.Widget.get(models.Widget.id == layout[-1][0])
|
||||
if neighbour_widget.width == 1:
|
||||
layout[-1].append(widget.id)
|
||||
new_row = False
|
||||
else:
|
||||
layout.append([widget.id])
|
||||
else:
|
||||
layout.append([widget.id])
|
||||
|
||||
widget.dashboard.layout = json.dumps(layout)
|
||||
widget.dashboard.save()
|
||||
|
||||
return {'widget': widget.to_dict(), 'layout': layout, 'new_row': new_row}
|
||||
|
||||
|
||||
class WidgetAPI(BaseResource):
|
||||
@require_permission('edit_dashboard')
|
||||
def delete(self, widget_id):
|
||||
widget = models.Widget.get(models.Widget.id == widget_id)
|
||||
widget.delete_instance()
|
||||
|
||||
api.add_resource(WidgetListAPI, '/api/widgets', endpoint='widgets')
|
||||
api.add_resource(WidgetAPI, '/api/widgets/<int:widget_id>', endpoint='widget')
|
||||
@@ -7,6 +7,7 @@ from flask.ext.script import Manager
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class Importer(object):
|
||||
def __init__(self, object_mapping=None, data_source=None):
|
||||
if object_mapping is None:
|
||||
@@ -146,6 +147,7 @@ def get_data_source():
|
||||
|
||||
return data_source
|
||||
|
||||
|
||||
@import_manager.command
|
||||
def query(mapping_filename, query_filename, user_id):
|
||||
user = models.User.get_by_id(user_id)
|
||||
|
||||
102
redash/models.py
102
redash/models.py
@@ -11,7 +11,6 @@ import peewee
|
||||
from passlib.apps import custom_app_context as pwd_context
|
||||
from playhouse.postgres_ext import ArrayField, DateTimeTZField, PostgresqlExtDatabase
|
||||
from flask.ext.login import UserMixin, AnonymousUserMixin
|
||||
import psycopg2
|
||||
|
||||
from redash import utils, settings, redis_connection
|
||||
from redash.query_runner import get_query_runner
|
||||
@@ -132,7 +131,7 @@ class ApiUser(UserMixin, PermissionsCheckMixin):
|
||||
|
||||
class Group(BaseModel):
|
||||
DEFAULT_PERMISSIONS = ['create_dashboard', 'create_query', 'edit_dashboard', 'edit_query',
|
||||
'view_query', 'view_source', 'execute_query']
|
||||
'view_query', 'view_source', 'execute_query', 'list_users', 'schedule_query']
|
||||
|
||||
id = peewee.PrimaryKeyField()
|
||||
name = peewee.CharField(max_length=100)
|
||||
@@ -169,16 +168,27 @@ class User(ModelTimestampsMixin, BaseModel, UserMixin, PermissionsCheckMixin):
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
def to_dict(self, with_api_key=False):
|
||||
d = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'email': self.email,
|
||||
'gravatar_url': self.gravatar_url,
|
||||
'groups': self.groups,
|
||||
'updated_at': self.updated_at,
|
||||
'created_at': self.created_at
|
||||
}
|
||||
|
||||
if self.password_hash is None:
|
||||
d['auth_type'] = 'external'
|
||||
else:
|
||||
d['auth_type'] = 'password'
|
||||
|
||||
if with_api_key:
|
||||
d['api_key'] = self.api_key
|
||||
|
||||
return d
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(User, self).__init__(*args, **kwargs)
|
||||
self._allowed_tables = None
|
||||
@@ -253,6 +263,8 @@ class ActivityLog(BaseModel):
|
||||
|
||||
|
||||
class DataSource(BaseModel):
|
||||
SECRET_PLACEHOLDER = '--------'
|
||||
|
||||
id = peewee.PrimaryKeyField()
|
||||
name = peewee.CharField(unique=True)
|
||||
type = peewee.CharField()
|
||||
@@ -273,7 +285,7 @@ class DataSource(BaseModel):
|
||||
}
|
||||
|
||||
if all:
|
||||
d['options'] = json.loads(self.options)
|
||||
d['options'] = self.configuration
|
||||
d['queue_name'] = self.queue_name
|
||||
d['scheduled_queue_name'] = self.scheduled_queue_name
|
||||
|
||||
@@ -282,6 +294,23 @@ class DataSource(BaseModel):
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def configuration(self):
|
||||
configuration = json.loads(self.options)
|
||||
schema = self.query_runner.configuration_schema()
|
||||
for prop in schema.get('secret', []):
|
||||
if prop in configuration and configuration[prop]:
|
||||
configuration[prop] = self.SECRET_PLACEHOLDER
|
||||
|
||||
return configuration
|
||||
|
||||
def replace_secret_placeholders(self, configuration):
|
||||
current_configuration = json.loads(self.options)
|
||||
schema = self.query_runner.configuration_schema()
|
||||
for prop in schema.get('secret', []):
|
||||
if prop in configuration and configuration[prop] == self.SECRET_PLACEHOLDER:
|
||||
configuration[prop] = current_configuration[prop]
|
||||
|
||||
def get_schema(self, refresh=False):
|
||||
key = "data_source:schema:{}".format(self.id)
|
||||
|
||||
@@ -340,10 +369,10 @@ class QueryResult(BaseModel):
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def unused(cls):
|
||||
week_ago = datetime.datetime.now() - datetime.timedelta(days=7)
|
||||
def unused(cls, days=7):
|
||||
age_threshold = datetime.datetime.now() - datetime.timedelta(days=days)
|
||||
|
||||
unused_results = cls.select().where(Query.id == None, cls.retrieved_at < week_ago)\
|
||||
unused_results = cls.select().where(Query.id == None, cls.retrieved_at < age_threshold)\
|
||||
.join(Query, join_type=peewee.JOIN_LEFT_OUTER)
|
||||
|
||||
return unused_results
|
||||
@@ -511,18 +540,24 @@ class Query(ModelTimestampsMixin, BaseModel):
|
||||
return cls.select().where(where).order_by(cls.created_at.desc())
|
||||
|
||||
@classmethod
|
||||
def recent(cls, user_id):
|
||||
def recent(cls, user_id=None, limit=20):
|
||||
# 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")).\
|
||||
query = 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"))
|
||||
|
||||
if user_id:
|
||||
query = query.where(Event.user == user_id)
|
||||
|
||||
query = query.limit(limit)
|
||||
|
||||
return query
|
||||
|
||||
@classmethod
|
||||
def update_instance(cls, query_id, **kwargs):
|
||||
if 'query' in kwargs:
|
||||
@@ -586,19 +621,26 @@ class Alert(ModelTimestampsMixin, BaseModel):
|
||||
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 to_dict(self, full=True):
|
||||
d = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'options': self.options,
|
||||
'state': self.state,
|
||||
'last_triggered_at': self.last_triggered_at,
|
||||
'updated_at': self.updated_at,
|
||||
'created_at': self.created_at
|
||||
}
|
||||
|
||||
if full:
|
||||
d['query'] = self.query.to_dict()
|
||||
d['user'] = self.user.to_dict()
|
||||
else:
|
||||
d['query_id'] = self._data['query']
|
||||
d['user_id'] = self._data['user']
|
||||
|
||||
return d
|
||||
|
||||
def evaluate(self):
|
||||
data = json.loads(self.query.latest_query_data.data)
|
||||
# todo: safe guard for empty
|
||||
@@ -694,6 +736,7 @@ class Dashboard(ModelTimestampsMixin, BaseModel):
|
||||
'layout': layout,
|
||||
'dashboard_filters_enabled': self.dashboard_filters_enabled,
|
||||
'widgets': widgets_layout,
|
||||
'is_archived': self.is_archived,
|
||||
'updated_at': self.updated_at,
|
||||
'created_at': self.created_at
|
||||
}
|
||||
@@ -703,16 +746,23 @@ class Dashboard(ModelTimestampsMixin, BaseModel):
|
||||
return cls.get(cls.slug == slug)
|
||||
|
||||
@classmethod
|
||||
def recent(cls, user_id):
|
||||
return cls.select().where(Event.created_at > peewee.SQL("current_date - 7")). \
|
||||
def recent(cls, user_id=None, limit=20):
|
||||
query = 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'). \
|
||||
where(Dashboard.is_archived == False). \
|
||||
group_by(Event.object_id, Dashboard.id). \
|
||||
order_by(peewee.SQL("count(0) desc"))
|
||||
|
||||
if user_id:
|
||||
query = query.where(Event.user == user_id)
|
||||
|
||||
query = query.limit(limit)
|
||||
|
||||
return query
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = utils.slugify(self.name)
|
||||
@@ -832,7 +882,7 @@ class Event(BaseModel):
|
||||
return event
|
||||
|
||||
|
||||
all_models = (DataSource, User, QueryResult, Query, Alert, Dashboard, Visualization, Widget, ActivityLog, Group, Event)
|
||||
all_models = (DataSource, User, QueryResult, Query, Alert, AlertSubscription, Dashboard, Visualization, Widget, ActivityLog, Group, Event)
|
||||
|
||||
|
||||
def init_db():
|
||||
|
||||
@@ -21,4 +21,22 @@ class require_permissions(object):
|
||||
|
||||
|
||||
def require_permission(permission):
|
||||
return require_permissions((permission,))
|
||||
return require_permissions((permission,))
|
||||
|
||||
|
||||
def has_permission_or_owner(permission, object_owner_id):
|
||||
return int(object_owner_id) == current_user.id or current_user.has_permission(permission)
|
||||
|
||||
|
||||
def is_admin_or_owner(object_owner_id):
|
||||
return has_permission_or_owner('admin', object_owner_id)
|
||||
|
||||
|
||||
def require_permission_or_owner(permission, object_owner_id):
|
||||
if not has_permission_or_owner(permission, object_owner_id):
|
||||
abort(403)
|
||||
|
||||
|
||||
def require_admin_or_owner(object_owner_id):
|
||||
if not is_admin_or_owner(object_owner_id):
|
||||
abort(403, message="You don't have permission to edit this resource.")
|
||||
|
||||
@@ -9,6 +9,7 @@ logger = logging.getLogger(__name__)
|
||||
__all__ = [
|
||||
'ValidationError',
|
||||
'BaseQueryRunner',
|
||||
'InterruptException',
|
||||
'TYPE_DATETIME',
|
||||
'TYPE_BOOLEAN',
|
||||
'TYPE_INTEGER',
|
||||
@@ -38,6 +39,9 @@ SUPPORTED_COLUMN_TYPES = set([
|
||||
TYPE_DATE
|
||||
])
|
||||
|
||||
class InterruptException(Exception):
|
||||
pass
|
||||
|
||||
class BaseQueryRunner(object):
|
||||
def __init__(self, configuration):
|
||||
jsonschema.validate(configuration, self.configuration_schema())
|
||||
@@ -67,9 +71,34 @@ class BaseQueryRunner(object):
|
||||
def run_query(self, query):
|
||||
raise NotImplementedError()
|
||||
|
||||
def fetch_columns(self, columns):
|
||||
column_names = []
|
||||
duplicates_counter = 1
|
||||
new_columns = []
|
||||
|
||||
for col in columns:
|
||||
column_name = col[0]
|
||||
if column_name in column_names:
|
||||
column_name = "{}{}".format(column_name, duplicates_counter)
|
||||
duplicates_counter += 1
|
||||
|
||||
column_names.append(column_name)
|
||||
new_columns.append({'name': column_name,
|
||||
'friendly_name': column_name,
|
||||
'type': col[1]})
|
||||
|
||||
return new_columns
|
||||
|
||||
def get_schema(self):
|
||||
return []
|
||||
|
||||
def _run_query_internal(self, query):
|
||||
results, error = self.run_query(query)
|
||||
|
||||
if error is not None:
|
||||
raise Exception("Failed running query [%s]." % query)
|
||||
return json.loads(results)['rows']
|
||||
|
||||
@classmethod
|
||||
def to_dict(cls):
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,7 @@ import time
|
||||
|
||||
import requests
|
||||
|
||||
from redash import settings
|
||||
from redash.query_runner import *
|
||||
from redash.utils import JSONEncoder
|
||||
|
||||
@@ -22,9 +23,6 @@ try:
|
||||
|
||||
enabled = True
|
||||
except ImportError:
|
||||
logger.warning("Missing dependencies. Please install google-api-python-client and oauth2client.")
|
||||
logger.warning("You can use pip: pip install google-api-python-client oauth2client")
|
||||
|
||||
enabled = False
|
||||
|
||||
types_map = {
|
||||
@@ -99,7 +97,8 @@ class BigQuery(BaseQueryRunner):
|
||||
'title': 'JSON Key File'
|
||||
}
|
||||
},
|
||||
'required': ['jsonKeyFile', 'projectId']
|
||||
'required': ['jsonKeyFile', 'projectId'],
|
||||
'secret': ['jsonKeyFile']
|
||||
}
|
||||
|
||||
def __init__(self, configuration_json):
|
||||
@@ -113,7 +112,7 @@ class BigQuery(BaseQueryRunner):
|
||||
key = json.loads(b64decode(self.configuration['jsonKeyFile']))
|
||||
|
||||
credentials = SignedJwtAssertionCredentials(key['client_email'], key['private_key'], scope=scope)
|
||||
http = httplib2.Http()
|
||||
http = httplib2.Http(timeout=settings.BIGQUERY_HTTP_TIMEOUT)
|
||||
http = credentials.authorize(http)
|
||||
|
||||
return build("bigquery", "v2", http=http)
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import urllib
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from redash.query_runner import *
|
||||
from redash import models
|
||||
|
||||
import requests
|
||||
import dateutil
|
||||
from dateutil.parser import parse
|
||||
|
||||
try:
|
||||
import http.client as http_client
|
||||
@@ -27,9 +24,15 @@ ELASTICSEARCH_TYPES_MAPPING = {
|
||||
"boolean" : TYPE_BOOLEAN,
|
||||
"string" : TYPE_STRING,
|
||||
"date" : TYPE_DATE,
|
||||
"object" : TYPE_STRING,
|
||||
# "geo_point" TODO: Need to split to 2 fields somehow
|
||||
}
|
||||
|
||||
ELASTICSEARCH_BUILTIN_FIELDS_MAPPING = {
|
||||
"_id" : "Id",
|
||||
"_score" : "Score"
|
||||
}
|
||||
|
||||
PYTHON_TYPES_MAPPING = {
|
||||
str: TYPE_STRING,
|
||||
unicode: TYPE_STRING,
|
||||
@@ -39,56 +42,10 @@ PYTHON_TYPES_MAPPING = {
|
||||
float: TYPE_FLOAT
|
||||
}
|
||||
|
||||
#
|
||||
# ElasticSearch currently supports only simple Lucene style queries (like Kibana
|
||||
# but without the aggregation).
|
||||
#
|
||||
# Full blown JSON based ElasticSearch queries (including aggregations) will be
|
||||
# added later
|
||||
#
|
||||
# Simple query example:
|
||||
#
|
||||
# - Query the index named "twitter"
|
||||
# - Filter by "user:kimchy"
|
||||
# - Return the fields: "@timestamp", "tweet" and "user"
|
||||
# - Return up to 15 results
|
||||
# - Sort by @timestamp ascending
|
||||
#
|
||||
# {
|
||||
# "index" : "twitter",
|
||||
# "query" : "user:kimchy",
|
||||
# "fields" : ["@timestamp", "tweet", "user"],
|
||||
# "size" : 15,
|
||||
# "sort" : "@timestamp:asc"
|
||||
# }
|
||||
#
|
||||
#
|
||||
# Simple query on a logstash ElasticSearch instance:
|
||||
#
|
||||
# - Query the index named "logstash-2015.04.*" (in this case its all of April 2015)
|
||||
# - Filter by type:events AND eventName:UserUpgrade AND channel:selfserve
|
||||
# - Return fields: "@timestamp", "userId", "channel", "utm_source", "utm_medium", "utm_campaign", "utm_content"
|
||||
# - Return up to 250 results
|
||||
# - Sort by @timestamp ascending
|
||||
class BaseElasticSearch(BaseQueryRunner):
|
||||
|
||||
# {
|
||||
# "index" : "logstash-2015.04.*",
|
||||
# "query" : "type:events AND eventName:UserUpgrade AND channel:selfserve",
|
||||
# "fields" : ["@timestamp", "userId", "channel", "utm_source", "utm_medium", "utm_campaign", "utm_content"],
|
||||
# "size" : 250,
|
||||
# "sort" : "@timestamp:asc"
|
||||
# }
|
||||
#
|
||||
#
|
||||
DEBUG_ENABLED = True
|
||||
|
||||
class ElasticSearch(BaseQueryRunner):
|
||||
DEBUG_ENABLED = False
|
||||
|
||||
"""
|
||||
ElastichSearch query runner for querying ElasticSearch servers.
|
||||
Query can be done using the Lucene Syntax (single line) or the more complex,
|
||||
full blown ElasticSearch JSON syntax
|
||||
"""
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
@@ -97,6 +54,14 @@ class ElasticSearch(BaseQueryRunner):
|
||||
'server': {
|
||||
'type': 'string',
|
||||
'title': 'Base URL'
|
||||
},
|
||||
'basic_auth_user': {
|
||||
'type': 'string',
|
||||
'title': 'Basic Auth User'
|
||||
},
|
||||
'basic_auth_password': {
|
||||
'type': 'string',
|
||||
'title': 'Basic Auth Password'
|
||||
}
|
||||
},
|
||||
"required" : ["server"]
|
||||
@@ -104,20 +69,16 @@ class ElasticSearch(BaseQueryRunner):
|
||||
|
||||
@classmethod
|
||||
def enabled(cls):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def annotate_query(cls):
|
||||
return False
|
||||
|
||||
def __init__(self, configuration_json):
|
||||
super(ElasticSearch, self).__init__(configuration_json)
|
||||
super(BaseElasticSearch, self).__init__(configuration_json)
|
||||
|
||||
self.syntax = "json"
|
||||
|
||||
if self.DEBUG_ENABLED:
|
||||
http_client.HTTPConnection.debuglevel = 1
|
||||
|
||||
|
||||
# you need to initialize logging, otherwise you will not see anything from requests
|
||||
logging.basicConfig()
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
@@ -125,11 +86,26 @@ class ElasticSearch(BaseQueryRunner):
|
||||
requests_log.setLevel(logging.DEBUG)
|
||||
requests_log.propagate = True
|
||||
|
||||
def get_mappings(self, url):
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
self.server_url = self.configuration["server"]
|
||||
if self.server_url[-1] == "/":
|
||||
self.server_url = self.server_url[:-1]
|
||||
|
||||
basic_auth_user = self.configuration["basic_auth_user"]
|
||||
basic_auth_password = self.configuration["basic_auth_password"]
|
||||
self.auth = None
|
||||
if basic_auth_user and basic_auth_password:
|
||||
self.auth = HTTPBasicAuth(basic_auth_user, basic_auth_password)
|
||||
|
||||
def _get_mappings(self, url):
|
||||
mappings = {}
|
||||
|
||||
r = requests.get(url)
|
||||
r = requests.get(url, auth=self.auth)
|
||||
mappings_data = r.json()
|
||||
|
||||
logger.debug(mappings_data)
|
||||
|
||||
for index_name in mappings_data:
|
||||
index_mappings = mappings_data[index_name]
|
||||
for m in index_mappings.get("mappings", {}):
|
||||
@@ -141,14 +117,21 @@ class ElasticSearch(BaseQueryRunner):
|
||||
if property_type in ELASTICSEARCH_TYPES_MAPPING:
|
||||
mappings[property_name] = property_type
|
||||
else:
|
||||
raise "Unknown property type: {0}".format(property_type)
|
||||
raise Exception("Unknown property type: {0}".format(property_type))
|
||||
|
||||
return mappings
|
||||
|
||||
def parse_results(self, mappings, result_fields, raw_result, result_columns, result_rows):
|
||||
result_columns_index = {}
|
||||
for c in result_columns:
|
||||
result_columns_index[c["name"]] = c
|
||||
def _parse_results(self, mappings, result_fields, raw_result, result_columns, result_rows):
|
||||
|
||||
def add_column_if_needed(mappings, column_name, friendly_name, result_columns, result_columns_index):
|
||||
if friendly_name not in result_columns_index:
|
||||
result_columns.append({
|
||||
"name" : friendly_name,
|
||||
"friendly_name" : friendly_name,
|
||||
"type" : mappings.get(column_name, "string")})
|
||||
result_columns_index[friendly_name] = result_columns[-1]
|
||||
|
||||
result_columns_index = {c["name"] : c for c in result_columns}
|
||||
|
||||
result_fields_index = {}
|
||||
if result_fields:
|
||||
@@ -157,32 +140,49 @@ class ElasticSearch(BaseQueryRunner):
|
||||
|
||||
for h in raw_result["hits"]["hits"]:
|
||||
row = {}
|
||||
for column in h["_source"]:
|
||||
|
||||
for field, column in ELASTICSEARCH_BUILTIN_FIELDS_MAPPING.iteritems():
|
||||
if field in h:
|
||||
add_column_if_needed(mappings, field, column, result_columns, result_columns_index)
|
||||
row[column] = h[field]
|
||||
|
||||
column_name = "_source" if "_source" in h else "fields"
|
||||
for column in h[column_name]:
|
||||
if result_fields and column not in result_fields_index:
|
||||
continue
|
||||
|
||||
if column not in result_columns_index:
|
||||
result_columns.append({
|
||||
"name" : column,
|
||||
"friendly_name" : column,
|
||||
"type" : mappings.get(column, "string")
|
||||
})
|
||||
result_columns_index[column] = result_columns[-1]
|
||||
add_column_if_needed(mappings, column, column, result_columns, result_columns_index)
|
||||
|
||||
value = h[column_name][column]
|
||||
row[column] = value[0] if isinstance(value, list) and len(value) == 1 else value
|
||||
|
||||
row[column] = h["_source"][column]
|
||||
|
||||
if row and len(row) > 0:
|
||||
result_rows.append(row)
|
||||
|
||||
def execute_simple_query(self, url, _from, mappings, result_fields, result_columns, result_rows):
|
||||
|
||||
class Kibana(BaseElasticSearch):
|
||||
|
||||
def __init__(self, configuration_json):
|
||||
super(Kibana, self).__init__(configuration_json)
|
||||
|
||||
@classmethod
|
||||
def enabled(cls):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def annotate_query(cls):
|
||||
return False
|
||||
|
||||
def _execute_simple_query(self, url, auth, _from, mappings, result_fields, result_columns, result_rows):
|
||||
url += "&from={0}".format(_from)
|
||||
r = requests.get(url)
|
||||
r = requests.get(url, auth=self.auth)
|
||||
if r.status_code != 200:
|
||||
raise Exception("Failed to execute query. Return Code: {0} Reason: {1}".format(r.status_code, r.text))
|
||||
|
||||
raw_result = r.json()
|
||||
|
||||
self.parse_results(mappings, result_fields, raw_result, result_columns, result_rows)
|
||||
self._parse_results(mappings, result_fields, raw_result, result_columns, result_rows)
|
||||
|
||||
total = raw_result["hits"]["total"]
|
||||
result_size = len(raw_result["hits"]["hits"])
|
||||
@@ -203,19 +203,14 @@ class ElasticSearch(BaseQueryRunner):
|
||||
result_fields = query_params.get("fields", None)
|
||||
sort = query_params.get("sort", None)
|
||||
|
||||
server_url = self.configuration["server"]
|
||||
if not server_url:
|
||||
if not self.server_url:
|
||||
error = "Missing configuration key 'server'"
|
||||
return None, error
|
||||
|
||||
url = "{0}/{1}/_search?".format(self.server_url, index_name)
|
||||
mapping_url = "{0}/{1}/_mapping".format(self.server_url, index_name)
|
||||
|
||||
if server_url[-1] == "/":
|
||||
server_url = server_url[:-1]
|
||||
|
||||
url = "{0}/{1}/_search?".format(server_url, index_name)
|
||||
mapping_url = "{0}/{1}/_mapping".format(server_url, index_name)
|
||||
|
||||
mappings = self.get_mappings(mapping_url)
|
||||
mappings = self._get_mappings(mapping_url)
|
||||
|
||||
logger.debug(json.dumps(mappings, indent=4))
|
||||
|
||||
@@ -235,7 +230,7 @@ class ElasticSearch(BaseQueryRunner):
|
||||
if isinstance(query_data, str) or isinstance(query_data, unicode):
|
||||
_from = 0
|
||||
while True:
|
||||
total = self.execute_simple_query(url, _from, mappings, result_fields, result_columns, result_rows)
|
||||
total = self._execute_simple_query(url, _from, mappings, result_fields, result_columns, result_rows)
|
||||
_from += size
|
||||
if _from >= total:
|
||||
break
|
||||
@@ -256,4 +251,61 @@ class ElasticSearch(BaseQueryRunner):
|
||||
return json_data, error
|
||||
|
||||
|
||||
class ElasticSearch(BaseElasticSearch):
|
||||
|
||||
def __init__(self, configuration_json):
|
||||
super(ElasticSearch, self).__init__(configuration_json)
|
||||
|
||||
@classmethod
|
||||
def enabled(cls):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def annotate_query(cls):
|
||||
return False
|
||||
|
||||
def run_query(self, query):
|
||||
try:
|
||||
error = None
|
||||
|
||||
logger.debug(query)
|
||||
query_dict = json.loads(query)
|
||||
|
||||
index_name = query_dict.pop("index", "")
|
||||
|
||||
if not self.server_url:
|
||||
error = "Missing configuration key 'server'"
|
||||
return None, error
|
||||
|
||||
url = "{0}/{1}/_search".format(self.server_url, index_name)
|
||||
mapping_url = "{0}/{1}/_mapping".format(self.server_url, index_name)
|
||||
|
||||
mappings = self._get_mappings(mapping_url)
|
||||
|
||||
logger.debug(json.dumps(mappings, indent=4))
|
||||
|
||||
params = {"source": json.dumps(query_dict)}
|
||||
logger.debug("Using URL: %s", url)
|
||||
logger.debug("Using params : %s", params)
|
||||
r = requests.get(url, params=params, auth=self.auth)
|
||||
logger.debug("Result: %s", r.json())
|
||||
|
||||
result_columns = []
|
||||
result_rows = []
|
||||
self._parse_results(mappings, None, r.json(), result_columns, result_rows)
|
||||
|
||||
json_data = json.dumps({
|
||||
"columns" : result_columns,
|
||||
"rows" : result_rows
|
||||
})
|
||||
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
|
||||
|
||||
|
||||
register(Kibana)
|
||||
register(ElasticSearch)
|
||||
|
||||
@@ -13,9 +13,6 @@ try:
|
||||
from dateutil import parser
|
||||
enabled = True
|
||||
except ImportError:
|
||||
logger.warning("Missing dependencies. Please install gspread, dateutil and oauth2client.")
|
||||
logger.warning("You can use pip: pip install gspread dateutil oauth2client")
|
||||
|
||||
enabled = False
|
||||
|
||||
|
||||
@@ -27,22 +24,51 @@ def _load_key(filename):
|
||||
def _guess_type(value):
|
||||
try:
|
||||
val = int(value)
|
||||
return TYPE_INTEGER, val
|
||||
return TYPE_INTEGER
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
val = float(value)
|
||||
return TYPE_FLOAT, val
|
||||
return TYPE_FLOAT
|
||||
except ValueError:
|
||||
pass
|
||||
if str(value).lower() in ('true', 'false'):
|
||||
return TYPE_BOOLEAN, bool(value)
|
||||
return TYPE_BOOLEAN
|
||||
try:
|
||||
val = parser.parse(value)
|
||||
return TYPE_DATETIME, val
|
||||
return TYPE_DATETIME
|
||||
except ValueError:
|
||||
pass
|
||||
return TYPE_STRING, value
|
||||
return TYPE_STRING
|
||||
|
||||
|
||||
def _value_eval_list(value):
|
||||
value_list = []
|
||||
for member in value:
|
||||
try:
|
||||
val = int(member)
|
||||
value_list.append(val)
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
val = float(member)
|
||||
value_list.append(val)
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
if str(member).lower() in ('true', 'false'):
|
||||
val = bool(member)
|
||||
value_list.append(val)
|
||||
continue
|
||||
try:
|
||||
val = parser.parse(member)
|
||||
value_list.append(val)
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
value_list.append(member)
|
||||
return value_list
|
||||
|
||||
|
||||
class GoogleSpreadsheet(BaseQueryRunner):
|
||||
@@ -70,7 +96,8 @@ class GoogleSpreadsheet(BaseQueryRunner):
|
||||
'title': 'JSON Key File'
|
||||
}
|
||||
},
|
||||
'required': ['jsonKeyFile']
|
||||
'required': ['jsonKeyFile'],
|
||||
'secret': ['jsonKeyFile']
|
||||
}
|
||||
|
||||
def __init__(self, configuration_json):
|
||||
@@ -105,7 +132,7 @@ class GoogleSpreadsheet(BaseQueryRunner):
|
||||
'friendly_name': column_name,
|
||||
'type': _guess_type(all_data[self.HEADER_INDEX+1][j])
|
||||
})
|
||||
rows = [dict(zip(column_names, row)) for row in all_data[self.HEADER_INDEX+1:]]
|
||||
rows = [dict(zip(column_names, _value_eval_list(row))) for row in all_data[self.HEADER_INDEX+1:]]
|
||||
data = {'columns': columns, 'rows': rows}
|
||||
json_data = json.dumps(data, cls=JSONEncoder)
|
||||
error = None
|
||||
|
||||
@@ -44,7 +44,8 @@ class Graphite(BaseQueryRunner):
|
||||
'title': 'Verify SSL certificate'
|
||||
}
|
||||
},
|
||||
'required': ['url']
|
||||
'required': ['url'],
|
||||
'secret': ['password']
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -59,7 +60,7 @@ class Graphite(BaseQueryRunner):
|
||||
else:
|
||||
self.auth = None
|
||||
|
||||
self.verify = self.configuration["verify"]
|
||||
self.verify = self.configuration.get("verify", True)
|
||||
self.base_url = "%s/render?format=json&" % self.configuration['url']
|
||||
|
||||
def run_query(self, query):
|
||||
@@ -80,4 +81,4 @@ class Graphite(BaseQueryRunner):
|
||||
|
||||
return data, error
|
||||
|
||||
register(Graphite)
|
||||
register(Graphite)
|
||||
|
||||
132
redash/query_runner/hive_ds.py
Normal file
132
redash/query_runner/hive_ds.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from redash.query_runner import *
|
||||
from redash.utils import JSONEncoder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from pyhive import hive
|
||||
enabled = True
|
||||
except ImportError, e:
|
||||
enabled = False
|
||||
|
||||
COLUMN_NAME = 0
|
||||
COLUMN_TYPE = 1
|
||||
|
||||
types_map = {
|
||||
'BIGINT': TYPE_INTEGER,
|
||||
'TINYINT': TYPE_INTEGER,
|
||||
'SMALLINT': TYPE_INTEGER,
|
||||
'INT': TYPE_INTEGER,
|
||||
'DOUBLE': TYPE_FLOAT,
|
||||
'DECIMAL': TYPE_FLOAT,
|
||||
'FLOAT': TYPE_FLOAT,
|
||||
'REAL': TYPE_FLOAT,
|
||||
'BOOLEAN': TYPE_BOOLEAN,
|
||||
'TIMESTAMP': TYPE_DATETIME,
|
||||
'DATE': TYPE_DATETIME,
|
||||
'CHAR': TYPE_STRING,
|
||||
'STRING': TYPE_STRING,
|
||||
'VARCHAR': TYPE_STRING
|
||||
}
|
||||
|
||||
|
||||
class Hive(BaseQueryRunner):
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "number"
|
||||
},
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["host"]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def annotate_query(cls):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def type(cls):
|
||||
return "hive"
|
||||
|
||||
def __init__(self, configuration_json):
|
||||
super(Hive, self).__init__(configuration_json)
|
||||
|
||||
def get_schema(self):
|
||||
try:
|
||||
schemas_query = "show schemas"
|
||||
|
||||
tables_query = "show tables in %s"
|
||||
|
||||
columns_query = "show columns in %s"
|
||||
|
||||
schema = {}
|
||||
for schema_name in filter(lambda a: len(a) > 0, map(lambda a: str(a['database_name']), self._run_query_internal(schemas_query))):
|
||||
for table_name in filter(lambda a: len(a) > 0, map(lambda a: str(a['tab_name']), self._run_query_internal(tables_query % schema_name))):
|
||||
columns = filter(lambda a: len(a) > 0, map(lambda a: str(a['field']), self._run_query_internal(columns_query % table_name)))
|
||||
|
||||
if schema_name != 'default':
|
||||
table_name = '{}.{}'.format(schema_name, table_name)
|
||||
|
||||
schema[table_name] = {'name': table_name, 'columns': columns}
|
||||
except Exception, e:
|
||||
raise sys.exc_info()[1], None, sys.exc_info()[2]
|
||||
return schema.values()
|
||||
|
||||
def run_query(self, query):
|
||||
|
||||
connection = None
|
||||
try:
|
||||
connection = hive.connect(**self.configuration)
|
||||
|
||||
cursor = connection.cursor()
|
||||
|
||||
cursor.execute(query)
|
||||
|
||||
column_names = []
|
||||
columns = []
|
||||
|
||||
for column in cursor.description:
|
||||
column_name = column[COLUMN_NAME]
|
||||
column_names.append(column_name)
|
||||
|
||||
columns.append({
|
||||
'name': column_name,
|
||||
'friendly_name': column_name,
|
||||
'type': types_map.get(column[COLUMN_TYPE], 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 KeyboardInterrupt:
|
||||
connection.cancel()
|
||||
error = "Query cancelled by user."
|
||||
json_data = None
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
raise sys.exc_info()[1], None, sys.exc_info()[2]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
return json_data, error
|
||||
|
||||
register(Hive)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user