mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Compare commits
251 Commits
v0.10.1.b1
...
v0.11.1.b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ea79b1e9 | ||
|
|
432fe9b8a3 | ||
|
|
33909a1b32 | ||
|
|
9bca3933e7 | ||
|
|
e1dd1b3f71 | ||
|
|
bba801f9d5 | ||
|
|
b41041014f | ||
|
|
31edf9cf80 | ||
|
|
522e07ac95 | ||
|
|
837073144f | ||
|
|
9895e28a3f | ||
|
|
ae9e295d2f | ||
|
|
7681d3ee84 | ||
|
|
458f5eb032 | ||
|
|
ce81d69f91 | ||
|
|
0456caf798 | ||
|
|
bcd3670282 | ||
|
|
f7e556969a | ||
|
|
dd759fe4b0 | ||
|
|
988b301f65 | ||
|
|
923b3b18e4 | ||
|
|
95c47138ab | ||
|
|
31e7375a30 | ||
|
|
8808e38de9 | ||
|
|
0314313285 | ||
|
|
9c0d1da7f9 | ||
|
|
904ea9f90a | ||
|
|
6bb09d8446 | ||
|
|
d5e5b2438b | ||
|
|
dbd48e15bc | ||
|
|
21fdd6b69d | ||
|
|
a666adeaa7 | ||
|
|
15361cc81c | ||
|
|
704a167c74 | ||
|
|
df2c8d83b0 | ||
|
|
7445972c10 | ||
|
|
1933995a28 | ||
|
|
8df822eee2 | ||
|
|
227fe9b44a | ||
|
|
5d0ed02caa | ||
|
|
392627d6d6 | ||
|
|
72d02e9e9d | ||
|
|
902ce24f6f | ||
|
|
c5bfbbaef7 | ||
|
|
a03f5f88fb | ||
|
|
a66e182f73 | ||
|
|
96dd811607 | ||
|
|
409200188e | ||
|
|
ad65391914 | ||
|
|
203f6afa09 | ||
|
|
2b710420ab | ||
|
|
01116f41ed | ||
|
|
87e25f2107 | ||
|
|
c495250a54 | ||
|
|
c01d266030 | ||
|
|
8515ac25bc | ||
|
|
23988a72aa | ||
|
|
6bc0e7a716 | ||
|
|
2c2ff0d252 | ||
|
|
69cefee0d4 | ||
|
|
02c065751a | ||
|
|
aed65f4bad | ||
|
|
6bb2716fe3 | ||
|
|
efaeb08178 | ||
|
|
e18a073128 | ||
|
|
f21276ec06 | ||
|
|
b0c0582e41 | ||
|
|
9ad85091ed | ||
|
|
2d2fb69b7b | ||
|
|
3ce27b9652 | ||
|
|
da4db94cf8 | ||
|
|
4cbc79a7aa | ||
|
|
4fabaaea8a | ||
|
|
a7af596da0 | ||
|
|
df637e3f6b | ||
|
|
68465b0c60 | ||
|
|
86565402fa | ||
|
|
c2e3637dce | ||
|
|
52558043ee | ||
|
|
a045d7ddf7 | ||
|
|
c107c94a27 | ||
|
|
790128ce77 | ||
|
|
abc790ce41 | ||
|
|
f2643521f7 | ||
|
|
0d897e6878 | ||
|
|
4ec473cf5e | ||
|
|
0c7f0c25a8 | ||
|
|
8c21e9149d | ||
|
|
7159f0beb0 | ||
|
|
095e7596b5 | ||
|
|
31013836ea | ||
|
|
b67f412f58 | ||
|
|
c1bf9dc67d | ||
|
|
65635ec703 | ||
|
|
ceaa00e448 | ||
|
|
679b0a3125 | ||
|
|
fe81dbd3a2 | ||
|
|
1409907ef1 | ||
|
|
cbbfc4e931 | ||
|
|
1ca5262fa8 | ||
|
|
429b76f5a7 | ||
|
|
8b73a2b135 | ||
|
|
eed5485080 | ||
|
|
daa6c1cd6f | ||
|
|
68dc3b033c | ||
|
|
2e88e7f396 | ||
|
|
cd06d276e4 | ||
|
|
437f589fde | ||
|
|
1fbeb5d2a5 | ||
|
|
df1e72ca01 | ||
|
|
fcc656e04e | ||
|
|
a0b97c1fc9 | ||
|
|
4d6599e0ea | ||
|
|
c75054b320 | ||
|
|
011ca74338 | ||
|
|
434615a1be | ||
|
|
2bc0b276b5 | ||
|
|
e942486ed7 | ||
|
|
9eff7ef8c9 | ||
|
|
34b305d232 | ||
|
|
f0d97bc5d1 | ||
|
|
f64622db77 | ||
|
|
8030baa6a5 | ||
|
|
3d82b702b3 | ||
|
|
ad8676df2e | ||
|
|
ea031e9a98 | ||
|
|
9cfebedec9 | ||
|
|
772d263827 | ||
|
|
8c455c8a1c | ||
|
|
857caab20e | ||
|
|
59f8af2c44 | ||
|
|
9538ee7c31 | ||
|
|
e8312185dc | ||
|
|
07d2b5ba42 | ||
|
|
f8120284d5 | ||
|
|
5b654fd1c8 | ||
|
|
6edb0ca8ec | ||
|
|
ef0de1414d | ||
|
|
214aa3b799 | ||
|
|
64d7538040 | ||
|
|
69177752bc | ||
|
|
d83c6c42dd | ||
|
|
2043834ae9 | ||
|
|
d6f4af448c | ||
|
|
43b425f91c | ||
|
|
17427cf47b | ||
|
|
b5be5a8fa4 | ||
|
|
14fcf01751 | ||
|
|
09848d65a1 | ||
|
|
0d897ea959 | ||
|
|
e88d4c3d27 | ||
|
|
82f0b4c386 | ||
|
|
3037c4f90d | ||
|
|
8900d02c95 | ||
|
|
c1c2db4a73 | ||
|
|
574d8a18ae | ||
|
|
82872db111 | ||
|
|
3f90dd9247 | ||
|
|
b2e2277d0b | ||
|
|
e20a00566a | ||
|
|
e10ecd2dad | ||
|
|
6e0dd2b9a3 | ||
|
|
0bb3fb9c40 | ||
|
|
1a1160eb76 | ||
|
|
d4ae97aab2 | ||
|
|
8bc42c8ad9 | ||
|
|
6c5865bd3b | ||
|
|
701035fabd | ||
|
|
31aee1b6b9 | ||
|
|
367ea859e4 | ||
|
|
d79d3da955 | ||
|
|
6c822d1e4b | ||
|
|
ad85b9a62c | ||
|
|
b5a4a6b880 | ||
|
|
1828de20b0 | ||
|
|
48c85645c6 | ||
|
|
ed45dcb01d | ||
|
|
d4ff7482ad | ||
|
|
90f0b3b49a | ||
|
|
f8efb2d7ea | ||
|
|
d2ba0cb6cf | ||
|
|
cfb852e9c5 | ||
|
|
d5c6e57c62 | ||
|
|
2924d4fce6 | ||
|
|
e602b8cf2b | ||
|
|
0b806e2e7d | ||
|
|
c3c302e11e | ||
|
|
aa837ed09b | ||
|
|
f07e7273c1 | ||
|
|
9b6f555d76 | ||
|
|
e069374232 | ||
|
|
c496df3b87 | ||
|
|
2ee0065102 | ||
|
|
c0ffea7083 | ||
|
|
fec0d5fecc | ||
|
|
83a03a22b1 | ||
|
|
8b5dc8ef68 | ||
|
|
f3a274a5c0 | ||
|
|
386d6efdaa | ||
|
|
e415189017 | ||
|
|
b066ce4b74 | ||
|
|
056ae4f63e | ||
|
|
6d495d2f2c | ||
|
|
960c416fcb | ||
|
|
f7322a413f | ||
|
|
d9cc063be2 | ||
|
|
8fa6fdb0d5 | ||
|
|
7016477700 | ||
|
|
0bb722df5d | ||
|
|
b3844d3643 | ||
|
|
e32bfe3db7 | ||
|
|
4591eff557 | ||
|
|
7062873cd1 | ||
|
|
9e23cc2bf2 | ||
|
|
c5d92b4e7e | ||
|
|
41dfcd8cbf | ||
|
|
1fa701c136 | ||
|
|
303e158eb1 | ||
|
|
19aaa938d8 | ||
|
|
4bcb705a2a | ||
|
|
1c04f3cc29 | ||
|
|
ee29f07802 | ||
|
|
df2067eec1 | ||
|
|
601010e44e | ||
|
|
6c3b713b3d | ||
|
|
faf2f7dede | ||
|
|
bf880a834b | ||
|
|
ce6ceac5c4 | ||
|
|
70b4f9d447 | ||
|
|
3838b03417 | ||
|
|
a11fa2717d | ||
|
|
becf315e66 | ||
|
|
04eb37a7f2 | ||
|
|
e91610f4b4 | ||
|
|
63786c98df | ||
|
|
54f3df6988 | ||
|
|
bb3874e631 | ||
|
|
eef18510d5 | ||
|
|
a3c0917d85 | ||
|
|
ed7f9ea5f0 | ||
|
|
82b7146216 | ||
|
|
3cfbb9855b | ||
|
|
4938f8e013 | ||
|
|
a43761da39 | ||
|
|
a3703b2058 | ||
|
|
f2d5d52310 | ||
|
|
eed2a41816 | ||
|
|
16c0df4117 | ||
|
|
3844483776 | ||
|
|
53f8f1de3b | ||
|
|
3ac7f02aea |
24
.github/ISSUE_TEMPLATE.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
Welcome to Redash's GitHub repo! 👋🎉
|
||||
|
||||
Do you need help or have a question? Checkout the Support category in our discussion forum: https://discuss.redash.io/c/support.
|
||||
|
||||
Got an idea for a new feature? Check if it isn't on the roadmap already: http://bit.ly/redash-roadmap and start a new discussion in the features category: https://discuss.redash.io/c/feature-requests 🌟.
|
||||
|
||||
Found a bug? Please fill out the sections below... thank you 👍
|
||||
|
||||
### Issue Summary
|
||||
|
||||
A summary of the issue and the browser/OS environment in which it occurs.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. This is the first step
|
||||
2. This is the second step, etc.
|
||||
|
||||
Any other info e.g. Why do you consider this to be a bug? What did you expect to happen instead?
|
||||
|
||||
### Technical details:
|
||||
|
||||
* Redash Version:
|
||||
* Browser/OS:
|
||||
* How did you install Redash:
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@ dump.rdb
|
||||
|
||||
# Docker related
|
||||
docker-compose.yml
|
||||
|
||||
node_modules
|
||||
.tmp
|
||||
.sass-cache
|
||||
rd_ui/app/bower_components
|
||||
|
||||
79
CONTRIBUTING.md
Normal file
79
CONTRIBUTING.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Contributing Guide
|
||||
|
||||
Thank you for taking the time to contribute! :tada::+1:
|
||||
|
||||
The following is a set of guidelines for contributing to Redash. These are guidelines, not rules, please use your best judgement and feel free to propose changes to this document in a pull request.
|
||||
|
||||
## Quick Links:
|
||||
|
||||
- [Feature Roadmap](https://trello.com/b/b2LUHU7A/re-dash-roadmap)
|
||||
- [Feature Requests](https://discuss.redash.io/c/feature-requests)
|
||||
- [Gitter Chat](https://gitter.im/getredash/redash) or [Slack](https://slack.redash.io)
|
||||
- [Documentation](http://docs.redash.io)
|
||||
- [Blog](http://blog.redash.io/)
|
||||
- [Twitter](https://twitter.com/getredash)
|
||||
|
||||
---
|
||||
:star: If you already here and love the project, please make sure to press the Star button. :star:
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Table of Contents
|
||||
|
||||
[How can I contribute?](#how-can-i-contribute)
|
||||
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Enhancements / Feature Requests](#suggesting-enhancements--feature-requests)
|
||||
- [Pull Requests](#pull-requests)
|
||||
- [Documentation](#documentation)
|
||||
- Design?
|
||||
|
||||
[Addtional Notes](#additional-notes)
|
||||
|
||||
- [Release Method](#release-method)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
|
||||
## How can I contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
When creating a new bug report, please make sure to:
|
||||
|
||||
- Search for existing issues first. If you find a previous report of your issue, please update the existing issue with additional information instead of creating a new one.
|
||||
- If you are not sure if your issue is really a bug or just some configuration/setup problem, please start a discussion in [the support forum](https://discuss.redash.io/c/support) first. Unless you can provide clear steps to reproduce, it's probably better to start with a thread in the forum and later to open an issue.
|
||||
- If you still decide to open an issue, please review the template and guidelines and include as much details as possible.
|
||||
|
||||
### Suggesting Enhancements / Feature Requests
|
||||
|
||||
If you would like to suggest an enchancement or ask for a new feature:
|
||||
|
||||
- Please check [the roadmap](https://trello.com/b/b2LUHU7A/re-dash-roadmap) for existing Trello card for what you want to suggest/ask. If there is, feel free to upvote it to signal interest or add your comments.
|
||||
- If there is no existing card, open a thread in [the forum](https://discuss.redash.io/c/feature-requests) to start a discussion about what you want to suggest. Try to provide as much details and context as possible and include information about *the problem you want to solve* rather only *your proposed solution*.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- **Code contributions are welcomed**. For big changes or significant features, it's usually better to reach out first and discuss what you want to implement and how (we recommend reading: [Pull Request First](https://medium.com/practical-blend/pull-request-first-f6bb667a9b6#.ozlqxvj36)). This to make sure that what you want to implement is aligned with our goals for the project and that no one else is already working on it.
|
||||
- Include screenshots and animated GIFs in your pull request whenever possible.
|
||||
- Please add [documentation](#documentation) for new features or changes in functionality along with the code.
|
||||
- Please follow existing code style. We use PEP8 for Python and sensible style for Javascript.
|
||||
|
||||
### Documentation
|
||||
|
||||
The project's documentation can be found at [docs.redash.io](http://docs.redash.io/). The [documentation sources](https://github.com/getredash/redash/tree/master/docs) are managed along with the code and to contribute edits / new pages, you can use GitHub's interface. Click the "Edit on GitHub" link on the documentation page to quickly open the edit interface.
|
||||
|
||||
The pages are written in *reStructuredText* format, which is very similar to Markdown.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
### Release Method
|
||||
|
||||
We publish a stable release every ~2 months, although the goal is to get to a stable release every month. You can see the change log on [GitHub releases page](http://github.com/getredash/redash/releases).
|
||||
|
||||
Every build of the master branch updates the latest *RC release*. These releases are usually stable, but might contain regressions and therefore recommended for "advanced users" only.
|
||||
|
||||
When we release a new stable release, we also update the *latest* Docker image tag, the EC2 AMIs and GCE images.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project adheres to the Contributor Covenant [code of conduct](http://redash.io/community/code_of_conduct). By participating, you are expected to uphold this code. Please report unacceptable behavior to team@redash.io.
|
||||
@@ -6,7 +6,7 @@ RUN apt-get update && \
|
||||
# Postgres client
|
||||
libpq-dev \
|
||||
# Additional packages required for data sources:
|
||||
libssl-dev libmysqlclient-dev freetds-dev && \
|
||||
libssl-dev libmysqlclient-dev freetds-dev libsasl2-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -14,7 +14,7 @@ RUN apt-get update && \
|
||||
RUN useradd --system --comment " " --create-home redash
|
||||
|
||||
# Pip requirements for all data source types
|
||||
RUN pip install -U setuptools && \
|
||||
RUN pip install -U setuptools==23.1.0 && \
|
||||
pip install supervisor==3.1.2
|
||||
|
||||
COPY . /opt/redash/current
|
||||
@@ -32,7 +32,7 @@ RUN pip install -r requirements_all_ds.txt && \
|
||||
RUN curl https://deb.nodesource.com/setup_4.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
sudo -u redash -H make deps && \
|
||||
rm -rf rd_ui/node_modules /home/redash/.npm /home/redash/.cache && \
|
||||
rm -rf node_modules rd_ui/node_modules /home/redash/.npm /home/redash/.cache && \
|
||||
apt-get purge -y nodejs && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
10
Makefile
10
Makefile
@@ -6,17 +6,17 @@ BASE_VERSION=$(shell python ./manage.py version | cut -d + -f 1)
|
||||
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(VERSION).tar.gz
|
||||
|
||||
deps:
|
||||
if [ -d "./rd_ui/app" ]; then cd rd_ui && npm install; fi
|
||||
if [ -d "./rd_ui/app" ]; then cd rd_ui && npm run bower install; fi
|
||||
if [ -d "./rd_ui/app" ]; then cd rd_ui && npm run build; fi
|
||||
if [ -d "./rd_ui/app" ]; then npm install; fi
|
||||
if [ -d "./rd_ui/app" ]; then npm run bower install; fi
|
||||
if [ -d "./rd_ui/app" ]; then npm run build; fi
|
||||
|
||||
pack:
|
||||
sed -ri "s/^__version__ = '([0-9.]*)'/__version__ = '$(FULL_VERSION)'/" redash/__init__.py
|
||||
tar -zcv -f $(FILENAME) --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="rd_ui/node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
|
||||
tar -zcv -f $(FILENAME) --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
|
||||
|
||||
upload:
|
||||
python bin/release_manager.py $(CIRCLE_SHA1) $(BASE_VERSION) $(FILENAME)
|
||||
|
||||
test:
|
||||
nosetests --with-coverage --cover-package=redash tests/
|
||||
#cd rd_ui && grunt test
|
||||
#grunt test
|
||||
|
||||
2
Procfile.heroku
Normal file
2
Procfile.heroku
Normal file
@@ -0,0 +1,2 @@
|
||||
web: ./manage.py runserver -d -r -p $PORT --host 0.0.0.0
|
||||
worker: celery worker --app=redash.worker -c2 --beat -Q queries,celery,scheduled_queries
|
||||
@@ -42,7 +42,7 @@ You can try out the demo instance: http://demo.redash.io/ (login with any Google
|
||||
## Getting Help
|
||||
|
||||
* Issues: https://github.com/getredash/redash/issues
|
||||
* Mailing List: https://groups.google.com/forum/#!forum/redash-users
|
||||
* Discussion Forum: https://discuss.redash.io/
|
||||
* Slack: http://slack.redash.io/
|
||||
* Gitter (chat): https://gitter.im/getredash/redash
|
||||
|
||||
|
||||
4
Vagrantfile
vendored
4
Vagrantfile
vendored
@@ -8,4 +8,8 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
config.vm.box = "redash/dev"
|
||||
config.vm.synced_folder "./", "/opt/redash/current"
|
||||
config.vm.network "forwarded_port", guest: 5000, host: 9001
|
||||
config.vm.provision "shell" do |s|
|
||||
s.inline = "/opt/redash/current/setup/vagrant/provision.sh"
|
||||
s.privileged = false
|
||||
end
|
||||
end
|
||||
|
||||
18
bin/pre_compile
Normal file
18
bin/pre_compile
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Heroku pre_compile script
|
||||
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
pushd $DIR/..
|
||||
|
||||
# heroku requires cffi to be in requirements.txt in order for libffi to be installed.
|
||||
# https://github.com/heroku/heroku-buildpack-python/blob/master/bin/steps/cryptography
|
||||
# to avoid making it a requirement for other build systems, we'll inject it now
|
||||
# into the requirements.txt file
|
||||
|
||||
# Remove Heroku unsupported Python packages:
|
||||
grep -v -E "^(pymssql|thrift|sasl|pyhive)" requirements_all_ds.txt >> requirements.txt
|
||||
|
||||
# make the heroku Procfile the active one
|
||||
cp Procfile.heroku Procfile
|
||||
|
||||
popd
|
||||
21
bin/vagrant_ctl.sh
Executable file
21
bin/vagrant_ctl.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
help() {
|
||||
echo "Usage: "
|
||||
echo "`basename "$0"` {start, test}"
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
vagrant up
|
||||
vagrant ssh -c "cd /opt/redash/current; bin/run honcho start -f Procfile.dev;"
|
||||
;;
|
||||
test)
|
||||
vagrant up
|
||||
vagrant ssh -c "cd /opt/redash/current; make test"
|
||||
;;
|
||||
*)
|
||||
help
|
||||
;;
|
||||
esac
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rdUi",
|
||||
"version": "0.1.0",
|
||||
"name": "redash",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"angular": "1.2.18",
|
||||
"angular-resource": "1.2.18",
|
||||
@@ -12,16 +12,16 @@ dependencies:
|
||||
- pip install -r requirements_dev.txt
|
||||
- pip install -r requirements.txt
|
||||
- pip install pymongo==3.2.1
|
||||
- if [ "$CIRCLE_BRANCH" = "master" ]; then make deps; fi
|
||||
- make deps
|
||||
cache_directories:
|
||||
- rd_ui/node_modules/
|
||||
- node_modules/
|
||||
- rd_ui/app/bower_components/
|
||||
test:
|
||||
override:
|
||||
- nosetests --with-xunit --xunit-file=$CIRCLE_TEST_REPORTS/junit.xml --with-coverage --cover-package=redash tests/
|
||||
deployment:
|
||||
github_and_docker:
|
||||
branch: master
|
||||
branch: [master, /release_.*/]
|
||||
commands:
|
||||
- make pack
|
||||
- make upload
|
||||
|
||||
@@ -16,7 +16,7 @@ redis:
|
||||
postgres:
|
||||
image: postgres:9.3
|
||||
volumes:
|
||||
- /opt/postgres-data:/var/lib/postgresql/data
|
||||
- /opt/postgres-data:/var/lib/postgresql/data
|
||||
redash-nginx:
|
||||
image: redash/nginx:latest
|
||||
ports:
|
||||
|
||||
34
docs/dev/saml.rst
Normal file
34
docs/dev/saml.rst
Normal file
@@ -0,0 +1,34 @@
|
||||
SAML Authentication and Authorization
|
||||
#####################################
|
||||
|
||||
Authentication
|
||||
==============
|
||||
|
||||
Add to your .env file REDASH_SAML_METADATA_URL config value which
|
||||
needs to point to the SAML provider metadata url, eg https://app.onelogin.com/saml/metadata/
|
||||
|
||||
And an optional REDASH_SAML_CALLBACK_SERVER_NAME which contains the
|
||||
server name of the redash server for the callbacks from the SAML provider (eg demo.redash.io)
|
||||
|
||||
On the SAML provider side, example configuration for OneLogin is:
|
||||
SAML Consumer URL: http://demo.redash.io/saml/login
|
||||
SAML Audience: http://demo.redash.io/saml/callback
|
||||
SAML Recipient: http://demo.redash.io/saml/callback
|
||||
|
||||
Example configuration for Okta is:
|
||||
Single Sign On URL: http://demo.redash.io/saml/callback
|
||||
Recipient URL: http://demo.redash.io/saml/callback
|
||||
Destination URL: http://demo.redash.io/saml/callback
|
||||
|
||||
with parameters 'FirstName' and 'LastName', both configured to be included in the SAML assertion.
|
||||
|
||||
|
||||
Authorization
|
||||
=============
|
||||
To manage group assignments in Redash using your SAML provider, configure SAML response to include
|
||||
attribute with key 'RedashGroups', and value as names of groups in Redash.
|
||||
|
||||
Example configuration for Okta is:
|
||||
In the Group Attribute Statements -
|
||||
Name: RedashGroups
|
||||
Filter: Starts with: this-is-a-group-in-redash
|
||||
@@ -14,27 +14,10 @@ To get started with this box:
|
||||
`Vagrant <https://www.vagrantup.com/>`__ installed.
|
||||
2. Clone the Re:dash repository:
|
||||
``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,
|
||||
3. Change dir into the repository (``cd redash``)
|
||||
4a. To execute tests, run ``./bin/vagrant_ctl.sh test``
|
||||
4b. To run the app, run ``./bin/vagrant_ctl.sh start``.
|
||||
This might take some time the first time you run it,
|
||||
as it downloads the Vagrant virtual box.
|
||||
4. Once Vagrant is ready, ssh into the instance (``vagrant ssh``), and
|
||||
change dir to ``/opt/redash/current`` -- this is where your local
|
||||
repository copy synced to.
|
||||
5. Copy ``.env`` file into this directory (``cp ../.env ./``).
|
||||
6. From ``/opt/redash/current/rd_ui`` run ``bower install`` to install
|
||||
frontend packages. This can be done from your host machine as well,
|
||||
if you have bower installed.
|
||||
7. Go back to ``/opt/redash/current`` and install python dependencies
|
||||
``sudo pip install -r requirements.txt``
|
||||
8. Update database schema to the latest version:
|
||||
|
||||
::
|
||||
|
||||
bin/run ./manage.py database drop_tables
|
||||
bin/run ./manage.py database create_tables
|
||||
bin/run ./manage.py users create --admin --password admin "Admin" "admin"
|
||||
|
||||
9. Start the server and background workers with
|
||||
``bin/run honcho start -f Procfile.dev``.
|
||||
10. Now the server should be available on your host on port 9001 and you
|
||||
Now the server should be available on your host on port 9001 and you
|
||||
can login with username admin and password admin.
|
||||
|
||||
@@ -39,7 +39,7 @@ Getting Help
|
||||
|
||||
* Source: https://github.com/getredash/redash
|
||||
* Issues: https://github.com/getredash/redash/issues
|
||||
* Mailing List: https://groups.google.com/forum/#!forum/redash-users
|
||||
* Discussion Forum: https://discuss.redash.io/
|
||||
* Slack: http://slack.redash.io/
|
||||
* Gitter (chat): https://gitter.im/getredash/redash
|
||||
|
||||
|
||||
@@ -42,6 +42,13 @@ SSL with your instance you need to:
|
||||
ssl_certificate /path-to/cert.pem; # or crt
|
||||
ssl_certificate_key /path-to/cert.key;
|
||||
|
||||
# Specifies that we don't want to use SSLv2 (insecure) or SSLv3 (exploitable)
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
# Uses the server's ciphers rather than the client's
|
||||
ssl_prefer_server_ciphers on;
|
||||
# Specifies which ciphers are okay and which are not okay. List taken from https://raymii.org/s/tutorials/Strong_SSL_Security_On_nginx.html
|
||||
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:ECDHE-RSA-AES128-GCM-SHA256:AES256+EECDH:DHE-RSA-AES128-GCM-SHA256:AES256+EDH:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
|
||||
|
||||
access_log /var/log/nginx/redash.access.log;
|
||||
|
||||
gzip on;
|
||||
|
||||
@@ -18,17 +18,17 @@ AWS
|
||||
Launch the instance with from the pre-baked AMI (for small deployments
|
||||
t2.micro should be enough):
|
||||
|
||||
- us-east-1: `ami-a7ddfbcd <https://console.aws.amazon.com/ec2/home?region=us-east-1#LaunchInstanceWizard:ami=ami-a7ddfbcd>`__
|
||||
- us-west-1: `ami-269feb46 <https://console.aws.amazon.com/ec2/home?region=us-west-1#LaunchInstanceWizard:ami=ami-269feb46>`__
|
||||
- us-west-2: `ami-435fba23 <https://console.aws.amazon.com/ec2/home?region=us-west-2#LaunchInstanceWizard:ami=ami-435fba23>`__
|
||||
- eu-west-1: `ami-b4c277c7 <https://console.aws.amazon.com/ec2/home?region=eu-west-1#LaunchInstanceWizard:ami=ami-b4c277c7>`__
|
||||
- eu-central-1: `ami-07ced76b <https://console.aws.amazon.com/ec2/home?region=eu-central-1#LaunchInstanceWizard:ami=ami-07ced76b>`__
|
||||
- sa-east-1: `ami-6e2eaf02 <https://console.aws.amazon.com/ec2/home?region=sa-east-1#LaunchInstanceWizard:ami=ami-6e2eaf02>`__
|
||||
- ap-northeast-1: `ami-aa5a64c4 <https://console.aws.amazon.com/ec2/home?region=ap-northeast-1#LaunchInstanceWizard:ami=ami-aa5a64c4>`__
|
||||
- ap-southeast-1: `ami-1c45897f <https://console.aws.amazon.com/ec2/home?region=ap-southeast-1#LaunchInstanceWizard:ami=ami-1c45897f>`__
|
||||
- ap-southeast-2: `ami-42b79221 <https://console.aws.amazon.com/ec2/home?region=ap-southeast-2#LaunchInstanceWizard:ami=ami-42b79221>`__
|
||||
- us-east-1: `ami-52c3373f <https://console.aws.amazon.com/ec2/home?region=us-east-1#LaunchInstanceWizard:ami=ami-52c3373f>`__
|
||||
- us-west-1: `ami-c6c5bda6 <https://console.aws.amazon.com/ec2/home?region=us-west-1#LaunchInstanceWizard:ami=ami-c6c5bda6>`__
|
||||
- us-west-2: `ami-f0b04e90 <https://console.aws.amazon.com/ec2/home?region=us-west-2#LaunchInstanceWizard:ami=ami-f0b04e90>`__
|
||||
- eu-west-1: `ami-f3910780 <https://console.aws.amazon.com/ec2/home?region=eu-west-1#LaunchInstanceWizard:ami=ami-f3910780>`__
|
||||
- eu-central-1: `ami-00719d6f <https://console.aws.amazon.com/ec2/home?region=eu-central-1#LaunchInstanceWizard:ami=ami-00719d6f>`__
|
||||
- sa-east-1: `ami-af2fa7c3 <https://console.aws.amazon.com/ec2/home?region=sa-east-1#LaunchInstanceWizard:ami=ami-af2fa7c3>`__
|
||||
- ap-northeast-1: `ami-78967519 <https://console.aws.amazon.com/ec2/home?region=ap-northeast-1#LaunchInstanceWizard:ami=ami-78967519>`__
|
||||
- ap-southeast-1: `ami-bdbb6ade <https://console.aws.amazon.com/ec2/home?region=ap-southeast-1#LaunchInstanceWizard:ami=ami-bdbb6ade>`__
|
||||
- ap-southeast-2: `ami-8edbf4ed <https://console.aws.amazon.com/ec2/home?region=ap-southeast-2#LaunchInstanceWizard:ami=ami-8edbf4ed>`__
|
||||
|
||||
(the above AMIs are of version: 0.9.1)
|
||||
(the above AMIs are of version: 0.10.1)
|
||||
|
||||
When launching the instance make sure to use a security group, that **only** allows incoming traffic on: port 22 (SSH), 80 (HTTP) and 443 (HTTPS). These AMIs are based on Ubuntu so you will need to use the user ``ubuntu`` when connecting to the instance via SSH.
|
||||
|
||||
@@ -44,8 +44,9 @@ First, you need to add the images to your account:
|
||||
$ gcloud compute images create "redash-091-b1377" --source-uri gs://redash-images/redash.0.9.1.b1377.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,
|
||||
you can use a dedicated image which comes with BigQuery preconfigured
|
||||
instance type is recommended).
|
||||
|
||||
If you plan using Re:dash with BigQuery, you can use a dedicated image which comes with BigQuery preconfigured
|
||||
(using instance permissions):
|
||||
|
||||
.. code:: bash
|
||||
@@ -61,7 +62,8 @@ Note that you need to launch this instance with BigQuery access:
|
||||
(the same can be done from the web interface, just make sure to enable
|
||||
BigQuery access)
|
||||
|
||||
Now proceed to `"Setup" <#setup>`__.
|
||||
Please note that currently the Google Compute Engine images are for version 0.9.1. After creating the instance, please
|
||||
run the :doc:`upgrade process <upgrade>` and then proceed to `"Setup" <#setup>`__.
|
||||
|
||||
Docker Compose
|
||||
------
|
||||
@@ -70,13 +72,56 @@ Docker Compose
|
||||
2. Make sure your current working directory is the root of this GitHub repository.
|
||||
3. Run ``docker-compose up postgres``.
|
||||
4. Run ``./setup/docker/create_database.sh``. This will access the postgres container and set up the database.
|
||||
5. Run ``docker compose up``
|
||||
5. Run ``docker-compose up``
|
||||
6. Run ``docker-machine ls``, take note of the ip for the Docker machine you are using, and open the web browser.
|
||||
7. Visit that Docker machine IP at port 80, and you should see a Re:dash login screen.
|
||||
|
||||
Now proceed to `"Setup" <#setup>`__.
|
||||
|
||||
|
||||
Heroku
|
||||
------
|
||||
|
||||
Due to the nature of Heroku deployments, upgrading to a newer version of Redash
|
||||
requires performing the steps outlined on the `"How to Upgrade" <http://docs.redash.io/en/latest/upgrade.html>`__ page.
|
||||
|
||||
1. Install `Heroku CLI <https://toolbelt.heroku.com/>`__.
|
||||
|
||||
2. Create Heroku App::
|
||||
|
||||
$ heroku apps:create <app name>
|
||||
|
||||
2. Set application buildpacks::
|
||||
|
||||
$ heroku buildpacks:set heroku/python
|
||||
$ heroku buildpacks:add --index 1 heroku/nodejs
|
||||
|
||||
3. Add Postgres and Redis addons::
|
||||
|
||||
$ heroku addons:create heroku-postgresql:hobby-dev
|
||||
$ heroku addons:create heroku-redis:hobby-dev
|
||||
|
||||
4. Update the cookie secret (**Important** otherwise anyone can sign new cookies and impersonate users. You may be able to run the command ``pwgen 32 -1`` to generate a random string)::
|
||||
|
||||
$ heroku config:set REDASH_COOKIE_SECRET='<create a secret token and put here>'
|
||||
|
||||
5. Push the repository to Heroku::
|
||||
|
||||
$ git push heroku master
|
||||
|
||||
6. Create database tables::
|
||||
|
||||
$ heroku run ./manage.py database create_tables
|
||||
|
||||
7. Create admin user::
|
||||
|
||||
$ heroku run ./manage.py users create --admin "Admin" admin
|
||||
|
||||
7. Start worker process::
|
||||
|
||||
$ heroku ps:scale worker=1
|
||||
|
||||
|
||||
Other
|
||||
-----
|
||||
|
||||
@@ -187,6 +232,11 @@ It's recommended to upgrade once in a while your Re:dash instance to
|
||||
benefit from bug fixes and new features. See :doc:`here </upgrade>` for full upgrade
|
||||
instructions (including Fabric script).
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
For a full list of environment variables, see :doc:`the settings page </settings>`.
|
||||
|
||||
Notes
|
||||
=====
|
||||
|
||||
|
||||
@@ -70,3 +70,43 @@ Version
|
||||
See current version:
|
||||
|
||||
``bin/run ./manage.py version``
|
||||
|
||||
Monitoring
|
||||
==========
|
||||
Re:dash ships by default with a HTTP handler that gives you useful information about the
|
||||
health of your application. The endpoint is ``/status.json`` and requires a super admin
|
||||
API key to be given if you're not already logged in. This API key can be obtained from
|
||||
the dedicated tab in your profile.
|
||||
|
||||
You'll find below an example output of this endpoint:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"dashboards_count": 30,
|
||||
"manager": {
|
||||
"last_refresh_at": "1465392784.433638",
|
||||
"outdated_queries_count": 1,
|
||||
"query_ids": "[34]",
|
||||
"queues": {
|
||||
"queries": {
|
||||
"data_sources": "Redshift data, re:dash metadata, MySQL data, MySQL read-only, Redshift read-only",
|
||||
"size": 1
|
||||
},
|
||||
"scheduled_queries": {
|
||||
"data_sources": "Redshift data, re:dash metadata, MySQL data, MySQL read-only, Redshift read-only",
|
||||
"size": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries_count": 204,
|
||||
"query_results_count": 11161,
|
||||
"redis_used_memory": "6.09M",
|
||||
"unused_query_results_count": 32,
|
||||
"version": "0.10.0+b1774",
|
||||
"widgets_count": 176,
|
||||
"workers": []
|
||||
}
|
||||
|
||||
|
||||
If you plan to hit this endpoint without being logged in, you'll need to provide your API key as a query parameter. Example endpoint with an API key: ``/status.json?api_key=fooBarqsLlGJQIs3maPErUxKuxwWGIpDXoSzQsx7xdv``
|
||||
|
||||
@@ -23,8 +23,7 @@ How does it work?
|
||||
|
||||
Dashboard widget with a visualization the user doesn't have access to.
|
||||
|
||||
In current implementation all the users see a list of all the dashboards. Once `pull request #957 <https://github.com/getredash/redash/pull/957>`__
|
||||
gets merged, we will filter out dashboards from the list that the user has no access to any of their widgets.
|
||||
If a user has access to at least one widget on a dashboard, they can see this dashboard in the list of all dashboards.
|
||||
|
||||
|
||||
What if I want to limit the user to only some tables?
|
||||
|
||||
@@ -7,17 +7,28 @@ var lazypipe = require('lazypipe');
|
||||
var rimraf = require('rimraf');
|
||||
var wiredep = require('wiredep').stream;
|
||||
var runSequence = require('run-sequence');
|
||||
var map = require('lodash.map');
|
||||
|
||||
var yeoman = {
|
||||
app: 'app',
|
||||
dist: 'dist'
|
||||
app: 'rd_ui/app',
|
||||
dist: 'rd_ui/dist'
|
||||
};
|
||||
|
||||
function applyAppPath(p) {
|
||||
if (typeof p === 'string') {
|
||||
return yeoman.app + p;
|
||||
} else {
|
||||
return map(p, function (path) {
|
||||
return applyAppPath(path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var paths = {
|
||||
scripts: [yeoman.app + '/scripts/**/*.js'],
|
||||
styles: [yeoman.app + '/styles/**/*.css'],
|
||||
views: {
|
||||
main: [yeoman.app + '/index.html', 'app/vendor_scripts.html', 'app/login.html', 'app/embed.html', 'app/public.html', 'app/app_layout.html', 'app/signed_out_layout.html'],
|
||||
main: applyAppPath(['/index.html', '/vendor_scripts.html', '/login.html', '/embed.html', '/public.html', '/app_layout.html', '/signed_out_layout.html']),
|
||||
files: [yeoman.app + '/views/**/*.html']
|
||||
}
|
||||
};
|
||||
@@ -104,12 +115,12 @@ gulp.task('images', function () {
|
||||
});
|
||||
|
||||
gulp.task('copy:extras', function () {
|
||||
return gulp.src([yeoman.app + '/*/.*', 'app/google_login.png', 'favicon.ico', 'robots.txt'], { dot: true })
|
||||
return gulp.src(applyAppPath(['/*/.*', '/google_login.png', '/favicon.ico', '/robots.txt']), { dot: true })
|
||||
.pipe(gulp.dest(yeoman.dist));
|
||||
});
|
||||
|
||||
gulp.task('copy:fonts', function () {
|
||||
return gulp.src([yeoman.app + '/fonts/**/*', 'app/bower_components/font-awesome/fonts/*', 'app/bower_components/material-design-iconic-font/dist/fonts/*'])
|
||||
return gulp.src(applyAppPath(['/fonts/**/*', '/bower_components/font-awesome/fonts/*', '/bower_components/material-design-iconic-font/dist/fonts/*']))
|
||||
.pipe(gulp.dest(yeoman.dist + '/fonts'));
|
||||
});
|
||||
|
||||
@@ -8,12 +8,13 @@ from flask_script import Manager
|
||||
|
||||
from redash import settings, models, __version__
|
||||
from redash.wsgi import app
|
||||
from redash.cli import users, database, data_sources, organization
|
||||
from redash.cli import users, groups, database, data_sources, organization
|
||||
from redash.monitor import get_status
|
||||
|
||||
manager = Manager(app)
|
||||
manager.add_command("database", database.manager)
|
||||
manager.add_command("users", users.manager)
|
||||
manager.add_command("groups", groups.manager)
|
||||
manager.add_command("ds", data_sources.manager)
|
||||
manager.add_command("org", organization.manager)
|
||||
|
||||
|
||||
81
migrations/0023_add_notification_destination.py
Normal file
81
migrations/0023_add_notification_destination.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import peewee
|
||||
from redash import settings
|
||||
from redash.models import db, NotificationDestination, AlertSubscription, Alert, Organization, User
|
||||
from redash.destinations import get_configuration_schema_for_destination_type
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
with db.database.transaction():
|
||||
|
||||
if not NotificationDestination.table_exists():
|
||||
NotificationDestination.create_table()
|
||||
|
||||
# Update alert subscription fields
|
||||
migrate(
|
||||
migrator.add_column('alert_subscriptions', 'destination_id', AlertSubscription.destination)
|
||||
)
|
||||
|
||||
try:
|
||||
org = Organization.get_by_slug('default')
|
||||
user = User.select().where(User.org==org, peewee.SQL("%s = ANY(groups)", org.admin_group.id)).get()
|
||||
except Exception:
|
||||
print "!!! Warning: failed finding default organization or admin user, won't migrate Webhook/HipChat alert subscriptions."
|
||||
exit()
|
||||
|
||||
if settings.WEBHOOK_ENDPOINT:
|
||||
# Have all existing alerts send to webhook if already configured
|
||||
schema = get_configuration_schema_for_destination_type('webhook')
|
||||
conf = {'url': settings.WEBHOOK_ENDPOINT}
|
||||
if settings.WEBHOOK_USERNAME:
|
||||
conf['username'] = settings.WEBHOOK_USERNAME
|
||||
conf['password'] = settings.WEBHOOK_PASSWORD
|
||||
options = ConfigurationContainer(conf, schema)
|
||||
|
||||
webhook = NotificationDestination.create(
|
||||
org=org,
|
||||
user=user,
|
||||
name="Webhook",
|
||||
type="webhook",
|
||||
options=options
|
||||
)
|
||||
|
||||
for alert in Alert.select():
|
||||
AlertSubscription.create(
|
||||
user=user,
|
||||
destination=webhook,
|
||||
alert=alert
|
||||
)
|
||||
|
||||
if settings.HIPCHAT_API_TOKEN:
|
||||
# Have all existing alerts send to HipChat if already configured
|
||||
schema = get_configuration_schema_for_destination_type('hipchat')
|
||||
|
||||
conf = {}
|
||||
|
||||
if settings.HIPCHAT_API_URL:
|
||||
conf['url'] = '{url}/room/{room_id}/notification?auth_token={token}'.format(
|
||||
url=settings.HIPCHAT_API_URL, room_id=settings.HIPCHAT_ROOM_ID, token=settings.HIPCHAT_API_TOKEN)
|
||||
else:
|
||||
conf['url'] = 'https://hipchat.com/v2/room/{room_id}/notification?auth_token={token}'.format(
|
||||
room_id=settings.HIPCHAT_ROOM_ID, token=settings.HIPCHAT_API_TOKEN)
|
||||
|
||||
options = ConfigurationContainer(conf, schema)
|
||||
|
||||
hipchat = NotificationDestination.create(
|
||||
org=org,
|
||||
user=user,
|
||||
name="HipChat",
|
||||
type="hipchat",
|
||||
options=options
|
||||
)
|
||||
|
||||
for alert in Alert.select():
|
||||
AlertSubscription.create(
|
||||
user=user,
|
||||
destination=hipchat,
|
||||
alert=alert
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
10
migrations/0024_add_options_to_query.py
Normal file
10
migrations/0024_add_options_to_query.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from redash.models import db, Query
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.add_column('queries', 'options', Query.options),
|
||||
)
|
||||
@@ -26,7 +26,8 @@
|
||||
"gulp-print": "^2.0.1",
|
||||
"gulp-rev-all": "^0.8.22",
|
||||
"bower": "~1.7.1",
|
||||
"gulp-cli": "~1.2.0"
|
||||
"gulp-cli": "~1.2.0",
|
||||
"lodash.map": "^4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -34,7 +35,8 @@
|
||||
"scripts": {
|
||||
"test": "echo 'No tests.'",
|
||||
"build": "gulp build",
|
||||
"bower": "bower"
|
||||
"bower": "bower",
|
||||
"heroku-postbuild": "npm install --dev && npm run bower install && npm run build && npm prune --production"
|
||||
},
|
||||
"dependencies": {
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"directory": "app/bower_components"
|
||||
}
|
||||
1
rd_ui/.gitattributes
vendored
1
rd_ui/.gitattributes
vendored
@@ -1 +0,0 @@
|
||||
* text=auto
|
||||
4
rd_ui/.gitignore
vendored
4
rd_ui/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
.tmp
|
||||
.sass-cache
|
||||
app/bower_components
|
||||
@@ -1,6 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- '0.10'
|
||||
before_script:
|
||||
- 'npm install -g bower grunt-cli'
|
||||
- 'bower install'
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
{% include 'vendor_scripts.html' %}
|
||||
|
||||
<!-- build:js({.tmp,app}) /scripts/scripts.js -->
|
||||
<!-- build:js({.tmp,rd_ui/app}) /scripts/scripts.js -->
|
||||
<script src="/scripts/app.js"></script>
|
||||
<script src="/scripts/services/services.js"></script>
|
||||
<script src="/scripts/services/resources.js"></script>
|
||||
@@ -74,6 +74,7 @@
|
||||
<script src="/scripts/controllers/dashboard.js"></script>
|
||||
<script src="/scripts/controllers/admin_controllers.js"></script>
|
||||
<script src="/scripts/controllers/data_sources.js"></script>
|
||||
<script src="/scripts/controllers/destinations.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>
|
||||
@@ -86,10 +87,10 @@
|
||||
<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/visualizations/wordcloud.js"></script>
|
||||
<script src="/scripts/vendor/cloud.js"></script>
|
||||
<script src="/scripts/directives/directives.js"></script>
|
||||
<script src="/scripts/directives/query_directives.js"></script>
|
||||
<script src="/scripts/directives/data_source_directives.js"></script>
|
||||
<script src="/scripts/directives/dashboard_directives.js"></script>
|
||||
<script src="/scripts/filters.js"></script>
|
||||
<script src="/scripts/controllers/alerts.js"></script>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
{% include 'vendor_scripts.html' %}
|
||||
|
||||
<!-- build:js({.tmp,app}) /scripts/embed-scripts.js -->
|
||||
<!-- build:js({.tmp,rd_ui/app}) /scripts/embed-scripts.js -->
|
||||
<script src="/scripts/embed.js"></script>
|
||||
<script src="/scripts/services/services.js"></script>
|
||||
<script src="/scripts/services/resources.js"></script>
|
||||
@@ -59,10 +59,10 @@
|
||||
<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/visualizations/wordcloud.js"></script>
|
||||
<script src="/scripts/vendor/cloud.js"></script>
|
||||
<script src="/scripts/directives/directives.js"></script>
|
||||
<script src="/scripts/directives/query_directives.js"></script>
|
||||
<script src="/scripts/directives/data_source_directives.js"></script>
|
||||
<script src="/scripts/directives/dashboard_directives.js"></script>
|
||||
<script src="/scripts/filters.js"></script>
|
||||
<script src="/scripts/controllers/alerts.js"></script>
|
||||
|
||||
BIN
rd_ui/app/favicon.ico
Normal file → Executable file
BIN
rd_ui/app/favicon.ico
Normal file → Executable file
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,7 +1,5 @@
|
||||
{% extends 'app_layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
<app-header></app-header>
|
||||
<edit-dashboard-form dashboard="newDashboard" id="new_dashboard_dialog"></edit-dashboard-form>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "signed_out_layout.html" %}
|
||||
{% block title %}Login{% endblock %}
|
||||
{% block title %}Login | Redash{% endblock %}
|
||||
{% block content %}
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
|
||||
@@ -115,6 +115,15 @@ angular.module('redash', [
|
||||
controller: 'DataSourcesCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/destinations/:destinationId', {
|
||||
templateUrl: '/views/destinations/edit.html',
|
||||
controller: 'DestinationCtrl'
|
||||
});
|
||||
$routeProvider.when('/destinations', {
|
||||
templateUrl: '/views/destinations/list.html',
|
||||
controller: 'DestinationsCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/users/new', {
|
||||
templateUrl: '/views/users/new.html',
|
||||
controller: 'NewUserCtrl'
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
];
|
||||
};
|
||||
|
||||
var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert) {
|
||||
var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert, Destination) {
|
||||
$scope.selectedTab = 'users';
|
||||
$scope.$parent.pageTitle = "Alerts";
|
||||
|
||||
$scope.alertId = $routeParams.alertId;
|
||||
@@ -66,10 +67,12 @@
|
||||
|
||||
if ($scope.alertId === "new") {
|
||||
$scope.alert = new Alert({options: {}});
|
||||
$scope.canEdit = true;
|
||||
} else {
|
||||
$scope.alert = Alert.get({id: $scope.alertId}, function(alert) {
|
||||
$scope.onQuerySelected(new Query($scope.alert.query));
|
||||
});
|
||||
$scope.canEdit = currentUser.canEdit($scope.alert);
|
||||
}
|
||||
|
||||
$scope.ops = ['greater than', 'less than', 'equals'];
|
||||
@@ -108,69 +111,118 @@
|
||||
growl.addErrorMessage("Failed saving alert.");
|
||||
});
|
||||
};
|
||||
|
||||
$scope.delete = function() {
|
||||
$scope.alert.$delete(function() {
|
||||
$location.path('/alerts');
|
||||
growl.addSuccessMessage("Alert deleted.");
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed deleting alert.");
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
angular.module('redash.directives').directive('alertSubscribers', ['AlertSubscription', function (AlertSubscription) {
|
||||
angular.module('redash.directives').directive('alertSubscriptions', ['$q', '$sce', 'AlertSubscription', 'Destination', 'growl', function ($q, $sce, AlertSubscription, Destination, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
templateUrl: '/views/alerts/subscribers.html',
|
||||
templateUrl: '/views/alerts/alert_subscriptions.html',
|
||||
scope: {
|
||||
'alertId': '='
|
||||
},
|
||||
controller: function ($scope) {
|
||||
$scope.subscribers = AlertSubscription.query({alertId: $scope.alertId});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
$scope.newSubscription = {};
|
||||
$scope.subscribers = [];
|
||||
$scope.destinations = [];
|
||||
$scope.currentUser = currentUser;
|
||||
|
||||
angular.module('redash.directives').directive('subscribeButton', ['AlertSubscription', 'growl', function (AlertSubscription, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
template: '<button class="btn btn-default btn-xs" ng-click="toggleSubscription()"><i ng-class="class"></i></button>',
|
||||
controller: function ($scope) {
|
||||
var updateClass = function() {
|
||||
if ($scope.subscription) {
|
||||
$scope.class = "fa fa-eye-slash";
|
||||
} else {
|
||||
$scope.class = "fa fa-eye";
|
||||
var destinations = Destination.query().$promise;
|
||||
var subscribers = AlertSubscription.query({alertId: $scope.alertId}).$promise;
|
||||
|
||||
$q.all([destinations, subscribers]).then(function(responses) {
|
||||
var destinations = responses[0];
|
||||
var subscribers = responses[1];
|
||||
|
||||
var subscribedDestinations = _.compact(_.map(subscribers, function(s) { return s.destination && s.destination.id }));
|
||||
var subscribedUsers = _.compact(_.map(subscribers, function(s) { if (!s.destination) { return s.user.id } }));
|
||||
|
||||
$scope.destinations = _.filter(destinations, function(d) { return !_.contains(subscribedDestinations, d.id); });
|
||||
|
||||
if (!_.contains(subscribedUsers, currentUser.id)) {
|
||||
$scope.destinations.unshift({user: {name: currentUser.name}});
|
||||
}
|
||||
}
|
||||
|
||||
$scope.subscribers.$promise.then(function() {
|
||||
$scope.subscription = _.find($scope.subscribers, function(subscription) {
|
||||
return (subscription.user.email == currentUser.email);
|
||||
});
|
||||
|
||||
updateClass();
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
$scope.subscribers = subscribers;
|
||||
});
|
||||
|
||||
$scope.toggleSubscription = function() {
|
||||
if ($scope.subscription) {
|
||||
$scope.subscription.$delete(function() {
|
||||
$scope.subscribers = _.without($scope.subscribers, $scope.subscription);
|
||||
$scope.subscription = undefined;
|
||||
updateClass();
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving subscription.");
|
||||
});
|
||||
} else {
|
||||
$scope.subscription = new AlertSubscription({alert_id: $scope.alertId});
|
||||
$scope.subscription.$save(function() {
|
||||
$scope.subscribers.push($scope.subscription);
|
||||
updateClass();
|
||||
}, function() {
|
||||
growl.addErrorMessage("Unsubscription failed.");
|
||||
});
|
||||
$scope.destinationsDisplay = function(destination) {
|
||||
if (!destination) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
if (destination.destination) {
|
||||
destination = destination.destination;
|
||||
} else if (destination.user) {
|
||||
destination = {
|
||||
name: destination.user.name + ' (Email)',
|
||||
icon: 'fa-envelope',
|
||||
type: 'user'
|
||||
};
|
||||
}
|
||||
|
||||
return $sce.trustAsHtml('<i class="fa ' + destination.icon + '"></i> ' + destination.name);
|
||||
};
|
||||
|
||||
$scope.saveSubscriber = function() {
|
||||
var sub = new AlertSubscription({alert_id: $scope.alertId});
|
||||
if ($scope.newSubscription.destination.id) {
|
||||
sub.destination_id = $scope.newSubscription.destination.id;
|
||||
}
|
||||
|
||||
sub.$save(function () {
|
||||
growl.addSuccessMessage("Subscribed.");
|
||||
$scope.subscribers.push(sub);
|
||||
$scope.destinations = _.without($scope.destinations, $scope.newSubscription.destination);
|
||||
if ($scope.destinations.length > 0) {
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
} else {
|
||||
$scope.newSubscription.destination = undefined;
|
||||
}
|
||||
console.log("dests: ", $scope.destinations);
|
||||
}, function (response) {
|
||||
growl.addErrorMessage("Failed saving subscription.");
|
||||
});
|
||||
};
|
||||
|
||||
$scope.unsubscribe = function(subscriber) {
|
||||
var destination = subscriber.destination;
|
||||
var user = subscriber.user;
|
||||
|
||||
subscriber.$delete(function () {
|
||||
growl.addSuccessMessage("Unsubscribed");
|
||||
$scope.subscribers = _.without($scope.subscribers, subscriber);
|
||||
if (destination) {
|
||||
$scope.destinations.push(destination);
|
||||
} else if (user.id == currentUser.id) {
|
||||
$scope.destinations.push({user: {name: currentUser.name}});
|
||||
}
|
||||
|
||||
if ($scope.destinations.length == 1) {
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
}
|
||||
|
||||
}, function () {
|
||||
growl.addErrorMessage("Failed unsubscribing.");
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('AlertsCtrl', ['$scope', 'Events', 'Alert', AlertsCtrl])
|
||||
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', AlertCtrl])
|
||||
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', 'Destination', AlertCtrl])
|
||||
|
||||
})();
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
},
|
||||
{
|
||||
'label': 'Runtime',
|
||||
'map': 'run_time',
|
||||
'map': 'runtime',
|
||||
'formatFunction': function (value) {
|
||||
return $filter('durationHumanize')(value);
|
||||
}
|
||||
|
||||
@@ -211,14 +211,20 @@
|
||||
|
||||
Events.record(currentUser, "view", "widget", $scope.widget.id);
|
||||
|
||||
$scope.reload = function(force) {
|
||||
var maxAge = $location.search()['maxAge'];
|
||||
if (force) {
|
||||
maxAge = 0;
|
||||
}
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge);
|
||||
};
|
||||
|
||||
if ($scope.widget.visualization) {
|
||||
Events.record(currentUser, "view", "query", $scope.widget.visualization.query.id);
|
||||
Events.record(currentUser, "view", "visualization", $scope.widget.visualization.id);
|
||||
|
||||
$scope.query = $scope.widget.getQuery();
|
||||
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
|
||||
var maxAge = $location.search()['maxAge'];
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
|
||||
$scope.reload(false);
|
||||
|
||||
$scope.type = 'visualization';
|
||||
} else if ($scope.widget.restricted) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
};
|
||||
|
||||
var DataSourceCtrl = function ($scope, $routeParams, $http, $location, Events, DataSource) {
|
||||
var DataSourceCtrl = function ($scope, $routeParams, $http, $location, growl, Events, DataSource) {
|
||||
Events.record(currentUser, "view", "page", "admin/data_source");
|
||||
$scope.$parent.pageTitle = "Data Sources";
|
||||
|
||||
@@ -24,9 +24,21 @@
|
||||
$location.path('/data_sources/' + id).replace();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.delete = function () {
|
||||
Events.record(currentUser, "delete", "datasource", $scope.dataSource.id);
|
||||
|
||||
$scope.dataSource.$delete(function (resource) {
|
||||
growl.addSuccessMessage("Data source deleted successfully.");
|
||||
$location.path('/data_sources/');
|
||||
}.bind(this), function (httpResponse) {
|
||||
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
growl.addErrorMessage("Failed to delete data source.");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('DataSourcesCtrl', ['$scope', '$location', 'growl', 'Events', 'DataSource', DataSourcesCtrl])
|
||||
.controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'Events', 'DataSource', DataSourceCtrl])
|
||||
.controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'DataSource', DataSourceCtrl])
|
||||
})();
|
||||
|
||||
44
rd_ui/app/scripts/controllers/destinations.js
Normal file
44
rd_ui/app/scripts/controllers/destinations.js
Normal file
@@ -0,0 +1,44 @@
|
||||
(function () {
|
||||
var DestinationsCtrl = function ($scope, $location, growl, Events, Destination) {
|
||||
Events.record(currentUser, "view", "page", "admin/destinations");
|
||||
$scope.$parent.pageTitle = "Destinations";
|
||||
|
||||
$scope.destinations = Destination.query();
|
||||
|
||||
};
|
||||
|
||||
var DestinationCtrl = function ($scope, $routeParams, $http, $location, growl, Events, Destination) {
|
||||
Events.record(currentUser, "view", "page", "admin/destination");
|
||||
$scope.$parent.pageTitle = "Destinations";
|
||||
|
||||
$scope.destinationId = $routeParams.destinationId;
|
||||
|
||||
if ($scope.destinationId == "new") {
|
||||
$scope.destination = new Destination({options: {}});
|
||||
} else {
|
||||
$scope.destination = Destination.get({id: $routeParams.destinationId});
|
||||
}
|
||||
|
||||
$scope.$watch('destination.id', function(id) {
|
||||
if (id != $scope.destinationId && id !== undefined) {
|
||||
$location.path('/destinations/' + id).replace();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.delete = function() {
|
||||
Events.record(currentUser, "delete", "destination", $scope.destination.id);
|
||||
|
||||
$scope.destination.$delete(function(resource) {
|
||||
growl.addSuccessMessage("Destination deleted successfully.");
|
||||
$location.path('/destinations/');
|
||||
}.bind(this), function(httpResponse) {
|
||||
console.log("Failed to delete destination: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
growl.addErrorMessage("Failed to delete destination.");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('DestinationsCtrl', ['$scope', '$location', 'growl', 'Events', 'Destination', DestinationsCtrl])
|
||||
.controller('DestinationCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Destination', DestinationCtrl])
|
||||
})();
|
||||
@@ -53,6 +53,10 @@
|
||||
$scope.saveQuery = function(options, data) {
|
||||
var savePromise = saveQuery(options, data);
|
||||
|
||||
if (!savePromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
savePromise.then(function(savedQuery) {
|
||||
queryText = savedQuery.query;
|
||||
$scope.isDirty = $scope.query.query !== queryText;
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
var DEFAULT_TAB = 'table';
|
||||
|
||||
var getQueryResult = function(maxAge) {
|
||||
// Collect params, and getQueryResult with params; getQueryResult merges it into the query
|
||||
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
|
||||
if (maxAge === undefined) {
|
||||
maxAge = $location.search()['maxAge'];
|
||||
}
|
||||
@@ -16,7 +14,7 @@
|
||||
}
|
||||
|
||||
$scope.showLog = false;
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge);
|
||||
};
|
||||
|
||||
var getDataSourceId = function() {
|
||||
@@ -125,9 +123,16 @@
|
||||
|
||||
$scope.saveQuery = function(options, data) {
|
||||
if (data) {
|
||||
// Don't save new query with partial data
|
||||
if ($scope.query.isNew()) {
|
||||
return;
|
||||
}
|
||||
data.id = $scope.query.id;
|
||||
} else {
|
||||
data = _.clone($scope.query);
|
||||
data = _.pick($scope.query, ["schedule", "query", "id", "description", "name", "data_source_id", "options"]);
|
||||
if ($scope.query.isNew()) {
|
||||
data['latest_query_data_id'] = $scope.query.latest_query_data_id;
|
||||
}
|
||||
}
|
||||
|
||||
options = _.extend({}, {
|
||||
@@ -135,9 +140,6 @@
|
||||
errorMessage: 'Query could not be saved'
|
||||
}, options);
|
||||
|
||||
delete data.latest_query_data;
|
||||
delete data.queryResult;
|
||||
|
||||
return Query.save(data, function() {
|
||||
growl.addSuccessMessage(options.successMessage);
|
||||
}, function(httpResponse) {
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
},
|
||||
transclude: true,
|
||||
template:
|
||||
'<h2>'+
|
||||
'<h2 class="p-l-5">'+
|
||||
'<edit-in-place editable="canEdit()" done="saveName" ignore-blanks=\'true\' value="group.name"></edit-in-place> ' +
|
||||
'<button class="btn btn-xs btn-danger" ng-if="canEdit()" ng-click="deleteGroup()">Delete this group</button>' +
|
||||
'</h2>',
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var directives = angular.module('redash.directives');
|
||||
|
||||
// Angular strips data- from the directive, so data-source-form becomes sourceForm...
|
||||
directives.directive('sourceForm', ['$http', 'growl', '$q', '$location', 'Events', function ($http, growl, $q, $location, Events) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
templateUrl: '/views/data_sources/form.html',
|
||||
scope: {
|
||||
'dataSource': '='
|
||||
},
|
||||
link: function ($scope) {
|
||||
var setType = function(types) {
|
||||
if ($scope.dataSource.type === undefined) {
|
||||
$scope.dataSource.type = types[0].type;
|
||||
return types[0];
|
||||
}
|
||||
|
||||
$scope.type = _.find(types, function (t) {
|
||||
return t.type == $scope.dataSource.type;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.files = {};
|
||||
|
||||
$scope.$watchCollection('files', function() {
|
||||
_.each($scope.files, function(v, k) {
|
||||
if (v) {
|
||||
$scope.dataSource.options[k] = v.base64;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var typesPromise = $http.get('api/data_sources/types');
|
||||
|
||||
$q.all([typesPromise, $scope.dataSource.$promise]).then(function(responses) {
|
||||
var types = responses[0].data;
|
||||
setType(types);
|
||||
|
||||
$scope.dataSourceTypes = types;
|
||||
|
||||
_.each(types, function (type) {
|
||||
_.each(type.configuration_schema.properties, function (prop, name) {
|
||||
if (name == 'password' || name == 'passwd') {
|
||||
prop.type = 'password';
|
||||
}
|
||||
|
||||
if (_.string.endsWith(name, "File")) {
|
||||
prop.type = 'file';
|
||||
}
|
||||
|
||||
if (prop.type == 'boolean') {
|
||||
prop.type = 'checkbox';
|
||||
}
|
||||
|
||||
prop.required = _.contains(type.configuration_schema.required, name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$scope.$watch('dataSource.type', function(current, prev) {
|
||||
if (prev !== current) {
|
||||
if (prev !== undefined) {
|
||||
$scope.dataSource.options = {};
|
||||
}
|
||||
setType($scope.dataSourceTypes);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.saveChanges = function() {
|
||||
$scope.dataSource.$save(function() {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving.");
|
||||
});
|
||||
}
|
||||
|
||||
$scope.deleteDataSource = function() {
|
||||
Events.record(currentUser, "delete", "datasource", $scope.dataSource.id);
|
||||
|
||||
$scope.dataSource.$delete(function(resource) {
|
||||
growl.addSuccessMessage("Data source deleted successfully.");
|
||||
$location.path('/data_sources/');
|
||||
}.bind(this), function(httpResponse) {
|
||||
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
growl.addErrorMessage("Failed to delete data source.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
})();
|
||||
@@ -92,13 +92,14 @@
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
'tabId': '@',
|
||||
'name': '@'
|
||||
'name': '@',
|
||||
'basePath': '=?'
|
||||
},
|
||||
transclude: true,
|
||||
template: '<li class="rd-tab" ng-class="{active: tabId==selectedTab}"><a href="{{basePath}}#{{tabId}}">{{name}}<span ng-transclude></span></a></li>',
|
||||
replace: true,
|
||||
link: function (scope) {
|
||||
scope.basePath = $location.path().substring(1);
|
||||
scope.basePath = scope.basePath || $location.path().substring(1);
|
||||
scope.$watch(function () {
|
||||
return scope.$parent.selectedTab
|
||||
}, function (tab) {
|
||||
@@ -383,7 +384,86 @@
|
||||
'</div>' +
|
||||
'</div>'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
directives.directive('dynamicForm', ['$http', 'growl', '$q', function ($http, growl, $q) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: 'true',
|
||||
transclude: true,
|
||||
templateUrl: '/views/directives/dynamic_form.html',
|
||||
scope: {
|
||||
'target': '=',
|
||||
'type': '@type'
|
||||
},
|
||||
link: function ($scope) {
|
||||
var setType = function(types) {
|
||||
if ($scope.target.type === undefined) {
|
||||
$scope.target.type = types[0].type;
|
||||
return types[0];
|
||||
}
|
||||
|
||||
$scope.type = _.find(types, function (t) {
|
||||
return t.type == $scope.target.type;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.files = {};
|
||||
|
||||
$scope.$watchCollection('files', function() {
|
||||
_.each($scope.files, function(v, k) {
|
||||
if (v) {
|
||||
$scope.target.options[k] = v.base64;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var typesPromise = $http.get('api/' + $scope.type + '/types');
|
||||
|
||||
$q.all([typesPromise, $scope.target.$promise]).then(function(responses) {
|
||||
var types = responses[0].data;
|
||||
setType(types);
|
||||
|
||||
$scope.types = types;
|
||||
|
||||
_.each(types, function (type) {
|
||||
_.each(type.configuration_schema.properties, function (prop, name) {
|
||||
if (name == 'password' || name == 'passwd') {
|
||||
prop.type = 'password';
|
||||
}
|
||||
|
||||
if (_.string.endsWith(name, "File")) {
|
||||
prop.type = 'file';
|
||||
}
|
||||
|
||||
if (prop.type == 'boolean') {
|
||||
prop.type = 'checkbox';
|
||||
}
|
||||
|
||||
prop.required = _.contains(type.configuration_schema.required, name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$scope.$watch('target.type', function(current, prev) {
|
||||
if (prev !== current) {
|
||||
if (prev !== undefined) {
|
||||
$scope.target.options = {};
|
||||
}
|
||||
setType($scope.types);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.saveChanges = function() {
|
||||
$scope.target.$save(function() {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('pageHeader', function() {
|
||||
return {
|
||||
@@ -407,10 +487,49 @@
|
||||
scope.usersPage = _.string.startsWith($location.path(), '/users');
|
||||
scope.groupsPage = _.string.startsWith($location.path(), '/groups');
|
||||
scope.dsPage = _.string.startsWith($location.path(), '/data_sources');
|
||||
scope.destinationsPage = _.string.startsWith($location.path(), '/destinations');
|
||||
|
||||
scope.showGroupsLink = currentUser.hasPermission('list_users');
|
||||
scope.showUsersLink = currentUser.hasPermission('list_users');
|
||||
scope.showDsLink = currentUser.hasPermission('admin');
|
||||
scope.showDestinationsLink = currentUser.hasPermission('admin');
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('parameters', ['$location', '$modal', function($location, $modal) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
transclude: true,
|
||||
scope: {
|
||||
'parameters': '=',
|
||||
'syncValues': '=?',
|
||||
'editable': '=?'
|
||||
},
|
||||
templateUrl: '/views/directives/parameters.html',
|
||||
link: function(scope, elem, attrs) {
|
||||
// is this the correct location for this logic?
|
||||
if (scope.syncValues !== false) {
|
||||
scope.$watch('parameters', function() {
|
||||
_.each(scope.parameters, function(param) {
|
||||
if (param.value !== null || param.value !== '') {
|
||||
$location.search('p_' + param.name, param.value);
|
||||
}
|
||||
})
|
||||
}, true);
|
||||
}
|
||||
|
||||
scope.showParameterSettings = function(param) {
|
||||
$modal.open({
|
||||
templateUrl: '/views/dialogs/parameter_settings.html',
|
||||
controller: ['$scope', '$modalInstance', function($scope, $modalInstance) {
|
||||
$scope.close = function() {
|
||||
$modalInstance.close();
|
||||
};
|
||||
$scope.parameter = param;
|
||||
}]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
@@ -45,59 +45,82 @@
|
||||
});
|
||||
};
|
||||
|
||||
var normalAreaStacking = function(seriesList) {
|
||||
fillXValues(seriesList);
|
||||
var storeOriginalHeightForEachSeries = function(seriesList) {
|
||||
_.each(seriesList, function(series) {
|
||||
if(!_.has(series,'visible')){
|
||||
series.visible = true;
|
||||
series.original_y = series.y.slice();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var getEnabledSeries = function(seriesList){
|
||||
return _.filter(seriesList, function(series) {
|
||||
return series.visible === true;
|
||||
});
|
||||
};
|
||||
|
||||
var initializeTextAndHover = function(seriesList){
|
||||
_.each(seriesList, function(series) {
|
||||
series.text = [];
|
||||
series.hoverinfo = 'text+name';
|
||||
});
|
||||
for (var i = 0; i < seriesList.length; i++) {
|
||||
for (var j = 0; j < seriesList[i].y.length; j++) {
|
||||
var sum = i > 0 ? seriesList[i-1].y[j] : 0;
|
||||
seriesList[i].text.push('Value: ' + seriesList[i].y[j] + '<br>Sum: ' + (sum + seriesList[i].y[j]));
|
||||
seriesList[i].y[j] += sum;
|
||||
};
|
||||
|
||||
var normalAreaStacking = function(seriesList) {
|
||||
fillXValues(seriesList);
|
||||
storeOriginalHeightForEachSeries(seriesList);
|
||||
initializeTextAndHover(seriesList);
|
||||
seriesList = getEnabledSeries(seriesList);
|
||||
|
||||
_.each(seriesList, function(series, seriesIndex, list){
|
||||
_.each(series.y, function(undefined, yIndex, undefined2){
|
||||
var cumulativeHeightOfPreviousSeries = seriesIndex > 0 ? list[seriesIndex-1].y[yIndex] : 0;
|
||||
var cumulativeHeightWithThisSeries = cumulativeHeightOfPreviousSeries + series.original_y[yIndex];
|
||||
series.y[yIndex] = cumulativeHeightWithThisSeries;
|
||||
series.text.push('Value: ' + series.original_y[yIndex] + '<br>Sum: ' + cumulativeHeightWithThisSeries);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var lastVisibleY = function(seriesList, lastSeriesIndex, yIndex){
|
||||
for(; lastSeriesIndex >= 0; lastSeriesIndex--){
|
||||
if(seriesList[lastSeriesIndex].visible === true){
|
||||
return seriesList[lastSeriesIndex].y[yIndex];
|
||||
}
|
||||
}
|
||||
};
|
||||
return 0;
|
||||
}
|
||||
|
||||
var percentAreaStacking = function(seriesList) {
|
||||
if (seriesList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
fillXValues(seriesList);
|
||||
_.each(seriesList, function(series) {
|
||||
series.text = [];
|
||||
series.hoverinfo = 'text+name';
|
||||
storeOriginalHeightForEachSeries(seriesList);
|
||||
initializeTextAndHover(seriesList);
|
||||
|
||||
_.each(seriesList[0].y, function(seriesY, yIndex, undefined){
|
||||
|
||||
var sumOfCorrespondingDataPoints = _.reduce(seriesList, function(total, series){
|
||||
return total + series.original_y[yIndex];
|
||||
}, 0);
|
||||
|
||||
_.each(seriesList, function(series, seriesIndex, list){
|
||||
var percentage = (series.original_y[yIndex] / sumOfCorrespondingDataPoints ) * 100;
|
||||
var previousVisiblePercentage = lastVisibleY(seriesList, seriesIndex-1, yIndex);
|
||||
series.y[yIndex] = percentage + previousVisiblePercentage;
|
||||
series.text.push('Value: ' + series.original_y[yIndex] + '<br>Relative: ' + percentage.toFixed(2) + '%');
|
||||
});
|
||||
});
|
||||
for (var i = 0; i < seriesList[0].y.length; i++) {
|
||||
var sum = 0;
|
||||
for(var j = 0; j < seriesList.length; j++) {
|
||||
sum += seriesList[j].y[i];
|
||||
}
|
||||
|
||||
for(var j = 0; j < seriesList.length; j++) {
|
||||
var value = seriesList[j].y[i] / sum * 100;
|
||||
seriesList[j].text.push('Value: ' + seriesList[j].y[i] + '<br>Relative: ' + value.toFixed(2) + '%');
|
||||
|
||||
seriesList[j].y[i] = value;
|
||||
if (j > 0) {
|
||||
seriesList[j].y[i] += seriesList[j-1].y[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var percentBarStacking = function(seriesList) {
|
||||
if (seriesList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
fillXValues(seriesList);
|
||||
_.each(seriesList, function(series) {
|
||||
series.text = [];
|
||||
series.hoverinfo = 'text+name';
|
||||
});
|
||||
initializeTextAndHover(seriesList);
|
||||
for (var i = 0; i < seriesList[0].y.length; i++) {
|
||||
var sum = 0;
|
||||
for(var j = 0; j < seriesList.length; j++) {
|
||||
@@ -121,6 +144,7 @@
|
||||
angular.module('plotly', [])
|
||||
.constant('ColorPalette', ColorPalette)
|
||||
.directive('plotlyChart', function () {
|
||||
var bottomMargin = 50;
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<div></div>',
|
||||
@@ -158,9 +182,18 @@
|
||||
return ColorPaletteArray[index % ColorPaletteArray.length];
|
||||
};
|
||||
|
||||
var calculateHeight = function() {
|
||||
var height = Math.max(scope.height, (scope.height - 50) + bottomMargin);
|
||||
return height;
|
||||
}
|
||||
|
||||
var recalculateOptions = function() {
|
||||
scope.data.length = 0;
|
||||
scope.layout.showlegend = _.has(scope.options, 'legend') ? scope.options.legend.enabled : true;
|
||||
if(_.has(scope.options, 'bottomMargin')) {
|
||||
bottomMargin = parseInt(scope.options.bottomMargin);
|
||||
scope.layout.margin.b = bottomMargin;
|
||||
}
|
||||
delete scope.layout.barmode;
|
||||
delete scope.layout.xaxis;
|
||||
delete scope.layout.yaxis;
|
||||
@@ -276,18 +309,39 @@
|
||||
percentBarStacking(scope.data);
|
||||
}
|
||||
}
|
||||
|
||||
scope.layout.margin.b = bottomMargin;
|
||||
scope.layout.height = calculateHeight();
|
||||
};
|
||||
|
||||
scope.$watch('series', recalculateOptions);
|
||||
scope.$watch('options', recalculateOptions, true);
|
||||
|
||||
scope.layout = {margin: {l: 50, r: 50, b: 50, t: 20, pad: 4}, height: scope.height, autosize: true, hovermode: 'closest'};
|
||||
scope.layout = {margin: {l: 50, r: 50, b: bottomMargin, t: 20, pad: 4}, height: calculateHeight(), autosize: true, hovermode: 'closest'};
|
||||
scope.plotlyOptions = {showLink: false, displaylogo: false};
|
||||
scope.data = [];
|
||||
|
||||
var element = element[0].children[0];
|
||||
Plotly.newPlot(element, scope.data, scope.layout, scope.plotlyOptions);
|
||||
|
||||
element.on('plotly_afterplot', function(d) {
|
||||
if(scope.options.globalSeriesType === 'area' && (scope.options.series.stacking === 'normal' || scope.options.series.stacking === 'percent')){
|
||||
$(element).find(".legendtoggle").each(function(i, rectDiv) {
|
||||
d3.select(rectDiv).on('click', function () {
|
||||
var maxIndex = scope.data.length - 1;
|
||||
var itemClicked = scope.data[maxIndex - i];
|
||||
|
||||
itemClicked.visible = (itemClicked.visible === true) ? 'legendonly' : true;
|
||||
if (scope.options.series.stacking === 'normal') {
|
||||
normalAreaStacking(scope.data);
|
||||
} else if (scope.options.series.stacking === 'percent') {
|
||||
percentAreaStacking(scope.data);
|
||||
}
|
||||
Plotly.redraw(element);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
scope.$watch('layout', function (layout, old) {
|
||||
if (angular.equals(layout, old)) {
|
||||
return;
|
||||
|
||||
@@ -10,29 +10,29 @@
|
||||
},
|
||||
template: '<a ng-href="{{link}}" class="query-link">{{query.name}}</a>',
|
||||
link: function(scope, element) {
|
||||
scope.link = 'queries/' + scope.query.id;
|
||||
var hash = null;
|
||||
if (scope.visualization) {
|
||||
if (scope.visualization.type === 'TABLE') {
|
||||
// link to hard-coded table tab instead of the (hidden) visualization tab
|
||||
scope.link += '#table';
|
||||
hash = 'table';
|
||||
} else {
|
||||
scope.link += '#' + scope.visualization.id;
|
||||
hash = scope.visualization.id;
|
||||
}
|
||||
}
|
||||
// element.find('a').attr('href', link);
|
||||
scope.link = scope.query.getUrl(false, hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function querySourceLink() {
|
||||
function querySourceLink($location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<span ng-show="query.id && canViewSource">\
|
||||
<a ng-show="!sourceMode"\
|
||||
ng-href="queries/{{query.id}}/source#{{selectedTab}}" class="btn btn-default">Show Source\
|
||||
ng-href="{{query.getUrl(true, selectedTab)}}" class="btn btn-default">Show Source\
|
||||
</a>\
|
||||
<a ng-show="sourceMode"\
|
||||
ng-href="queries/{{query.id}}#{{selectedTab}}" class="btn btn-default">Hide Source\
|
||||
ng-href="{{query.getUrl(false, selectedTab)}}" class="btn btn-default">Hide Source\
|
||||
</a>\
|
||||
</span>'
|
||||
}
|
||||
@@ -156,7 +156,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
function queryFormatter($http) {
|
||||
function queryFormatter($http, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
// don't create new scope to avoid ui-codemirror bug
|
||||
@@ -165,18 +165,29 @@
|
||||
template: '<button type="button" class="btn btn-default btn-s"\
|
||||
ng-click="formatQuery()">\
|
||||
<span class="zmdi zmdi-format-indent-increase"></span>\
|
||||
Format SQL\
|
||||
Format Query\
|
||||
</button>',
|
||||
link: function($scope) {
|
||||
$scope.formatQuery = function formatQuery() {
|
||||
if ($scope.dataSource.syntax == 'json') {
|
||||
try {
|
||||
$scope.query.query = JSON.stringify(JSON.parse($scope.query.query), ' ', 4);
|
||||
} catch(err) {
|
||||
growl.addErrorMessage(err);
|
||||
}
|
||||
} else if ($scope.dataSource.syntax =='sql') {
|
||||
|
||||
$scope.queryFormatting = true;
|
||||
$http.post('api/queries/format', {
|
||||
'query': $scope.query.query
|
||||
'query': $scope.query.query
|
||||
}).success(function (response) {
|
||||
$scope.query.query = response;
|
||||
$scope.query.query = response;
|
||||
}).finally(function () {
|
||||
$scope.queryFormatting = false;
|
||||
});
|
||||
} else {
|
||||
growl.addInfoMessage("Query formatting is not supported for your data source syntax.");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -263,11 +274,15 @@
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(7 * 24 * 3600),
|
||||
name: 'Once a week'
|
||||
name: 'Every 7 days'
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(14 * 24 * 3600),
|
||||
name: 'Every 14 days'
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(30 * 24 * 3600),
|
||||
name: 'Every 30d'
|
||||
name: 'Every 30 days'
|
||||
});
|
||||
|
||||
$scope.$watch('refreshType', function() {
|
||||
@@ -285,10 +300,10 @@
|
||||
|
||||
angular.module('redash.directives')
|
||||
.directive('queryLink', queryLink)
|
||||
.directive('querySourceLink', querySourceLink)
|
||||
.directive('querySourceLink', ['$location', querySourceLink])
|
||||
.directive('queryResultLink', queryResultLink)
|
||||
.directive('queryEditor', queryEditor)
|
||||
.directive('queryRefreshSelect', queryRefreshSelect)
|
||||
.directive('queryTimePicker', queryTimePicker)
|
||||
.directive('queryFormatter', ['$http', queryFormatter]);
|
||||
.directive('queryFormatter', ['$http', 'growl', queryFormatter]);
|
||||
})();
|
||||
|
||||
@@ -120,4 +120,10 @@ angular.module('redash.filters', []).
|
||||
filtered.push(items[i])
|
||||
return filtered;
|
||||
};
|
||||
})
|
||||
|
||||
.filter('notEmpty', function() {
|
||||
return function(collection) {
|
||||
return !_.isEmpty(collection);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== "granted") {
|
||||
if (Notification.permission === "default") {
|
||||
Notification.requestPermission(function (status) {
|
||||
if (Notification.permission !== status) {
|
||||
Notification.permission = status;
|
||||
|
||||
@@ -174,9 +174,14 @@
|
||||
};
|
||||
|
||||
return (memo && _.some(filter.current, function(v) {
|
||||
// We compare with either the value or the String representation of the value,
|
||||
// because Select2 casts true/false to "true"/"false".
|
||||
return v == row[filter.name] || String(row[filter.name]) == v
|
||||
var value = row[filter.name];
|
||||
if (moment.isMoment(value)) {
|
||||
return value.isSame(v);
|
||||
} else {
|
||||
// We compare with either the value or the String representation of the value,
|
||||
// because Select2 casts true/false to "true"/"false".
|
||||
return (v == value || String(value) == v);
|
||||
}
|
||||
}));
|
||||
}, true);
|
||||
});
|
||||
@@ -279,7 +284,7 @@
|
||||
var typeSplit;
|
||||
if (column.indexOf("::") != -1) {
|
||||
typeSplit = "::";
|
||||
} else if (column.indexOf("__" != -1)) {
|
||||
} else if (column.indexOf("__") != -1) {
|
||||
typeSplit = "__";
|
||||
} else {
|
||||
return column;
|
||||
@@ -353,7 +358,13 @@
|
||||
});
|
||||
|
||||
_.each(filters, function(filter) {
|
||||
filter.values = _.uniq(filter.values);
|
||||
filter.values = _.uniq(filter.values, function(v) {
|
||||
if (moment.isMoment(v)) {
|
||||
return v.unix();
|
||||
} else {
|
||||
return v;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.filters = filters;
|
||||
@@ -372,7 +383,10 @@
|
||||
refreshStatus(queryResult, query);
|
||||
}, 3000);
|
||||
}
|
||||
})
|
||||
}, function(error) {
|
||||
console.log("Connection error", error);
|
||||
queryResult.update({job: {error: 'failed communicating with server. Please check your Internet connection and try again.', status: 4}})
|
||||
});
|
||||
}
|
||||
|
||||
QueryResult.getById = function (id) {
|
||||
@@ -406,6 +420,11 @@
|
||||
}, function(error) {
|
||||
if (error.status === 403) {
|
||||
queryResult.update(error.data);
|
||||
} else if (error.status === 400 && 'job' in error.data) {
|
||||
queryResult.update(error.data);
|
||||
} else {
|
||||
console.log("Unknown error", error);
|
||||
queryResult.update({job: {error: 'unknown error occurred. Please try again later.', status: 4}})
|
||||
}
|
||||
});
|
||||
|
||||
@@ -415,7 +434,7 @@
|
||||
return QueryResult;
|
||||
};
|
||||
|
||||
var Query = function ($resource, QueryResult, DataSource) {
|
||||
var Query = function ($resource, $location, QueryResult) {
|
||||
var Query = $resource('api/queries/:id', {id: '@id'},
|
||||
{
|
||||
search: {
|
||||
@@ -427,32 +446,19 @@
|
||||
method: 'get',
|
||||
isArray: true,
|
||||
url: "api/queries/recent"
|
||||
}});
|
||||
}
|
||||
});
|
||||
|
||||
Query.newQuery = function () {
|
||||
return new Query({
|
||||
query: "",
|
||||
name: "New Query",
|
||||
schedule: null,
|
||||
user: currentUser
|
||||
user: currentUser,
|
||||
options: {}
|
||||
});
|
||||
};
|
||||
|
||||
Query.collectParamsFromQueryString = function($location, query) {
|
||||
var parameterNames = query.getParameters();
|
||||
var parameters = {};
|
||||
|
||||
var queryString = $location.search();
|
||||
_.each(parameterNames, function(param, i) {
|
||||
var qsName = "p_" + param;
|
||||
if (qsName in queryString) {
|
||||
parameters[param] = queryString[qsName];
|
||||
}
|
||||
});
|
||||
|
||||
return parameters;
|
||||
};
|
||||
|
||||
Query.prototype.getSourceLink = function () {
|
||||
return '/queries/' + this.id + '/source';
|
||||
};
|
||||
@@ -475,32 +481,31 @@
|
||||
};
|
||||
|
||||
Query.prototype.paramsRequired = function() {
|
||||
var queryParameters = this.getParameters();
|
||||
return !_.isEmpty(queryParameters);
|
||||
return this.getParameters().isRequired();
|
||||
};
|
||||
|
||||
Query.prototype.getQueryResult = function (maxAge, parameters) {
|
||||
Query.prototype.getQueryResult = function (maxAge) {
|
||||
if (!this.query) {
|
||||
return;
|
||||
}
|
||||
var queryText = this.query;
|
||||
|
||||
var queryParameters = this.getParameters();
|
||||
var paramsRequired = !_.isEmpty(queryParameters);
|
||||
var parameters = this.getParameters();
|
||||
var missingParams = parameters.getMissing();
|
||||
|
||||
var missingParams = parameters === undefined ? queryParameters : _.difference(queryParameters, _.keys(parameters));
|
||||
|
||||
if (paramsRequired && missingParams.length > 0) {
|
||||
if (missingParams.length > 0) {
|
||||
var paramsWord = "parameter";
|
||||
var valuesWord = "value";
|
||||
if (missingParams.length > 1) {
|
||||
paramsWord = "parameters";
|
||||
valuesWord = "values";
|
||||
}
|
||||
|
||||
return new QueryResult({job: {error: "Missing values for " + missingParams.join(', ') + " "+paramsWord+".", status: 4}});
|
||||
return new QueryResult({job: {error: "missing " + valuesWord + " for " + missingParams.join(', ') + " "+paramsWord+".", status: 4}});
|
||||
}
|
||||
|
||||
if (paramsRequired) {
|
||||
queryText = Mustache.render(queryText, parameters);
|
||||
if (parameters.isRequired()) {
|
||||
queryText = Mustache.render(queryText, parameters.getValues());
|
||||
|
||||
// Need to clear latest results, to make sure we don't use results for different params.
|
||||
this.latest_query_data = null;
|
||||
@@ -524,35 +529,143 @@
|
||||
return this.queryResult;
|
||||
};
|
||||
|
||||
Query.prototype.getUrl = function(source, hash) {
|
||||
var url = "queries/" + this.id;
|
||||
|
||||
if (source) {
|
||||
url += '/source';
|
||||
}
|
||||
|
||||
var params = "";
|
||||
if (this.getParameters().isRequired()) {
|
||||
_.each(this.getParameters().getValues(), function(value, name) {
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (params !== "") {
|
||||
params += "&";
|
||||
}
|
||||
|
||||
params += 'p_' + encodeURIComponent(name) + "=" + encodeURIComponent(value);
|
||||
});
|
||||
}
|
||||
|
||||
if (params !== "") {
|
||||
url += "?" + params;
|
||||
}
|
||||
|
||||
if (hash) {
|
||||
url += "#" + hash;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
Query.prototype.getQueryResultPromise = function() {
|
||||
return this.getQueryResult().toPromise();
|
||||
};
|
||||
|
||||
Query.prototype.getParameters = function() {
|
||||
var parts = Mustache.parse(this.query);
|
||||
var parameters = [];
|
||||
var collectParams = function(parts) {
|
||||
parameters = [];
|
||||
_.each(parts, function(part) {
|
||||
if (part[0] == 'name' || part[0] == '&') {
|
||||
parameters.push(part[1]);
|
||||
} else if (part[0] == '#') {
|
||||
parameters = _.union(parameters, collectParams(part[4]));
|
||||
|
||||
var Parameters = function(query) {
|
||||
this.query = query;
|
||||
|
||||
this.parseQuery = function() {
|
||||
var parts = Mustache.parse(this.query.query);
|
||||
var parameters = [];
|
||||
var collectParams = function(parts) {
|
||||
parameters = [];
|
||||
_.each(parts, function(part) {
|
||||
if (part[0] == 'name' || part[0] == '&') {
|
||||
parameters.push(part[1]);
|
||||
} else if (part[0] == '#') {
|
||||
parameters = _.union(parameters, collectParams(part[4]));
|
||||
}
|
||||
});
|
||||
return parameters;
|
||||
};
|
||||
|
||||
parameters = _.uniq(collectParams(parts));
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
this.updateParameters = function() {
|
||||
if (this.query.query === this.cachedQueryText) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cachedQueryText = this.query.query;
|
||||
var parameterNames = this.parseQuery();
|
||||
|
||||
this.query.options.parameters = this.query.options.parameters || [];
|
||||
|
||||
var parametersMap = {};
|
||||
_.each(this.query.options.parameters, function(param) {
|
||||
parametersMap[param.name] = param;
|
||||
});
|
||||
|
||||
_.each(parameterNames, function(param) {
|
||||
if (!_.has(parametersMap, param)) {
|
||||
this.query.options.parameters.push({
|
||||
'title': param,
|
||||
'name': param,
|
||||
'type': 'text',
|
||||
'value': null
|
||||
});
|
||||
}
|
||||
}.bind(this));
|
||||
|
||||
this.query.options.parameters = _.filter(this.query.options.parameters, function(p) { return _.indexOf(parameterNames, p.name) !== -1});
|
||||
}
|
||||
|
||||
this.initFromQueryString = function() {
|
||||
var queryString = $location.search();
|
||||
_.each(this.get(), function(param) {
|
||||
var queryStringName = 'p_' + param.name;
|
||||
if (_.has(queryString, queryStringName)) {
|
||||
param.value = queryString[queryStringName];
|
||||
}
|
||||
});
|
||||
return parameters;
|
||||
};
|
||||
}
|
||||
|
||||
parameters = collectParams(parts);
|
||||
this.updateParameters();
|
||||
this.initFromQueryString();
|
||||
}
|
||||
|
||||
return parameters;
|
||||
Parameters.prototype.get = function() {
|
||||
this.updateParameters();
|
||||
return this.query.options.parameters;
|
||||
};
|
||||
|
||||
Parameters.prototype.getMissing = function() {
|
||||
return _.pluck(_.filter(this.get(), function(p) { return p.value === null || p.value === ''; }), 'title');
|
||||
}
|
||||
|
||||
Parameters.prototype.isRequired = function() {
|
||||
return !_.isEmpty(this.get());
|
||||
}
|
||||
|
||||
Parameters.prototype.getValues = function() {
|
||||
var params = this.get();
|
||||
return _.object(_.pluck(params, 'name'), _.pluck(params, 'value'));
|
||||
}
|
||||
|
||||
Query.prototype.getParameters = function() {
|
||||
if (!this.$parameters) {
|
||||
this.$parameters = new Parameters(this);
|
||||
}
|
||||
|
||||
return this.$parameters;
|
||||
}
|
||||
|
||||
Query.prototype.getParametersDefs = function() {
|
||||
return this.getParameters().get();
|
||||
}
|
||||
|
||||
return Query;
|
||||
};
|
||||
|
||||
|
||||
|
||||
var DataSource = function ($resource) {
|
||||
var actions = {
|
||||
'get': {'method': 'GET', 'cache': false, 'isArray': false},
|
||||
@@ -565,6 +678,17 @@
|
||||
return DataSourceResource;
|
||||
};
|
||||
|
||||
var Destination = function ($resource) {
|
||||
var actions = {
|
||||
'get': {'method': 'GET', 'cache': false, 'isArray': false},
|
||||
'query': {'method': 'GET', 'cache': false, 'isArray': true}
|
||||
};
|
||||
|
||||
var DestinationResource = $resource('api/destinations/:id', {id: '@id'}, actions);
|
||||
|
||||
return DestinationResource;
|
||||
};
|
||||
|
||||
var User = function ($resource, $http) {
|
||||
var transformSingle = function(user) {
|
||||
if (user.groups !== undefined) {
|
||||
@@ -605,7 +729,7 @@
|
||||
};
|
||||
|
||||
var AlertSubscription = function ($resource) {
|
||||
var resource = $resource('api/alerts/:alertId/subscriptions/:userId', {alertId: '@alert_id', userId: '@user.id'});
|
||||
var resource = $resource('api/alerts/:alertId/subscriptions/:subscriberId', {alertId: '@alert_id', subscriberId: '@id'});
|
||||
return resource;
|
||||
};
|
||||
|
||||
@@ -617,7 +741,9 @@
|
||||
var newData = _.extend({}, data);
|
||||
if (newData.query_id === undefined) {
|
||||
newData.query_id = newData.query.id;
|
||||
newData.destination_id = newData.destinations;
|
||||
delete newData.query;
|
||||
delete newData.destinations;
|
||||
}
|
||||
|
||||
return newData;
|
||||
@@ -652,8 +778,9 @@
|
||||
|
||||
angular.module('redash.services')
|
||||
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
|
||||
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
|
||||
.factory('Query', ['$resource', '$location', 'QueryResult', Query])
|
||||
.factory('DataSource', ['$resource', DataSource])
|
||||
.factory('Destination', ['$resource', Destination])
|
||||
.factory('Alert', ['$resource', '$http', Alert])
|
||||
.factory('AlertSubscription', ['$resource', AlertSubscription])
|
||||
.factory('Widget', ['$resource', 'Query', Widget])
|
||||
|
||||
505
rd_ui/app/scripts/vendor/cloud.js
vendored
Normal file
505
rd_ui/app/scripts/vendor/cloud.js
vendored
Normal file
@@ -0,0 +1,505 @@
|
||||
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.d3||(g.d3 = {}));g=(g.layout||(g.layout = {}));g.cloud = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
|
||||
// Word cloud layout by Jason Davies, https://www.jasondavies.com/wordcloud/
|
||||
// Algorithm due to Jonathan Feinberg, http://static.mrfeinberg.com/bv_ch03.pdf
|
||||
|
||||
var dispatch = require("d3-dispatch").dispatch;
|
||||
|
||||
var cloudRadians = Math.PI / 180,
|
||||
cw = 1 << 11 >> 5,
|
||||
ch = 1 << 11;
|
||||
|
||||
d3.cloud = function() {
|
||||
var size = [256, 256],
|
||||
text = cloudText,
|
||||
font = cloudFont,
|
||||
fontSize = cloudFontSize,
|
||||
fontStyle = cloudFontNormal,
|
||||
fontWeight = cloudFontNormal,
|
||||
rotate = cloudRotate,
|
||||
padding = cloudPadding,
|
||||
spiral = archimedeanSpiral,
|
||||
words = [],
|
||||
timeInterval = Infinity,
|
||||
event = dispatch("word", "end"),
|
||||
timer = null,
|
||||
random = Math.random,
|
||||
cloud = {},
|
||||
canvas = cloudCanvas;
|
||||
|
||||
cloud.canvas = function(_) {
|
||||
return arguments.length ? (canvas = functor(_), cloud) : canvas;
|
||||
};
|
||||
|
||||
cloud.start = function() {
|
||||
var contextAndRatio = getContext(canvas()),
|
||||
board = zeroArray((size[0] >> 5) * size[1]),
|
||||
bounds = null,
|
||||
n = words.length,
|
||||
i = -1,
|
||||
tags = [],
|
||||
data = words.map(function(d, i) {
|
||||
d.text = text.call(this, d, i);
|
||||
d.font = font.call(this, d, i);
|
||||
d.style = fontStyle.call(this, d, i);
|
||||
d.weight = fontWeight.call(this, d, i);
|
||||
d.rotate = rotate.call(this, d, i);
|
||||
d.size = ~~fontSize.call(this, d, i);
|
||||
d.padding = padding.call(this, d, i);
|
||||
return d;
|
||||
}).sort(function(a, b) { return b.size - a.size; });
|
||||
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(step, 0);
|
||||
step();
|
||||
|
||||
return cloud;
|
||||
|
||||
function step() {
|
||||
var start = Date.now();
|
||||
while (Date.now() - start < timeInterval && ++i < n && timer) {
|
||||
var d = data[i];
|
||||
d.x = (size[0] * (random() + .5)) >> 1;
|
||||
d.y = (size[1] * (random() + .5)) >> 1;
|
||||
cloudSprite(contextAndRatio, d, data, i);
|
||||
if (d.hasText && place(board, d, bounds)) {
|
||||
tags.push(d);
|
||||
event.word(d);
|
||||
if (bounds) cloudBounds(bounds, d);
|
||||
else bounds = [{x: d.x + d.x0, y: d.y + d.y0}, {x: d.x + d.x1, y: d.y + d.y1}];
|
||||
// Temporary hack
|
||||
d.x -= size[0] >> 1;
|
||||
d.y -= size[1] >> 1;
|
||||
}
|
||||
}
|
||||
if (i >= n) {
|
||||
cloud.stop();
|
||||
event.end(tags, bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cloud.stop = function() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
return cloud;
|
||||
};
|
||||
|
||||
function getContext(canvas) {
|
||||
canvas.width = canvas.height = 1;
|
||||
var ratio = Math.sqrt(canvas.getContext("2d").getImageData(0, 0, 1, 1).data.length >> 2);
|
||||
canvas.width = (cw << 5) / ratio;
|
||||
canvas.height = ch / ratio;
|
||||
|
||||
var context = canvas.getContext("2d");
|
||||
context.fillStyle = context.strokeStyle = "red";
|
||||
context.textAlign = "center";
|
||||
|
||||
return {context: context, ratio: ratio};
|
||||
}
|
||||
|
||||
function place(board, tag, bounds) {
|
||||
var perimeter = [{x: 0, y: 0}, {x: size[0], y: size[1]}],
|
||||
startX = tag.x,
|
||||
startY = tag.y,
|
||||
maxDelta = Math.sqrt(size[0] * size[0] + size[1] * size[1]),
|
||||
s = spiral(size),
|
||||
dt = random() < .5 ? 1 : -1,
|
||||
t = -dt,
|
||||
dxdy,
|
||||
dx,
|
||||
dy;
|
||||
|
||||
while (dxdy = s(t += dt)) {
|
||||
dx = ~~dxdy[0];
|
||||
dy = ~~dxdy[1];
|
||||
|
||||
if (Math.min(Math.abs(dx), Math.abs(dy)) >= maxDelta) break;
|
||||
|
||||
tag.x = startX + dx;
|
||||
tag.y = startY + dy;
|
||||
|
||||
if (tag.x + tag.x0 < 0 || tag.y + tag.y0 < 0 ||
|
||||
tag.x + tag.x1 > size[0] || tag.y + tag.y1 > size[1]) continue;
|
||||
// TODO only check for collisions within current bounds.
|
||||
if (!bounds || !cloudCollide(tag, board, size[0])) {
|
||||
if (!bounds || collideRects(tag, bounds)) {
|
||||
var sprite = tag.sprite,
|
||||
w = tag.width >> 5,
|
||||
sw = size[0] >> 5,
|
||||
lx = tag.x - (w << 4),
|
||||
sx = lx & 0x7f,
|
||||
msx = 32 - sx,
|
||||
h = tag.y1 - tag.y0,
|
||||
x = (tag.y + tag.y0) * sw + (lx >> 5),
|
||||
last;
|
||||
for (var j = 0; j < h; j++) {
|
||||
last = 0;
|
||||
for (var i = 0; i <= w; i++) {
|
||||
board[x + i] |= (last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0);
|
||||
}
|
||||
x += sw;
|
||||
}
|
||||
delete tag.sprite;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
cloud.timeInterval = function(_) {
|
||||
return arguments.length ? (timeInterval = _ == null ? Infinity : _, cloud) : timeInterval;
|
||||
};
|
||||
|
||||
cloud.words = function(_) {
|
||||
return arguments.length ? (words = _, cloud) : words;
|
||||
};
|
||||
|
||||
cloud.size = function(_) {
|
||||
return arguments.length ? (size = [+_[0], +_[1]], cloud) : size;
|
||||
};
|
||||
|
||||
cloud.font = function(_) {
|
||||
return arguments.length ? (font = functor(_), cloud) : font;
|
||||
};
|
||||
|
||||
cloud.fontStyle = function(_) {
|
||||
return arguments.length ? (fontStyle = functor(_), cloud) : fontStyle;
|
||||
};
|
||||
|
||||
cloud.fontWeight = function(_) {
|
||||
return arguments.length ? (fontWeight = functor(_), cloud) : fontWeight;
|
||||
};
|
||||
|
||||
cloud.rotate = function(_) {
|
||||
return arguments.length ? (rotate = functor(_), cloud) : rotate;
|
||||
};
|
||||
|
||||
cloud.text = function(_) {
|
||||
return arguments.length ? (text = functor(_), cloud) : text;
|
||||
};
|
||||
|
||||
cloud.spiral = function(_) {
|
||||
return arguments.length ? (spiral = spirals[_] || _, cloud) : spiral;
|
||||
};
|
||||
|
||||
cloud.fontSize = function(_) {
|
||||
return arguments.length ? (fontSize = functor(_), cloud) : fontSize;
|
||||
};
|
||||
|
||||
cloud.padding = function(_) {
|
||||
return arguments.length ? (padding = functor(_), cloud) : padding;
|
||||
};
|
||||
|
||||
cloud.random = function(_) {
|
||||
return arguments.length ? (random = _, cloud) : random;
|
||||
};
|
||||
|
||||
cloud.on = function() {
|
||||
var value = event.on.apply(event, arguments);
|
||||
return value === event ? cloud : value;
|
||||
};
|
||||
|
||||
return cloud;
|
||||
};
|
||||
|
||||
function cloudText(d) {
|
||||
return d.text;
|
||||
}
|
||||
|
||||
function cloudFont() {
|
||||
return "serif";
|
||||
}
|
||||
|
||||
function cloudFontNormal() {
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function cloudFontSize(d) {
|
||||
return Math.sqrt(d.value);
|
||||
}
|
||||
|
||||
function cloudRotate() {
|
||||
return (~~(Math.random() * 6) - 3) * 30;
|
||||
}
|
||||
|
||||
function cloudPadding() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Fetches a monochrome sprite bitmap for the specified text.
|
||||
// Load in batches for speed.
|
||||
function cloudSprite(contextAndRatio, d, data, di) {
|
||||
if (d.sprite) return;
|
||||
var c = contextAndRatio.context,
|
||||
ratio = contextAndRatio.ratio;
|
||||
|
||||
c.clearRect(0, 0, (cw << 5) / ratio, ch / ratio);
|
||||
var x = 0,
|
||||
y = 0,
|
||||
maxh = 0,
|
||||
n = data.length;
|
||||
--di;
|
||||
while (++di < n) {
|
||||
d = data[di];
|
||||
c.save();
|
||||
c.font = d.style + " " + d.weight + " " + ~~((d.size + 1) / ratio) + "px " + d.font;
|
||||
var w = c.measureText(d.text + "m").width * ratio,
|
||||
h = d.size << 1;
|
||||
if (d.rotate) {
|
||||
var sr = Math.sin(d.rotate * cloudRadians),
|
||||
cr = Math.cos(d.rotate * cloudRadians),
|
||||
wcr = w * cr,
|
||||
wsr = w * sr,
|
||||
hcr = h * cr,
|
||||
hsr = h * sr;
|
||||
w = (Math.max(Math.abs(wcr + hsr), Math.abs(wcr - hsr)) + 0x1f) >> 5 << 5;
|
||||
h = ~~Math.max(Math.abs(wsr + hcr), Math.abs(wsr - hcr));
|
||||
} else {
|
||||
w = (w + 0x1f) >> 5 << 5;
|
||||
}
|
||||
if (h > maxh) maxh = h;
|
||||
if (x + w >= (cw << 5)) {
|
||||
x = 0;
|
||||
y += maxh;
|
||||
maxh = 0;
|
||||
}
|
||||
if (y + h >= ch) break;
|
||||
c.translate((x + (w >> 1)) / ratio, (y + (h >> 1)) / ratio);
|
||||
if (d.rotate) c.rotate(d.rotate * cloudRadians);
|
||||
c.fillText(d.text, 0, 0);
|
||||
if (d.padding) c.lineWidth = 2 * d.padding, c.strokeText(d.text, 0, 0);
|
||||
c.restore();
|
||||
d.width = w;
|
||||
d.height = h;
|
||||
d.xoff = x;
|
||||
d.yoff = y;
|
||||
d.x1 = w >> 1;
|
||||
d.y1 = h >> 1;
|
||||
d.x0 = -d.x1;
|
||||
d.y0 = -d.y1;
|
||||
d.hasText = true;
|
||||
x += w;
|
||||
}
|
||||
var pixels = c.getImageData(0, 0, (cw << 5) / ratio, ch / ratio).data,
|
||||
sprite = [];
|
||||
while (--di >= 0) {
|
||||
d = data[di];
|
||||
if (!d.hasText) continue;
|
||||
var w = d.width,
|
||||
w32 = w >> 5,
|
||||
h = d.y1 - d.y0;
|
||||
// Zero the buffer
|
||||
for (var i = 0; i < h * w32; i++) sprite[i] = 0;
|
||||
x = d.xoff;
|
||||
if (x == null) return;
|
||||
y = d.yoff;
|
||||
var seen = 0,
|
||||
seenRow = -1;
|
||||
for (var j = 0; j < h; j++) {
|
||||
for (var i = 0; i < w; i++) {
|
||||
var k = w32 * j + (i >> 5),
|
||||
m = pixels[((y + j) * (cw << 5) + (x + i)) << 2] ? 1 << (31 - (i % 32)) : 0;
|
||||
sprite[k] |= m;
|
||||
seen |= m;
|
||||
}
|
||||
if (seen) seenRow = j;
|
||||
else {
|
||||
d.y0++;
|
||||
h--;
|
||||
j--;
|
||||
y++;
|
||||
}
|
||||
}
|
||||
d.y1 = d.y0 + seenRow;
|
||||
d.sprite = sprite.slice(0, (d.y1 - d.y0) * w32);
|
||||
}
|
||||
}
|
||||
|
||||
// Use mask-based collision detection.
|
||||
function cloudCollide(tag, board, sw) {
|
||||
sw >>= 5;
|
||||
var sprite = tag.sprite,
|
||||
w = tag.width >> 5,
|
||||
lx = tag.x - (w << 4),
|
||||
sx = lx & 0x7f,
|
||||
msx = 32 - sx,
|
||||
h = tag.y1 - tag.y0,
|
||||
x = (tag.y + tag.y0) * sw + (lx >> 5),
|
||||
last;
|
||||
for (var j = 0; j < h; j++) {
|
||||
last = 0;
|
||||
for (var i = 0; i <= w; i++) {
|
||||
if (((last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0))
|
||||
& board[x + i]) return true;
|
||||
}
|
||||
x += sw;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function cloudBounds(bounds, d) {
|
||||
var b0 = bounds[0],
|
||||
b1 = bounds[1];
|
||||
if (d.x + d.x0 < b0.x) b0.x = d.x + d.x0;
|
||||
if (d.y + d.y0 < b0.y) b0.y = d.y + d.y0;
|
||||
if (d.x + d.x1 > b1.x) b1.x = d.x + d.x1;
|
||||
if (d.y + d.y1 > b1.y) b1.y = d.y + d.y1;
|
||||
}
|
||||
|
||||
function collideRects(a, b) {
|
||||
return a.x + a.x1 > b[0].x && a.x + a.x0 < b[1].x && a.y + a.y1 > b[0].y && a.y + a.y0 < b[1].y;
|
||||
}
|
||||
|
||||
function archimedeanSpiral(size) {
|
||||
var e = size[0] / size[1];
|
||||
return function(t) {
|
||||
return [e * (t *= .1) * Math.cos(t), t * Math.sin(t)];
|
||||
};
|
||||
}
|
||||
|
||||
function rectangularSpiral(size) {
|
||||
var dy = 4,
|
||||
dx = dy * size[0] / size[1],
|
||||
x = 0,
|
||||
y = 0;
|
||||
return function(t) {
|
||||
var sign = t < 0 ? -1 : 1;
|
||||
// See triangular numbers: T_n = n * (n + 1) / 2.
|
||||
switch ((Math.sqrt(1 + 4 * sign * t) - sign) & 3) {
|
||||
case 0: x += dx; break;
|
||||
case 1: y += dy; break;
|
||||
case 2: x -= dx; break;
|
||||
default: y -= dy; break;
|
||||
}
|
||||
return [x, y];
|
||||
};
|
||||
}
|
||||
|
||||
// TODO reuse arrays?
|
||||
function zeroArray(n) {
|
||||
var a = [],
|
||||
i = -1;
|
||||
while (++i < n) a[i] = 0;
|
||||
return a;
|
||||
}
|
||||
|
||||
function cloudCanvas() {
|
||||
return document.createElement("canvas");
|
||||
}
|
||||
|
||||
function functor(d) {
|
||||
return typeof d === "function" ? d : function() { return d; };
|
||||
}
|
||||
|
||||
var spirals = {
|
||||
archimedean: archimedeanSpiral,
|
||||
rectangular: rectangularSpiral
|
||||
};
|
||||
|
||||
},{"d3-dispatch":2}],2:[function(require,module,exports){
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
||||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
||||
factory((global.dispatch = {}));
|
||||
}(this, function (exports) { 'use strict';
|
||||
|
||||
function Dispatch(types) {
|
||||
var i = -1,
|
||||
n = types.length,
|
||||
callbacksByType = {},
|
||||
callbackByName = {},
|
||||
type,
|
||||
that = this;
|
||||
|
||||
that.on = function(type, callback) {
|
||||
type = parseType(type);
|
||||
|
||||
// Return the current callback, if any.
|
||||
if (arguments.length < 2) {
|
||||
return (callback = callbackByName[type.name]) && callback.value;
|
||||
}
|
||||
|
||||
// If a type was specified…
|
||||
if (type.type) {
|
||||
var callbacks = callbacksByType[type.type],
|
||||
callback0 = callbackByName[type.name],
|
||||
i;
|
||||
|
||||
// Remove the current callback, if any, using copy-on-remove.
|
||||
if (callback0) {
|
||||
callback0.value = null;
|
||||
i = callbacks.indexOf(callback0);
|
||||
callbacksByType[type.type] = callbacks = callbacks.slice(0, i).concat(callbacks.slice(i + 1));
|
||||
delete callbackByName[type.name];
|
||||
}
|
||||
|
||||
// Add the new callback, if any.
|
||||
if (callback) {
|
||||
callback = {value: callback};
|
||||
callbackByName[type.name] = callback;
|
||||
callbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, if a null callback was specified, remove all callbacks with the given name.
|
||||
else if (callback == null) {
|
||||
for (var otherType in callbacksByType) {
|
||||
if (callback = callbackByName[otherType + type.name]) {
|
||||
callback.value = null;
|
||||
var callbacks = callbacksByType[otherType], i = callbacks.indexOf(callback);
|
||||
callbacksByType[otherType] = callbacks.slice(0, i).concat(callbacks.slice(i + 1));
|
||||
delete callbackByName[callback.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return that;
|
||||
};
|
||||
|
||||
while (++i < n) {
|
||||
type = types[i] + "";
|
||||
if (!type || (type in that)) throw new Error("illegal or duplicate type: " + type);
|
||||
callbacksByType[type] = [];
|
||||
that[type] = applier(type);
|
||||
}
|
||||
|
||||
function parseType(type) {
|
||||
var i = (type += "").indexOf("."), name = type;
|
||||
if (i >= 0) type = type.slice(0, i); else name += ".";
|
||||
if (type && !callbacksByType.hasOwnProperty(type)) throw new Error("unknown type: " + type);
|
||||
return {type: type, name: name};
|
||||
}
|
||||
|
||||
function applier(type) {
|
||||
return function() {
|
||||
var callbacks = callbacksByType[type], // Defensive reference; copy-on-remove.
|
||||
callback,
|
||||
callbackValue,
|
||||
i = -1,
|
||||
n = callbacks.length;
|
||||
|
||||
while (++i < n) {
|
||||
if (callbackValue = (callback = callbacks[i]).value) {
|
||||
callbackValue.apply(this, arguments);
|
||||
}
|
||||
}
|
||||
|
||||
return that;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function dispatch() {
|
||||
return new Dispatch(arguments);
|
||||
}
|
||||
|
||||
dispatch.prototype = Dispatch.prototype; // allow instanceof
|
||||
|
||||
exports.dispatch = dispatch;
|
||||
|
||||
}));
|
||||
},{}]},{},[1])(1)
|
||||
});
|
||||
@@ -13,7 +13,8 @@
|
||||
xAxis: {type: 'datetime', labels: {enabled: true}},
|
||||
series: {stacking: null},
|
||||
seriesOptions: {},
|
||||
columnMapping: {}
|
||||
columnMapping: {},
|
||||
bottomMargin: 50
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
@@ -93,7 +94,7 @@
|
||||
};
|
||||
|
||||
scope.xAxisScales = ['datetime', 'linear', 'logarithmic', 'category'];
|
||||
scope.yAxisScales = ['linear', 'logarithmic'];
|
||||
scope.yAxisScales = ['linear', 'logarithmic', 'datetime'];
|
||||
|
||||
var refreshColumns = function() {
|
||||
scope.columns = scope.queryResult.getColumns();
|
||||
@@ -194,6 +195,10 @@
|
||||
scope.options.legend = {enabled: true};
|
||||
}
|
||||
|
||||
if (!_.has(scope.options, 'bottomMargin')) {
|
||||
scope.options.bottomMargin = 50;
|
||||
}
|
||||
|
||||
if (scope.columnNames)
|
||||
_.each(scope.options.columnMapping, function(value, key) {
|
||||
if (scope.columnNames.length > 0 && !_.contains(scope.columnNames, key))
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
(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);
|
||||
97
rd_ui/app/scripts/visualizations/wordcloud.js
Normal file
97
rd_ui/app/scripts/visualizations/wordcloud.js
Normal file
@@ -0,0 +1,97 @@
|
||||
(function () {
|
||||
var wordCloudVisualization = angular.module('redash.visualization');
|
||||
|
||||
wordCloudVisualization.config(['VisualizationProvider', function (VisualizationProvider) {
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'WORD_CLOUD',
|
||||
name: 'Word Cloud',
|
||||
renderTemplate: '<word-cloud-renderer options="visualization.options" query-result="queryResult"></word-cloud-renderer>',
|
||||
editorTemplate: '<word-cloud-editor></word-cloud-editor>'
|
||||
});
|
||||
}]);
|
||||
|
||||
wordCloudVisualization.directive('wordCloudRenderer', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
link: function($scope, elem, attrs) {
|
||||
|
||||
reloadCloud = function () {
|
||||
|
||||
if (!angular.isDefined($scope.queryResult)) retun;
|
||||
data = $scope.queryResult.getData();
|
||||
cloud = d3.cloud;
|
||||
|
||||
wordsHash = {};
|
||||
if($scope.visualization.options.column){
|
||||
data.map(function(d) {
|
||||
d[$scope.visualization.options.column]
|
||||
.toString()
|
||||
.split(' ')
|
||||
.map(function(d) {
|
||||
if (d in wordsHash) {
|
||||
wordsHash[d]+=1;
|
||||
} else {
|
||||
wordsHash[d]=1;
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
wordList = [];
|
||||
for(var key in wordsHash) {
|
||||
wordList.push({text: key, size: 10 + Math.pow(wordsHash[key],2)});
|
||||
}
|
||||
|
||||
var fill = d3.scale.category20();
|
||||
|
||||
var layout = cloud()
|
||||
.size([500, 500])
|
||||
.words(wordList)
|
||||
.padding(5)
|
||||
.rotate(function() { return ~~(Math.random() * 2) * 90; })
|
||||
.font("Impact")
|
||||
.fontSize(function(d) { return d.size; })
|
||||
.on("end", draw);
|
||||
|
||||
layout.start();
|
||||
|
||||
function draw(words) {
|
||||
d3.select(elem[0].parentNode)
|
||||
.select("svg")
|
||||
.remove();
|
||||
|
||||
d3.select(elem[0].parentNode)
|
||||
.append("svg")
|
||||
.attr("width", layout.size()[0])
|
||||
.attr("height", layout.size()[1])
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + layout.size()[0] / 2 + "," + layout.size()[1] / 2 + ")")
|
||||
.selectAll("text")
|
||||
.data(words)
|
||||
.enter().append("text")
|
||||
.style("font-size", function(d) { return d.size + "px"; })
|
||||
.style("font-family", "Impact")
|
||||
.style("fill", function(d, i) { return fill(i); })
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("transform", function(d) {
|
||||
return "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")";
|
||||
})
|
||||
.text(function(d) { return d.text; });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', reloadCloud);
|
||||
$scope.$watch('visualization.options.column', reloadCloud);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wordCloudVisualization.directive('wordCloudEditor', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/word_cloud_editor.html'
|
||||
};
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -40,6 +40,8 @@
|
||||
<!-- build:js /scripts/layout_vendor.js -->
|
||||
<script src="/bower_components/jquery/jquery.js"></script>
|
||||
<!-- endbuild -->
|
||||
{% block scripts %}
|
||||
{% endblock %}
|
||||
|
||||
{% include '_includes/signed_out_tail.html' %}
|
||||
|
||||
|
||||
@@ -158,23 +158,6 @@ 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 {
|
||||
@@ -213,7 +196,7 @@ li.widget:hover {
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
overflow-y: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@@ -657,3 +640,26 @@ div.table-name:hover {
|
||||
.t-body a.actions.open > a {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ui-select adjustments for SuperFlat */
|
||||
|
||||
/* Same definition as .form-control */
|
||||
.ui-select-toggle.btn-default {
|
||||
height: 35px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.42857143;
|
||||
color: #9E9E9E;
|
||||
background: #fff none;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 5px;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
|
||||
-webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
|
||||
-o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
|
||||
transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
|
||||
}
|
||||
|
||||
.t-header.widget {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<ul class="tab-nav">
|
||||
<rd-tab tab-id="in_progress" name="In Progress ({{tasks.in_progress.length}})" ng-click="setTab('in_progress')"></rd-tab>
|
||||
<rd-tab tab-id="waiting" name="Waiting ({{tasks.waiting.length}})" ng-click="setTab('waiting')"></rd-tab>
|
||||
<rd-tab tab-id="done" name="Done ({{tasks.done.length}})" ng-click="setTab('done')"></rd-tab>
|
||||
<rd-tab tab-id="done" name="Done" ng-click="setTab('done')"></rd-tab>
|
||||
</ul>
|
||||
|
||||
<smart-table rows="showingTasks" columns="gridColumns"
|
||||
|
||||
27
rd_ui/app/views/alerts/alert_subscriptions.html
Normal file
27
rd_ui/app/views/alerts/alert_subscriptions.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="p-5">
|
||||
<h4>Notifications</h4>
|
||||
|
||||
<div>
|
||||
<ui-select ng-model="newSubscription.destination" ng-disabled="destinations.length == 0">
|
||||
<ui-select-match><span ng-bind-html="destinationsDisplay($select.selected)"></span></ui-select-match>
|
||||
<ui-select-choices repeat="d in destinations">
|
||||
<span ng-bind-html="destinationsDisplay(d)"></span>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
<div class="m-t-5">
|
||||
<button class="btn btn-default" ng-click="saveSubscriber()" ng-disabled="destinations.length == 0" style="width:50%;">Add</button>
|
||||
<span class="pull-right m-t-5">
|
||||
<a href="destinations/new" ng-if="currentUser.isAdmin">Create New Destination</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<div>
|
||||
<div class="list-group-item" ng-repeat="subscriber in subscribers">
|
||||
<span ng-bind-html="destinationsDisplay(subscriber)"></span>
|
||||
<button class="btn btn-xs btn-danger pull-right" ng-click="unsubscribe(subscriber)" ng-if="currentUser.isAdmin || currentUser.id == subscriber.user.id">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,10 +7,10 @@
|
||||
<div class="container">
|
||||
<div class="row bg-white p-10">
|
||||
<div class="col-md-8">
|
||||
<form name="alertForm" ng-submit="saveChanges()" class="form">
|
||||
<form name="alertForm" class="form">
|
||||
<div class="form-group">
|
||||
<label>Query</label>
|
||||
<ui-select ng-model="alert.query" reset-search-input="false" on-select="onQuerySelected($item)">
|
||||
<ui-select ng-model="alert.query" reset-search-input="false" on-select="onQuerySelected($item)" ng-disabled="!canEdit">
|
||||
<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)"
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<div class="form-group" ng-show="selectedQuery">
|
||||
<label>Name</label>
|
||||
<input type="string" placeholder="{{getDefaultName()}}" class="form-control" ng-model="alert.name">
|
||||
<input type="string" placeholder="{{getDefaultName()}}" class="form-control" ng-model="alert.name" ng-disabled="!canEdit">
|
||||
</div>
|
||||
|
||||
<div ng-show="queryResult" class="form-horizontal">
|
||||
@@ -30,7 +30,7 @@
|
||||
<label class="control-label col-md-2">Value column</label>
|
||||
<div class="col-md-4">
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="alert.options.column"
|
||||
class="form-control"></select>
|
||||
class="form-control" ng-disabled="!canEdit"></select>
|
||||
</div>
|
||||
<label class="control-label col-md-2">Value</label>
|
||||
<div class="col-md-4">
|
||||
@@ -40,29 +40,30 @@
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-2">Op</label>
|
||||
<div class="col-md-4">
|
||||
<select ng-options="name for name in ops" ng-model="alert.options.op" class="form-control"></select>
|
||||
<select ng-options="name for name in ops" ng-model="alert.options.op" class="form-control" ng-disabled="!canEdit"></select>
|
||||
</div>
|
||||
<label class="control-label col-md-2">Reference</label>
|
||||
<div class="col-md-4">
|
||||
<input type="number" class="form-control" ng-model="alert.options.value" placeholder="reference value"
|
||||
<input type="number" step="any" class="form-control" ng-model="alert.options.value" placeholder="reference value" ng-disabled="!canEdit"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-2">Rearm seconds</label>
|
||||
<div class="col-md-4">
|
||||
<input type="number" class="form-control" ng-model="alert.rearm"/>
|
||||
<input type="number" class="form-control" ng-model="alert.rearm" ng-disabled="!canEdit"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" ng-disabled="!alertForm.$valid">Save</button>
|
||||
<div class="form-group" ng-if="canEdit">
|
||||
<button class="btn btn-primary" ng-disabled="!alertForm.$valid" ng-click="saveChanges()">Save</button>
|
||||
<button class="btn btn-danger" ng-if="alert.id" ng-click="delete()">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-4" ng-if="alert.id">
|
||||
<alert-subscribers alert-id="alert.id"></alert-subscribers>
|
||||
<alert-subscriptions alert-id="alert.id"></alert-subscriptions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<div>
|
||||
<strong>Subscribers</strong> <subscribe-button alert-id="alertId" subscribers="subscribers"></subscribe-button><br/>
|
||||
<img ng-src="{{s.user.gravatar_url}}" class="img-circle" alt="{{s.user.name}}" ng-repeat="s in subscribers"/>
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@
|
||||
<page-header title="{{dashboard.name}}">
|
||||
<span ng-if="!dashboard.is_archived && !public" class="hidden-print">
|
||||
<button type="button" class="btn btn-sm" ng-class="{'btn-default': !refreshEnabled, 'btn-primary': refreshEnabled}" tooltip="Enable/Disable Auto Refresh" ng-click="triggerRefresh()">
|
||||
<span class="zmdi zmdi-refresh-sync"></span>
|
||||
<span class="zmdi zmdi-refresh"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm" ng-class="{'btn-default': !isFullscreen, 'btn-primary': isFullscreen}" tooltip="Enable/Disable Fullscreen display" ng-click="toggleFullscreen()">
|
||||
<span class="zmdi zmdi-fullscreen"></span>
|
||||
@@ -29,11 +29,14 @@
|
||||
This dashboard is archived and won't appear in the dashboards list or search results.
|
||||
</div>
|
||||
|
||||
<div class="m-b-5">
|
||||
<filters ng-if="dashboard.dashboard_filters_enabled"></filters>
|
||||
</div>
|
||||
|
||||
<div ng-repeat="row in dashboard.widgets" class="row">
|
||||
<div ng-repeat="widget in row" class="col-lg-{{widget.width | colWidth}}" ng-controller='WidgetCtrl'>
|
||||
<div class="tile" ng-if="type=='visualization'">
|
||||
<div class="t-header">
|
||||
<div class="t-header widget">
|
||||
<div class="th-title">
|
||||
<p class="hidden-print">
|
||||
<span ng-hide="currentUser.hasPermission('view_query')">{{query.name}}</span>
|
||||
@@ -53,18 +56,31 @@
|
||||
<li><a ng-disabled="!queryResult.getData()" query-result-link target="_self">Download as CSV File</a></li>
|
||||
<li><a ng-disabled="!queryResult.getData()" file-type="xlsx" query-result-link target="_self" >Download as Excel File</a></li>
|
||||
<li><a ng-href="queries/{{query.id}}#{{widget.visualization.id}}" ng-show="currentUser.hasPermission('view_query')">View Query</a></li>
|
||||
<li><a ng-show="dashboard.canEdit()" ng-click="deleteWidget()">Remove From Dashbaord</a></li>
|
||||
<li><a ng-show="dashboard.canEdit()" ng-click="deleteWidget()">Remove From Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<visualization-renderer visualization="widget.visualization" query-result="queryResult" class="t-body"></visualization-renderer>
|
||||
<parameters parameters="widget.query.getParametersDefs()"></parameters>
|
||||
|
||||
<div class="panel-footer">
|
||||
<span class="label label-default hidden-print">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
|
||||
<div ng-switch="queryResult.getStatus()">
|
||||
<div ng-switch-when="failed">
|
||||
<div class="alert alert-danger m-5" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong></div>
|
||||
</div>
|
||||
<div ng-switch-when="done">
|
||||
<visualization-renderer visualization="widget.visualization" query-result="queryResult" class="t-body"></visualization-renderer>
|
||||
</div>
|
||||
<div ng-switch-default class="text-center">
|
||||
<i class="zmdi zmdi-refresh zmdi-hc-spin zmdi-hc-5x"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5 clearfix" style="line-height:28px;">
|
||||
<span class="small hidden-print">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
|
||||
<span class="visible-print">
|
||||
Updated: {{queryResult.getUpdatedAt() | dateTime}}
|
||||
</span>
|
||||
<button class="btn btn-sm btn-default pull-right hidden-print" ng-click="reload(true)" ng-if="!public"><i class="zmdi zmdi-refresh"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<settings-screen>
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<data-source-form data-data-source="dataSource" />
|
||||
<dynamic-form target="dataSource" type="data_sources">
|
||||
<button class="btn btn-danger" ng-if="dataSource.id" ng-click="delete()">Delete</button>
|
||||
</dynamic-form>
|
||||
</div>
|
||||
</div>
|
||||
</settings-screen>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<div>
|
||||
|
||||
<form name="dataSourceForm" ng-submit="saveChanges()">
|
||||
<div class="form-group">
|
||||
<label for="dataSourceName">Name</label>
|
||||
<input type="string" class="form-control" name="dataSourceName" ng-model="dataSource.name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="type">Type</label>
|
||||
<select name="type" class="form-control" ng-options="type.type as type.name for type in dataSourceTypes"
|
||||
ng-model="dataSource.type"></select>
|
||||
</div>
|
||||
<div class="form-group" ng-class='{"has-error": !inner.input.$valid}' ng-form="inner"
|
||||
ng-repeat="(name, input) in type.configuration_schema.properties">
|
||||
<label>{{input.title || name | capitalize}}</label>
|
||||
<input name="input" type="{{input.type}}" class="form-control" ng-model="dataSource.options[name]"
|
||||
ng-required="input.required"
|
||||
ng-if="input.type !== 'file'" accesskey="tab" placeholder="{{input.default}}">
|
||||
|
||||
<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>
|
||||
</form>
|
||||
<button class="btn btn-primary" ng-disabled="!dataSourceForm.$valid" ng-click="saveChanges()">Save</button>
|
||||
<button class="btn btn-danger" ng-click="deleteDataSource()" ng-if="dataSource.id">Delete</button>
|
||||
</div>
|
||||
9
rd_ui/app/views/destinations/edit.html
Normal file
9
rd_ui/app/views/destinations/edit.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<settings-screen>
|
||||
<div class="row voffset1">
|
||||
<div class="col-md-6">
|
||||
<dynamic-form target="destination" type="destinations">
|
||||
<button class="btn btn-danger" ng-if="destination.id" ng-click="delete()">Delete</button>
|
||||
</dynamic-form>
|
||||
</div>
|
||||
</div>
|
||||
</settings-screen>
|
||||
12
rd_ui/app/views/destinations/list.html
Normal file
12
rd_ui/app/views/destinations/list.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<settings-screen>
|
||||
<div class="row voffset1">
|
||||
<div class="col-md-4">
|
||||
<p>
|
||||
<a href="destinations/new" class="btn btn-default"><i class="fa fa-plus"></i> New Alert Destination</a>
|
||||
</p>
|
||||
<div class="list-group">
|
||||
<a ng-href="destinations/{{destination.id}}" class="list-group-item" ng-repeat="destination in destinations"><i class="fa {{destination.icon}}"></i> {{destination.name}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</settings-screen>
|
||||
21
rd_ui/app/views/dialogs/parameter_settings.html
Normal file
21
rd_ui/app/views/dialogs/parameter_settings.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" aria-label="Close" ng-click="close()"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">{{parameter.name}}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form">
|
||||
<div class="form-group">
|
||||
<label>Title</label>
|
||||
<input type="text" class="form-control" ng-model="parameter.title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
<select ng-model="parameter.type" class="form-control">
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="datetime-local">Date and Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
23
rd_ui/app/views/directives/dynamic_form.html
Normal file
23
rd_ui/app/views/directives/dynamic_form.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<form name="dataSourceForm">
|
||||
<div class="form-group">
|
||||
<label for="dataSourceName">Name</label>
|
||||
<input type="string" class="form-control" name="dataSourceName" ng-model="target.name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="type">Type</label>
|
||||
<select name="type" class="form-control" ng-options="type.type as type.name for type in types" ng-model="target.type"></select>
|
||||
</div>
|
||||
<div class="form-group" ng-class='{"has-error": !inner.input.$valid}' ng-form="inner" ng-repeat="(name, input) in type.configuration_schema.properties">
|
||||
<label>{{input.title || name | capitalize}}</label>
|
||||
<input name="input" type="{{input.type}}" class="form-control" ng-model="target.options[name]" ng-required="input.required"
|
||||
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 && !target.options[name]"
|
||||
base-sixty-four-input
|
||||
ng-if="input.type === 'file'">
|
||||
</div>
|
||||
<button class="btn btn-primary" ng-disabled="!dataSourceForm.$valid" ng-click="saveChanges()">Save</button>
|
||||
<span ng-transclude>
|
||||
|
||||
</span>
|
||||
</form>
|
||||
7
rd_ui/app/views/directives/parameters.html
Normal file
7
rd_ui/app/views/directives/parameters.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="form-inline bg-white p-5" ng-if="parameters | notEmpty" ui-sortable="{ 'ui-floating': true, 'disabled': !editable }" ng-model="parameters">
|
||||
<div class="form-group" ng-repeat="param in parameters">
|
||||
<label>{{param.title}}</label>
|
||||
<button class="btn btn-default btn-xs" ng-click="showParameterSettings(param)" ng-if="editable"><i class="zmdi zmdi-settings"></i></button>
|
||||
<input type="{{param.type}}" class="form-control" ng-model="param.value">
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,6 +7,7 @@
|
||||
<li ng-class="{'active': dsPage }" ng-if="showDsLink"><a href="data_sources">Data Sources</a></li>
|
||||
<li ng-class="{'active': usersPage }" ng-if="showUsersLink"><a href="users">Users</a></li>
|
||||
<li ng-class="{'active': groupsPage }" ng-if="showGroupsLink"><a href="groups">Groups</a></li>
|
||||
<li ng-class="{'active': destinationsPage }" ng-if="showDestinationsLink"><a href="destinations">Alert Destinations</a></li>
|
||||
</ul>
|
||||
|
||||
<div ng-transclude>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<div class="tile m-t-10 m-b-5">
|
||||
<div class="t-body p-5">
|
||||
<a href="queries/new" class="btn btn-default">New Query</a>
|
||||
<a href="queries/new" ng-show="currentUser.hasPermission('create_query')" 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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Are you sure you want to archive this query?
|
||||
<br/> All dashboard widgets created with its visualizations will be deleted.
|
||||
<br/> All alerts and dashboard widgets created with its visualizations will be deleted.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">No</button>
|
||||
@@ -28,7 +28,7 @@
|
||||
Looks like no data sources were created yet (or none of them available to the group(s) you're member of). Please
|
||||
create one first, and then start querying.
|
||||
<br/>
|
||||
<a href="data_sources/new" class="btn btn-default">Create Data Source</a> <a href="groups" class="btn btn-default">Manage
|
||||
<a href="data_sources/new" class="btn btn-primary">Create Data Source</a> <a href="groups" class="btn btn-default">Manage
|
||||
Group Permissions</a>
|
||||
</overlay>
|
||||
<overlay ng-if="noDataSources && !currentUser.isAdmin">
|
||||
@@ -221,6 +221,7 @@
|
||||
<div class="t-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<parameters parameters="query.getParametersDefs()" sync-values="!query.isNew()" editable="sourceMode && canEdit"></parameters>
|
||||
<!-- Query Execution Status -->
|
||||
<div class="alert alert-info" ng-show="queryResult.getStatus() == 'processing'">
|
||||
Executing query…
|
||||
@@ -255,9 +256,8 @@
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<ul class="tab-nav">
|
||||
<rd-tab tab-id="table" name="Table"></rd-tab>
|
||||
<rd-tab tab-id="pivot" name="Pivot Table"></rd-tab>
|
||||
<rd-tab tab-id="{{vis.id}}" name="{{vis.name}}" ng-if="vis.type!='TABLE'"
|
||||
<rd-tab tab-id="table" name="Table" base-path="query.getUrl(sourceMode)"></rd-tab>
|
||||
<rd-tab tab-id="{{vis.id}}" name="{{vis.name}}" ng-if="vis.type!='TABLE'" base-path="query.getUrl(sourceMode)"
|
||||
ng-repeat="vis in query.visualizations">
|
||||
<span class="remove" ng-click="deleteVisualization($event, vis)"
|
||||
ng-show="canEdit"> ×</span>
|
||||
@@ -276,13 +276,7 @@
|
||||
<button class="btn btn-default" ng-if="!query.isNew()" ng-click="showEmbedDialog(query, vis)"><i class="zmdi zmdi-code"></i> Embed</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="selectedTab == 'pivot'">
|
||||
<h3>
|
||||
Pivot tables are now regular visualization, which you can create from the
|
||||
<a hash="add" hash-link>"New Visualization" tab</a> and <strong>save</strong>.
|
||||
</h3>
|
||||
</div>
|
||||
<div ng-show="selectedTab == vis.id" ng-repeat="vis in query.visualizations">
|
||||
<div ng-if="selectedTab == vis.id" ng-repeat="vis in query.visualizations">
|
||||
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
|
||||
<div class="bg-ace p-5">
|
||||
<button class="btn btn-default" ng-click="openVisualizationEditor(vis)" ng-if="canEdit">Edit</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<settings-screen>
|
||||
<email-settings-warning function="invite emails"></email-settings-warning>
|
||||
|
||||
<h3>{{user.name}}</h3>
|
||||
<h3 class="p-l-5">{{user.name}}</h3>
|
||||
|
||||
<ul class="tab-nav">
|
||||
<rd-tab tab-id="profile" name="Profile" ng-click="selectTab('profile')"></rd-tab>
|
||||
|
||||
@@ -111,6 +111,11 @@
|
||||
<i class="input-helper"></i> Show Labels
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label">Height</label>
|
||||
<input name="x-axis-height" type="number" class="form-control" ng-model="options.bottomMargin">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="currentTab == 'yAxis'">
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<div>
|
||||
<span>
|
||||
From <input type="date" ng-model="dateRangeHuman.min">
|
||||
</span>
|
||||
<span>
|
||||
To <input type="date" ng-model="dateRangeHuman.max">
|
||||
</span>
|
||||
</div>
|
||||
@@ -21,7 +21,6 @@
|
||||
<label class="control-label">Visualization Name</label>
|
||||
<input name="name" type="text" class="form-control" ng-model="visualization.name"
|
||||
placeholder="{{visualization.type | capitalize}}">
|
||||
|
||||
</div>
|
||||
|
||||
<visualization-options-editor></visualization-options-editor>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<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 | filterValue:filter}}</ui-select-match>
|
||||
<ui-select-choices repeat="value in filter.values | filter: $select.search">
|
||||
{{value | filterValue:filter }}
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
<div class="container bg-white p-5" ng-show="filters">
|
||||
<div class="row" ng-show="filters">
|
||||
<div class="col-sm-6 m-t-5" 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 | filterValue:filter}}</ui-select-match>
|
||||
<ui-select-choices repeat="value in filter.values | filter: $select.search">
|
||||
{{value | filterValue:filter }}
|
||||
</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 | filterValue:filter}}</ui-select-match>
|
||||
<ui-select-choices repeat="value in filter.values | filter: $select.search">
|
||||
{{value | filterValue:filter }}
|
||||
</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 | filterValue:filter}}</ui-select-match>
|
||||
<ui-select-choices repeat="value in filter.values | filter: $select.search">
|
||||
{{value | filterValue:filter }}
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
3
rd_ui/app/views/visualizations/word_cloud.html
Normal file
3
rd_ui/app/views/visualizations/word_cloud.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
|
||||
</div>
|
||||
8
rd_ui/app/views/visualizations/word_cloud_editor.html
Normal file
8
rd_ui/app/views/visualizations/word_cloud_editor.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Word Cloud Column Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.column" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -10,9 +10,10 @@ from flask_mail import Mail
|
||||
|
||||
from redash import settings
|
||||
from redash.query_runner import import_query_runners
|
||||
from redash.destinations import import_destinations
|
||||
|
||||
|
||||
__version__ = '0.10.0'
|
||||
__version__ = '0.11.1'
|
||||
|
||||
|
||||
if settings.FEATURE_TABLES_PERMISSIONS:
|
||||
@@ -61,6 +62,7 @@ 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)
|
||||
import_destinations(settings.DESTINATIONS)
|
||||
|
||||
from redash.version_check import reset_new_version_status
|
||||
reset_new_version_status()
|
||||
|
||||
@@ -69,6 +69,8 @@ def create_and_login_user(org, name, email):
|
||||
|
||||
login_user(user_object, remember=True)
|
||||
|
||||
return user_object
|
||||
|
||||
|
||||
@blueprint.route('/<org_slug>/oauth/google', endpoint="authorize_org")
|
||||
def org_login(org_slug):
|
||||
|
||||
@@ -7,14 +7,14 @@ single Organization in your installation.
|
||||
|
||||
import logging
|
||||
|
||||
from flask import request
|
||||
from flask import request, g
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from redash.models import Organization
|
||||
|
||||
|
||||
def _get_current_org():
|
||||
slug = request.view_args.get('org_slug', 'default')
|
||||
slug = request.view_args.get('org_slug', g.get('org_slug', 'default'))
|
||||
org = Organization.get_by_slug(slug)
|
||||
logging.debug("Current organization: %s (slug: %s)", org, slug)
|
||||
return org
|
||||
|
||||
@@ -85,7 +85,12 @@ def idp_initiated():
|
||||
# This is what as known as "Just In Time (JIT) provisioning".
|
||||
# What that means is that, if a user in a SAML assertion
|
||||
# isn't in the user store, we create that user first, then log them in
|
||||
create_and_login_user(current_org, name, email)
|
||||
user = create_and_login_user(current_org, name, email)
|
||||
|
||||
if 'RedashGroups' in authn_response.ava:
|
||||
group_names = authn_response.ava.get('RedashGroups')
|
||||
user.update_group_assignments(group_names)
|
||||
|
||||
url = url_for('redash.index')
|
||||
|
||||
return redirect(url)
|
||||
|
||||
@@ -2,16 +2,21 @@ import json
|
||||
import click
|
||||
from flask_script import Manager
|
||||
from redash import models
|
||||
from redash.query_runner import query_runners, get_configuration_schema_for_type
|
||||
from redash.query_runner import query_runners, get_configuration_schema_for_query_runner_type
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
|
||||
manager = Manager(help="Data sources management commands.")
|
||||
|
||||
|
||||
@manager.command
|
||||
def list():
|
||||
@manager.option('--org', dest='organization', default=None, help="The organization the user belongs to (leave blank for all organizations).")
|
||||
def list(organization=None):
|
||||
"""List currently configured data sources."""
|
||||
for i, ds in enumerate(models.DataSource.select()):
|
||||
if organization:
|
||||
org = models.Organization.get_by_slug(organization)
|
||||
data_sources = models.DataSource.select().where(models.DataSource.org==org.id)
|
||||
else:
|
||||
data_sources = models.DataSource.select()
|
||||
for i, ds in enumerate(data_sources):
|
||||
if i > 0:
|
||||
print "-" * 20
|
||||
|
||||
@@ -24,8 +29,11 @@ def validate_data_source_type(type):
|
||||
exit()
|
||||
|
||||
|
||||
@manager.command
|
||||
def new(name=None, type=None, options=None):
|
||||
@manager.option('name', default=None, help="name of data source to create")
|
||||
@manager.option('--type', dest='type', default=None, help="new type for the data source")
|
||||
@manager.option('--options', dest='options', default=None, help="updated options for the data source")
|
||||
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default').")
|
||||
def new(name=None, type=None, options=None, organization='default'):
|
||||
"""Create new data source."""
|
||||
if name is None:
|
||||
name = click.prompt("Name")
|
||||
@@ -84,15 +92,20 @@ def new(name=None, type=None, options=None):
|
||||
data_source = models.DataSource.create_with_group(name=name,
|
||||
type=type,
|
||||
options=options,
|
||||
org=models.Organization.get_by_slug('default'))
|
||||
org=models.Organization.get_by_slug(organization))
|
||||
print "Id: {}".format(data_source.id)
|
||||
|
||||
|
||||
@manager.command
|
||||
def delete(name):
|
||||
@manager.option('name', default=None, help="name of data source to delete")
|
||||
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default').")
|
||||
def delete(name, organization='default'):
|
||||
"""Delete data source by name."""
|
||||
try:
|
||||
data_source = models.DataSource.get(models.DataSource.name==name)
|
||||
org = models.Organization.get_by_slug(organization)
|
||||
data_source = models.DataSource.get(
|
||||
models.DataSource.name==name,
|
||||
models.DataSource.org==org,
|
||||
)
|
||||
print "Deleting data source: {} (id={})".format(name, data_source.id)
|
||||
data_source.delete_instance(recursive=True)
|
||||
except models.DataSource.DoesNotExist:
|
||||
@@ -110,16 +123,20 @@ def update_attr(obj, attr, new_value):
|
||||
@manager.option('--name', dest='new_name', default=None, help="new name for the data source")
|
||||
@manager.option('--options', dest='options', default=None, help="updated options for the data source")
|
||||
@manager.option('--type', dest='type', default=None, help="new type for the data source")
|
||||
def edit(name, new_name=None, options=None, type=None):
|
||||
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default').")
|
||||
def edit(name, new_name=None, options=None, type=None, organization='default'):
|
||||
"""Edit data source settings (name, options, type)."""
|
||||
try:
|
||||
if type is not None:
|
||||
validate_data_source_type(type)
|
||||
|
||||
data_source = models.DataSource.get(models.DataSource.name==name)
|
||||
data_source = models.DataSource.get(
|
||||
models.DataSource.name==name,
|
||||
models.DataSource.org==org,
|
||||
)
|
||||
|
||||
if options is not None:
|
||||
schema = get_configuration_schema_for_type(data_source.type)
|
||||
schema = get_configuration_schema_for_query_runner_type(data_source.type)
|
||||
options = json.loads(options)
|
||||
data_source.options.set_schema(schema)
|
||||
data_source.options.update(options)
|
||||
|
||||
51
redash/cli/groups.py
Normal file
51
redash/cli/groups.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from flask_script import Manager, prompt_pass
|
||||
from redash import models
|
||||
|
||||
manager = Manager(help="Groups management commands.")
|
||||
|
||||
@manager.option('name', help="Group's name")
|
||||
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default').")
|
||||
@manager.option('--permissions', dest='permissions', default=None, help="Comma seperated list of permissions ('create_dashboard', 'create_query', 'edit_dashboard', 'edit_query', 'view_query', 'view_source', 'execute_query', 'list_users', 'schedule_query', 'list_dashboards', 'list_alerts', 'list_data_sources') (leave blank for default).")
|
||||
def create(name, permissions=None, organization='default'):
|
||||
print "Creating group (%s)..." % (name)
|
||||
|
||||
org = models.Organization.get_by_slug(organization)
|
||||
|
||||
permissions = extract_permissions_string(permissions)
|
||||
|
||||
print "permissions: [%s]" % ",".join(permissions)
|
||||
|
||||
try:
|
||||
models.Group.create(name=name, org=org, permissions=permissions)
|
||||
except Exception, e:
|
||||
print "Failed create group: %s" % e.message
|
||||
|
||||
@manager.option('id', help="Group's id")
|
||||
@manager.option('--permissions', dest='permissions', default=None, help="Comma seperated list of permissions ('create_dashboard', 'create_query', 'edit_dashboard', 'edit_query', 'view_query', 'view_source', 'execute_query', 'list_users', 'schedule_query', 'list_dashboards', 'list_alerts', 'list_data_sources') (leave blank for default).")
|
||||
def change_permissions(id, permissions=None):
|
||||
print "Change permissions of group %s ..." % id
|
||||
|
||||
try:
|
||||
group = models.Group.get_by_id(id)
|
||||
except models.Group.DoesNotExist:
|
||||
print "User [%s] not found." % id
|
||||
return
|
||||
|
||||
permissions = extract_permissions_string(permissions)
|
||||
print "current permissions [%s] will be modify to [%s]" % (",".join(group.permissions), ",".join(permissions))
|
||||
|
||||
group.permissions = permissions
|
||||
|
||||
try:
|
||||
group.save()
|
||||
except Exception, e:
|
||||
print "Failed change permission: %s" % e.message
|
||||
|
||||
|
||||
def extract_permissions_string(permissions):
|
||||
if permissions is None:
|
||||
permissions = models.Group.DEFAULT_PERMISSIONS
|
||||
else:
|
||||
permissions = permissions.split(',')
|
||||
permissions = [p.strip() for p in permissions]
|
||||
return permissions
|
||||
@@ -4,11 +4,10 @@ from peewee import IntegrityError
|
||||
from redash import models
|
||||
from redash.handlers.users import invite_user
|
||||
|
||||
manager = Manager(help="Users management commands. This commands assume single organization operation.")
|
||||
manager = Manager(help="Users management commands.")
|
||||
|
||||
|
||||
def build_groups(groups, is_admin):
|
||||
org = models.Organization.get_by_slug('default')
|
||||
def build_groups(org, groups, is_admin):
|
||||
if isinstance(groups, basestring):
|
||||
groups= groups.split(',')
|
||||
groups.remove('') # in case it was empty string
|
||||
@@ -23,9 +22,10 @@ def build_groups(groups, is_admin):
|
||||
return groups
|
||||
|
||||
@manager.option('email', help="email address of the user to grant admin to")
|
||||
def grant_admin(email):
|
||||
@manager.option('--org', dest='organization', default='default', help="the organization the user belongs to, (leave blank for 'default').")
|
||||
def grant_admin(email, organization='default'):
|
||||
try:
|
||||
org = models.Organization.get_by_slug('default')
|
||||
org = models.Organization.get_by_slug(organization)
|
||||
admin_group = org.admin_group
|
||||
user = models.User.get_by_email_and_org(email, org)
|
||||
|
||||
@@ -42,17 +42,18 @@ def grant_admin(email):
|
||||
|
||||
@manager.option('email', help="User's email")
|
||||
@manager.option('name', help="User's full name")
|
||||
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default').")
|
||||
@manager.option('--admin', dest='is_admin', action="store_true", default=False, help="set user as admin")
|
||||
@manager.option('--google', dest='google_auth', action="store_true", default=False, help="user uses Google Auth to login")
|
||||
@manager.option('--password', dest='password', default=None, help="Password for users who don't use Google Auth (leave blank for prompt).")
|
||||
@manager.option('--groups', dest='groups', default=None, help="Comma seperated list of groups (leave blank for default).")
|
||||
def create(email, name, groups, is_admin=False, google_auth=False, password=None):
|
||||
print "Creating user (%s, %s)..." % (email, name)
|
||||
def create(email, name, groups, is_admin=False, google_auth=False, password=None, organization='default'):
|
||||
print "Creating user (%s, %s) in organization %s..." % (email, name, organization)
|
||||
print "Admin: %r" % is_admin
|
||||
print "Login with Google Auth: %r\n" % google_auth
|
||||
|
||||
org = models.Organization.get_by_slug('default')
|
||||
groups = build_groups(groups, is_admin)
|
||||
org = models.Organization.get_by_slug(organization)
|
||||
groups = build_groups(org, groups, is_admin)
|
||||
|
||||
user = models.User(org=org, email=email, name=name, groups=groups)
|
||||
if not google_auth:
|
||||
@@ -66,16 +67,32 @@ def create(email, name, groups, is_admin=False, google_auth=False, password=None
|
||||
|
||||
|
||||
@manager.option('email', help="email address of user to delete")
|
||||
def delete(email):
|
||||
deleted_count = models.User.delete().where(models.User.email == email).execute()
|
||||
@manager.option('--org', dest='organization', default=None, help="The organization the user belongs to (leave blank for all organizations).")
|
||||
def delete(email, organization=None):
|
||||
if organization:
|
||||
org = models.Organization.get_by_slug(organization)
|
||||
deleted_count = models.User.delete().where(
|
||||
models.User.email == email,
|
||||
models.User.org == org.id,
|
||||
).execute()
|
||||
else:
|
||||
deleted_count = models.User.delete().where(models.User.email == email).execute()
|
||||
print "Deleted %d users." % deleted_count
|
||||
|
||||
|
||||
@manager.option('password', help="new password for the user")
|
||||
@manager.option('email', help="email address of the user to change password for")
|
||||
def password(email, password):
|
||||
@manager.option('--org', dest='organization', default=None, help="The organization the user belongs to (leave blank for all organizations).")
|
||||
def password(email, password, organization=None):
|
||||
try:
|
||||
user = models.User.get_by_email_and_org(email, models.Organization.get_by_slug('default'))
|
||||
if organization:
|
||||
org = models.Organization.get_by_slug(organization)
|
||||
user = models.User.select().where(
|
||||
models.User.email == email,
|
||||
models.User.org == org.id,
|
||||
).first()
|
||||
else:
|
||||
user = models.User.select().where(models.User.email == email).first()
|
||||
|
||||
user.hash_password(password)
|
||||
user.save()
|
||||
@@ -88,11 +105,12 @@ def password(email, password):
|
||||
@manager.option('email', help="The invitee's email")
|
||||
@manager.option('name', help="The invitee's full name")
|
||||
@manager.option('inviter_email', help="The email of the inviter")
|
||||
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default')")
|
||||
@manager.option('--admin', dest='is_admin', action="store_true", default=False, help="set user as admin")
|
||||
@manager.option('--groups', dest='groups', default=None, help="Comma seperated list of groups (leave blank for default).")
|
||||
def invite(email, name, inviter_email, groups, is_admin=False):
|
||||
org = models.Organization.get_by_slug('default')
|
||||
groups = build_groups(groups, is_admin)
|
||||
def invite(email, name, inviter_email, groups, is_admin=False, organization='default'):
|
||||
org = models.Organization.get_by_slug(organization)
|
||||
groups = build_groups(org, groups, is_admin)
|
||||
try:
|
||||
user_from = models.User.get_by_email_and_org(inviter_email, org)
|
||||
user = models.User(org=org, name=name, email=email, groups=groups)
|
||||
@@ -110,11 +128,16 @@ def invite(email, name, inviter_email, groups, is_admin=False):
|
||||
print "The inviter [%s] was not found." % inviterEmail
|
||||
|
||||
|
||||
@manager.command
|
||||
def list():
|
||||
@manager.option('--org', dest='organization', default=None, help="The organization the user belongs to (leave blank for all organizations)")
|
||||
def list(organization=None):
|
||||
"""List all users"""
|
||||
for i, user in enumerate(models.User.select()):
|
||||
if organization:
|
||||
org = models.Organization.get_by_slug(organization)
|
||||
users = models.Users.select().where(models.Users.org==org.id)
|
||||
else:
|
||||
users = models.DataSource.select()
|
||||
for i, user in enumerate(users):
|
||||
if i > 0:
|
||||
print "-" * 20
|
||||
|
||||
print "Id: {}\nName: {}\nEmail: {}".format(user.id, user.name.encode('utf-8'), user.email)
|
||||
print "Id: {}\nName: {}\nEmail: {}\nOrganization: {}".format(user.id, user.name.encode('utf-8'), user.email, user.org.name)
|
||||
|
||||
82
redash/destinations/__init__.py
Normal file
82
redash/destinations/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
import json
|
||||
|
||||
from redash import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
'BaseDestination',
|
||||
'register',
|
||||
'get_destination',
|
||||
'import_destinations'
|
||||
]
|
||||
|
||||
|
||||
class BaseDestination(object):
|
||||
def __init__(self, configuration):
|
||||
self.configuration = configuration
|
||||
|
||||
@classmethod
|
||||
def name(cls):
|
||||
return cls.__name__
|
||||
|
||||
@classmethod
|
||||
def type(cls):
|
||||
return cls.__name__.lower()
|
||||
|
||||
@classmethod
|
||||
def icon(cls):
|
||||
return 'fa-bullseye'
|
||||
|
||||
@classmethod
|
||||
def enabled(cls):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {}
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host, options):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def to_dict(cls):
|
||||
return {
|
||||
'name': cls.name(),
|
||||
'type': cls.type(),
|
||||
'icon': cls.icon(),
|
||||
'configuration_schema': cls.configuration_schema()
|
||||
}
|
||||
|
||||
|
||||
destinations = {}
|
||||
|
||||
|
||||
def register(destination_class):
|
||||
global destinations
|
||||
if destination_class.enabled():
|
||||
logger.debug("Registering %s (%s) destinations.", destination_class.name(), destination_class.type())
|
||||
destinations[destination_class.type()] = destination_class
|
||||
else:
|
||||
logger.warning("%s destination enabled but not supported, not registering. Either disable or install missing dependencies.", destination_class.name())
|
||||
|
||||
|
||||
def get_destination(destination_type, configuration):
|
||||
destination_class = destinations.get(destination_type, None)
|
||||
if destination_class is None:
|
||||
return None
|
||||
return destination_class(configuration)
|
||||
|
||||
|
||||
def get_configuration_schema_for_destination_type(destination_type):
|
||||
destination_class = destinations.get(destination_type, None)
|
||||
if destination_class is None:
|
||||
return None
|
||||
|
||||
return destination_class.configuration_schema()
|
||||
|
||||
|
||||
def import_destinations(destination_imports):
|
||||
for destination_import in destination_imports:
|
||||
__import__(destination_import)
|
||||
48
redash/destinations/email.py
Normal file
48
redash/destinations/email.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
|
||||
from flask_mail import Message
|
||||
from redash import models, mail
|
||||
from redash.destinations import *
|
||||
|
||||
|
||||
class Email(BaseDestination):
|
||||
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"addresses": {
|
||||
"type": "string"
|
||||
},
|
||||
},
|
||||
"required": ["addresses"]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def icon(cls):
|
||||
return 'fa-envelope'
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host, options):
|
||||
recipients = [email for email in options.get('addresses', '').split(',') if email]
|
||||
|
||||
if not recipients:
|
||||
logging.warning("No emails given. Skipping send.")
|
||||
|
||||
html = """
|
||||
Check <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a>.
|
||||
""".format(host=host, alert_id=alert.id, query_id=query.id)
|
||||
logging.debug("Notifying: %s", recipients)
|
||||
|
||||
try:
|
||||
with app.app_context():
|
||||
message = Message(
|
||||
recipients=recipients,
|
||||
subject="[{1}] {0}".format(alert.name.encode('utf-8', 'ignore'), new_state.upper()),
|
||||
html=html
|
||||
)
|
||||
mail.send(message)
|
||||
except Exception:
|
||||
logging.exception("Mail send error.")
|
||||
|
||||
register(Email)
|
||||
57
redash/destinations/hipchat.py
Normal file
57
redash/destinations/hipchat.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from redash.destinations import *
|
||||
from redash.models import Alert
|
||||
|
||||
|
||||
colors = {
|
||||
Alert.OK_STATE: 'green',
|
||||
Alert.TRIGGERED_STATE: 'red',
|
||||
Alert.UNKNOWN_STATE: 'yellow'
|
||||
}
|
||||
|
||||
|
||||
class HipChat(BaseDestination):
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"title": "HipChat Notification URL (get it from the Integrations page)"
|
||||
},
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def icon(cls):
|
||||
return 'fa-comment-o'
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host, options):
|
||||
try:
|
||||
alert_url = '{host}/alerts/{alert_id}'.format(host=host, alert_id=alert.id);
|
||||
query_url = '{host}/queries/{query_id}'.format(host=host, query_id=query.id);
|
||||
|
||||
message = '<a href="{alert_url}">{alert_name}</a> changed state to {new_state} (based on <a href="{query_url}">this query</a>).'.format(
|
||||
alert_name=alert.name, new_state=new_state.upper(),
|
||||
alert_url=alert_url,
|
||||
query_url=query_url)
|
||||
|
||||
data = {
|
||||
'message': message,
|
||||
'color': colors.get(new_state, 'green')
|
||||
}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
response = requests.post(options['url'], data=json.dumps(data), headers=headers)
|
||||
|
||||
if response.status_code != 204:
|
||||
logging.error('Bad status code received from HipChat: %d', response.status_code)
|
||||
except Exception:
|
||||
logging.exception("HipChat Send ERROR.")
|
||||
|
||||
|
||||
register(HipChat)
|
||||
55
redash/destinations/slack.py
Normal file
55
redash/destinations/slack.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from redash.destinations import *
|
||||
|
||||
|
||||
class Slack(BaseDestination):
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'url': {
|
||||
'type': 'string',
|
||||
'title': 'Slack Webhook URL'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def icon(cls):
|
||||
return 'fa-slack'
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host, options):
|
||||
# Documentation: https://api.slack.com/docs/attachments
|
||||
fields = [
|
||||
{
|
||||
"title": "Query",
|
||||
"value": "{host}/queries/{query_id}".format(host=host, query_id=query.id),
|
||||
"short": True
|
||||
},
|
||||
{
|
||||
"title": "Alert",
|
||||
"value": "{host}/alerts/{alert_id}".format(host=host, alert_id=alert.id),
|
||||
"short": True
|
||||
}
|
||||
]
|
||||
if new_state == "triggered":
|
||||
text = alert.name + " just triggered"
|
||||
color = "#c0392b"
|
||||
else:
|
||||
text = alert.name + " went back to normal"
|
||||
color = "#27ae60"
|
||||
|
||||
payload = {'attachments': [{'text': text, 'color': color, 'fields': fields}]}
|
||||
try:
|
||||
resp = requests.post(options.get('url'), data=json.dumps(payload))
|
||||
logging.warning(resp.text)
|
||||
if resp.status_code != 200:
|
||||
logging.error("Slack send ERROR. status_code => {status}".format(status=resp.status_code))
|
||||
except Exception:
|
||||
logging.exception("Slack send ERROR.")
|
||||
|
||||
register(Slack)
|
||||
49
redash/destinations/webhook.py
Normal file
49
redash/destinations/webhook.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import logging
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from redash.destinations import *
|
||||
from redash.utils import json_dumps
|
||||
|
||||
|
||||
class Webhook(BaseDestination):
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["url"],
|
||||
"secret": ["password"]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def icon(cls):
|
||||
return 'fa-bolt'
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host, options):
|
||||
try:
|
||||
data = {
|
||||
'event': 'alert_state_change',
|
||||
'alert': alert.to_dict(full=False),
|
||||
'url_base': host
|
||||
}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
auth = HTTPBasicAuth(options.get('username'), options.get('password')) if options.get('username') else None
|
||||
resp = requests.post(options.get('url'), data=json_dumps(data), auth=auth, headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logging.error("webhook send ERROR. status_code => {status}".format(status=resp.status_code))
|
||||
except Exception:
|
||||
logging.exception("webhook send ERROR.")
|
||||
|
||||
|
||||
register(Webhook)
|
||||
@@ -34,6 +34,11 @@ class AlertResource(BaseResource):
|
||||
|
||||
return alert.to_dict()
|
||||
|
||||
def delete(self, alert_id):
|
||||
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
|
||||
require_admin_or_owner(alert.user.id)
|
||||
alert.delete_instance(recursive=True)
|
||||
|
||||
|
||||
class AlertListResource(BaseResource):
|
||||
def post(self):
|
||||
@@ -57,16 +62,6 @@ class AlertListResource(BaseResource):
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
# TODO: should be in model?
|
||||
models.AlertSubscription.create(alert=alert, user=self.current_user)
|
||||
|
||||
self.record_event({
|
||||
'action': 'subscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert.id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
return alert.to_dict()
|
||||
|
||||
@require_permission('list_alerts')
|
||||
@@ -76,15 +71,24 @@ class AlertListResource(BaseResource):
|
||||
|
||||
class AlertSubscriptionListResource(BaseResource):
|
||||
def post(self, alert_id):
|
||||
req = request.get_json(True)
|
||||
|
||||
alert = models.Alert.get_by_id_and_org(alert_id, self.current_org)
|
||||
require_access(alert.groups, self.current_user, view_only)
|
||||
kwargs = {'alert': alert, 'user': self.current_user}
|
||||
|
||||
if 'destination_id' in req:
|
||||
destination = models.NotificationDestination.get_by_id_and_org(req['destination_id'], self.current_org)
|
||||
kwargs['destination'] = destination
|
||||
|
||||
subscription = models.AlertSubscription.create(**kwargs)
|
||||
|
||||
subscription = models.AlertSubscription.create(alert=alert_id, user=self.current_user)
|
||||
self.record_event({
|
||||
'action': 'subscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert_id,
|
||||
'object_type': 'alert'
|
||||
'object_type': 'alert',
|
||||
'destination': req.get('destination_id')
|
||||
})
|
||||
|
||||
return subscription.to_dict()
|
||||
@@ -99,8 +103,10 @@ class AlertSubscriptionListResource(BaseResource):
|
||||
|
||||
class AlertSubscriptionResource(BaseResource):
|
||||
def delete(self, alert_id, subscriber_id):
|
||||
models.AlertSubscription.unsubscribe(alert_id, subscriber_id)
|
||||
require_admin_or_owner(subscriber_id)
|
||||
|
||||
subscription = get_object_or_404(models.AlertSubscription.get_by_id, subscriber_id)
|
||||
require_admin_or_owner(subscription.user.id)
|
||||
subscription.delete_instance()
|
||||
|
||||
self.record_event({
|
||||
'action': 'unsubscribe',
|
||||
|
||||
@@ -6,7 +6,7 @@ from redash.utils import json_dumps
|
||||
from redash.handlers.base import org_scoped_rule
|
||||
from redash.handlers.alerts import AlertResource, AlertListResource, AlertSubscriptionListResource, AlertSubscriptionResource
|
||||
from redash.handlers.dashboards import DashboardListResource, RecentDashboardsResource, DashboardResource, DashboardShareResource
|
||||
from redash.handlers.data_sources import DataSourceTypeListResource, DataSourceListResource, DataSourceSchemaResource, DataSourceResource
|
||||
from redash.handlers.data_sources import DataSourceTypeListResource, DataSourceListResource, DataSourceSchemaResource, DataSourceResource, DataSourcePauseResource
|
||||
from redash.handlers.events import EventResource
|
||||
from redash.handlers.queries import QueryRefreshResource, QueryListResource, QueryRecentResource, QuerySearchResource, QueryResource
|
||||
from redash.handlers.query_results import QueryResultListResource, QueryResultResource, JobResource
|
||||
@@ -16,6 +16,7 @@ from redash.handlers.visualizations import VisualizationResource
|
||||
from redash.handlers.widgets import WidgetResource, WidgetListResource
|
||||
from redash.handlers.groups import GroupListResource, GroupResource, GroupMemberListResource, GroupMemberResource, \
|
||||
GroupDataSourceListResource, GroupDataSourceResource
|
||||
from redash.handlers.destinations import DestinationTypeListResource, DestinationResource, DestinationListResource
|
||||
|
||||
|
||||
class ApiExt(Api):
|
||||
@@ -49,6 +50,7 @@ api.add_org_resource(DashboardShareResource, '/api/dashboards/<dashboard_id>/sha
|
||||
api.add_org_resource(DataSourceTypeListResource, '/api/data_sources/types', endpoint='data_source_types')
|
||||
api.add_org_resource(DataSourceListResource, '/api/data_sources', endpoint='data_sources')
|
||||
api.add_org_resource(DataSourceSchemaResource, '/api/data_sources/<data_source_id>/schema')
|
||||
api.add_org_resource(DataSourcePauseResource, '/api/data_sources/<data_source_id>/pause')
|
||||
api.add_org_resource(DataSourceResource, '/api/data_sources/<data_source_id>', endpoint='data_source')
|
||||
|
||||
api.add_org_resource(GroupListResource, '/api/groups', endpoint='groups')
|
||||
@@ -85,4 +87,6 @@ api.add_org_resource(VisualizationResource, '/api/visualizations/<visualization_
|
||||
api.add_org_resource(WidgetListResource, '/api/widgets', endpoint='widgets')
|
||||
api.add_org_resource(WidgetResource, '/api/widgets/<int:widget_id>', endpoint='widget')
|
||||
|
||||
|
||||
api.add_org_resource(DestinationTypeListResource, '/api/destinations/types', endpoint='destination_types')
|
||||
api.add_org_resource(DestinationResource, '/api/destinations/<destination_id>', endpoint='destination')
|
||||
api.add_org_resource(DestinationListResource, '/api/destinations', endpoint='destinations')
|
||||
|
||||
@@ -11,11 +11,11 @@ from redash.handlers.base import BaseResource, get_object_or_404
|
||||
class RecentDashboardsResource(BaseResource):
|
||||
@require_permission('list_dashboards')
|
||||
def get(self):
|
||||
recent = [d.to_dict() for d in models.Dashboard.recent(self.current_user.groups, self.current_user.id, for_user=True)]
|
||||
recent = [d.to_dict() for d in models.Dashboard.recent(self.current_org, self.current_user.groups, self.current_user.id, for_user=True)]
|
||||
|
||||
global_recent = []
|
||||
if len(recent) < 10:
|
||||
global_recent = [d.to_dict() for d in models.Dashboard.recent(self.current_user.groups, self.current_user.id)]
|
||||
global_recent = [d.to_dict() for d in models.Dashboard.recent(self.current_org, self.current_user.groups, self.current_user.id)]
|
||||
|
||||
return take(20, distinct(chain(recent, global_recent), key=lambda d: d['id']))
|
||||
|
||||
@@ -23,7 +23,7 @@ class RecentDashboardsResource(BaseResource):
|
||||
class DashboardListResource(BaseResource):
|
||||
@require_permission('list_dashboards')
|
||||
def get(self):
|
||||
dashboards = [d.to_dict() for d in models.Dashboard.all(self.current_user.groups, self.current_user.id)]
|
||||
dashboards = [d.to_dict() for d in models.Dashboard.all(self.current_org, self.current_user.groups, self.current_user)]
|
||||
|
||||
return dashboards
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from funcy import project
|
||||
from redash import models
|
||||
from redash.utils.configuration import ConfigurationContainer, ValidationError
|
||||
from redash.permissions import require_admin, require_permission, require_access, view_only
|
||||
from redash.query_runner import query_runners, get_configuration_schema_for_type
|
||||
from redash.query_runner import query_runners, get_configuration_schema_for_query_runner_type
|
||||
from redash.handlers.base import BaseResource, get_object_or_404
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class DataSourceResource(BaseResource):
|
||||
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
|
||||
req = request.get_json(True)
|
||||
|
||||
schema = get_configuration_schema_for_type(req['type'])
|
||||
schema = get_configuration_schema_for_query_runner_type(req['type'])
|
||||
if schema is None:
|
||||
abort(400)
|
||||
|
||||
@@ -35,7 +35,7 @@ class DataSourceResource(BaseResource):
|
||||
data_source.options.update(req['options'])
|
||||
except ValidationError:
|
||||
abort(400)
|
||||
|
||||
|
||||
data_source.type = req['type']
|
||||
data_source.name = req['name']
|
||||
data_source.save()
|
||||
@@ -67,7 +67,7 @@ class DataSourceListResource(BaseResource):
|
||||
d['view_only'] = all(project(ds.groups, self.current_user.groups).values())
|
||||
response[ds.id] = d
|
||||
|
||||
return response.values()
|
||||
return sorted(response.values(), key=lambda d: d['id'])
|
||||
|
||||
@require_admin
|
||||
def post(self):
|
||||
@@ -77,7 +77,7 @@ class DataSourceListResource(BaseResource):
|
||||
if f not in req:
|
||||
abort(400)
|
||||
|
||||
schema = get_configuration_schema_for_type(req['type'])
|
||||
schema = get_configuration_schema_for_query_runner_type(req['type'])
|
||||
if schema is None:
|
||||
abort(400)
|
||||
|
||||
@@ -106,3 +106,38 @@ class DataSourceSchemaResource(BaseResource):
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
class DataSourcePauseResource(BaseResource):
|
||||
@require_admin
|
||||
def post(self, data_source_id):
|
||||
data_source = get_object_or_404(models.DataSource.get_by_id_and_org, data_source_id, self.current_org)
|
||||
data = request.get_json(force=True, silent=True)
|
||||
if data:
|
||||
reason = data.get('reason')
|
||||
else:
|
||||
reason = request.args.get('reason')
|
||||
|
||||
data_source.pause(reason)
|
||||
data_source.save()
|
||||
|
||||
self.record_event({
|
||||
'action': 'pause',
|
||||
'object_id': data_source.id,
|
||||
'object_type': 'datasource'
|
||||
})
|
||||
|
||||
return data_source.to_dict()
|
||||
|
||||
@require_admin
|
||||
def delete(self, data_source_id):
|
||||
data_source = get_object_or_404(models.DataSource.get_by_id_and_org, data_source_id, self.current_org)
|
||||
data_source.resume()
|
||||
data_source.save()
|
||||
|
||||
self.record_event({
|
||||
'action': 'resume',
|
||||
'object_id': data_source.id,
|
||||
'object_type': 'datasource'
|
||||
})
|
||||
|
||||
return data_source.to_dict()
|
||||
|
||||
93
redash/handlers/destinations.py
Normal file
93
redash/handlers/destinations.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import json
|
||||
|
||||
from flask import make_response, request
|
||||
from flask.ext.restful import abort
|
||||
from funcy import project
|
||||
|
||||
from redash import models
|
||||
from redash.permissions import require_admin
|
||||
from redash.destinations import destinations, get_configuration_schema_for_destination_type
|
||||
from redash.utils.configuration import ConfigurationContainer, ValidationError
|
||||
from redash.handlers.base import BaseResource, get_object_or_404
|
||||
|
||||
|
||||
class DestinationTypeListResource(BaseResource):
|
||||
@require_admin
|
||||
def get(self):
|
||||
return [q.to_dict() for q in destinations.values()]
|
||||
|
||||
|
||||
class DestinationResource(BaseResource):
|
||||
@require_admin
|
||||
def get(self, destination_id):
|
||||
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
|
||||
return destination.to_dict(all=True)
|
||||
|
||||
@require_admin
|
||||
def post(self, destination_id):
|
||||
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
|
||||
req = request.get_json(True)
|
||||
|
||||
schema = get_configuration_schema_for_destination_type(req['type'])
|
||||
if schema is None:
|
||||
abort(400)
|
||||
|
||||
try:
|
||||
destination.options.set_schema(schema)
|
||||
destination.options.update(req['options'])
|
||||
except ValidationError:
|
||||
abort(400)
|
||||
|
||||
destination.type = req['type']
|
||||
destination.name = req['name']
|
||||
|
||||
destination.save()
|
||||
|
||||
return destination.to_dict(all=True)
|
||||
|
||||
@require_admin
|
||||
def delete(self, destination_id):
|
||||
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
|
||||
destination.delete_instance(recursive=True)
|
||||
|
||||
return make_response('', 204)
|
||||
|
||||
|
||||
class DestinationListResource(BaseResource):
|
||||
def get(self):
|
||||
destinations = models.NotificationDestination.all(self.current_org)
|
||||
|
||||
response = {}
|
||||
for ds in destinations:
|
||||
if ds.id in response:
|
||||
continue
|
||||
|
||||
d = ds.to_dict()
|
||||
response[ds.id] = d
|
||||
|
||||
return response.values()
|
||||
|
||||
@require_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)
|
||||
|
||||
schema = get_configuration_schema_for_destination_type(req['type'])
|
||||
if schema is None:
|
||||
abort(400)
|
||||
|
||||
config = ConfigurationContainer(req['options'], schema)
|
||||
if not config.is_valid():
|
||||
abort(400)
|
||||
|
||||
destination = models.NotificationDestination(org=self.current_org,
|
||||
name=req['name'],
|
||||
type=req['type'],
|
||||
options=config,
|
||||
user=self.current_user)
|
||||
destination.save()
|
||||
|
||||
return destination.to_dict(all=True)
|
||||
@@ -1,18 +1,69 @@
|
||||
import json
|
||||
import pystache
|
||||
import time
|
||||
import logging
|
||||
|
||||
from funcy import project
|
||||
from flask import render_template, request
|
||||
from flask_login import login_required, current_user
|
||||
from flask_restful import abort
|
||||
|
||||
from redash import models, settings
|
||||
from redash import models, settings, utils
|
||||
from redash import serializers
|
||||
from redash.utils import json_dumps
|
||||
from redash.utils import json_dumps, collect_parameters_from_request, gen_query_hash
|
||||
from redash.handlers import routes
|
||||
from redash.handlers.base import org_scoped_rule, record_event
|
||||
from redash.handlers.query_results import collect_query_parameters
|
||||
from redash.permissions import require_access, view_only
|
||||
from authentication import current_org
|
||||
|
||||
#
|
||||
# Run a parameterized query synchronously and return the result
|
||||
# DISCLAIMER: Temporary solution to support parameters in queries. Should be
|
||||
# removed once we refactor the query results API endpoints and handling
|
||||
# on the client side. Please don't reuse in other API handlers.
|
||||
#
|
||||
def run_query_sync(data_source, parameter_values, query_text, max_age=0):
|
||||
query_parameters = set(collect_query_parameters(query_text))
|
||||
missing_params = set(query_parameters) - set(parameter_values.keys())
|
||||
if missing_params:
|
||||
raise Exception('Missing parameter value for: {}'.format(", ".join(missing_params)))
|
||||
|
||||
if query_parameters:
|
||||
query_text = pystache.render(query_text, parameter_values)
|
||||
|
||||
if max_age <= 0:
|
||||
query_result = None
|
||||
else:
|
||||
query_result = models.QueryResult.get_latest(data_source, query_text, max_age)
|
||||
|
||||
query_hash = gen_query_hash(query_text)
|
||||
|
||||
if query_result:
|
||||
logging.info("Returning cached result for query %s" % query_hash)
|
||||
return query_result.data
|
||||
|
||||
try:
|
||||
started_at = time.time()
|
||||
data, error = data_source.query_runner.run_query(query_text)
|
||||
|
||||
if error:
|
||||
return None
|
||||
# update cache
|
||||
if max_age > 0:
|
||||
run_time = time.time() - started_at
|
||||
query_result, updated_query_ids = models.QueryResult.store_result(data_source.org_id, data_source.id,
|
||||
query_hash, query_text, data,
|
||||
run_time, utils.utcnow())
|
||||
|
||||
return data
|
||||
except Exception, e:
|
||||
if max_age > 0:
|
||||
abort(404, message="Unable to get result from the database, and no cached query result found.")
|
||||
else:
|
||||
abort(503, message="Unable to get result from the database.")
|
||||
return None
|
||||
|
||||
|
||||
@routes.route(org_scoped_rule('/embed/query/<query_id>/visualization/<visualization_id>'), methods=['GET'])
|
||||
@login_required
|
||||
@@ -22,10 +73,24 @@ def embed(query_id, visualization_id, org_slug=None):
|
||||
vis = query.visualizations.where(models.Visualization.id == visualization_id).first()
|
||||
qr = {}
|
||||
|
||||
parameter_values = collect_parameters_from_request(request.args)
|
||||
|
||||
if vis is not None:
|
||||
vis = vis.to_dict()
|
||||
qr = query.latest_query_data
|
||||
if qr is None:
|
||||
if settings.ALLOW_PARAMETERS_IN_EMBEDS == True and len(parameter_values) > 0:
|
||||
# run parameterized query
|
||||
#
|
||||
# WARNING: Note that the external query parameters
|
||||
# are a potential risk of SQL injections.
|
||||
#
|
||||
max_age = int(request.args.get('maxAge', 0))
|
||||
results = run_query_sync(query.data_source, parameter_values, query.query, max_age=max_age)
|
||||
if results is None:
|
||||
abort(400, message="Unable to get results for this query")
|
||||
else:
|
||||
qr = {"data": json.loads(results)}
|
||||
elif qr is None:
|
||||
abort(400, message="No Results for this query")
|
||||
else:
|
||||
qr = qr.to_dict()
|
||||
@@ -54,6 +119,7 @@ def embed(query_id, visualization_id, org_slug=None):
|
||||
query_result=json_dumps(qr))
|
||||
|
||||
|
||||
|
||||
@routes.route(org_scoped_rule('/public/dashboards/<token>'), methods=['GET'])
|
||||
@login_required
|
||||
def public_dashboard(token, org_slug=None):
|
||||
|
||||
@@ -176,5 +176,3 @@ class GroupDataSourceResource(BaseResource):
|
||||
'object_type': 'group',
|
||||
'member_id': data_source.id
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -86,8 +86,6 @@ class QueryResource(BaseResource):
|
||||
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'user', 'last_modified_by', 'org']:
|
||||
query_def.pop(field, None)
|
||||
|
||||
# TODO(@arikfr): after running a query it updates all relevant queries with the new result. So is this really
|
||||
# needed?
|
||||
if 'latest_query_data_id' in query_def:
|
||||
query_def['latest_query_data'] = query_def.pop('latest_query_data_id')
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user