mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Compare commits
168 Commits
v0.8.3.b11
...
v0.9.1.b13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd478c93c | ||
|
|
9971496401 | ||
|
|
8473783b0b | ||
|
|
a9ae3c9ea3 | ||
|
|
505166455d | ||
|
|
c6a06bd40a | ||
|
|
ed9e27019f | ||
|
|
5b1abaaa52 | ||
|
|
c1da2579a3 | ||
|
|
1b36a62b91 | ||
|
|
ed2e06a787 | ||
|
|
47d3faae92 | ||
|
|
ff49321056 | ||
|
|
ee98b5a5c6 | ||
|
|
245a4b5a3f | ||
|
|
0546528b2c | ||
|
|
d8d925c297 | ||
|
|
fac0af548b | ||
|
|
5deca9bd60 | ||
|
|
b1e0620f85 | ||
|
|
0a35f70a27 | ||
|
|
bd1551fb9d | ||
|
|
f6a8a9975f | ||
|
|
179649d422 | ||
|
|
1c584f65ba | ||
|
|
b62c75ac66 | ||
|
|
f4096c0356 | ||
|
|
419fe389a4 | ||
|
|
031cb63f67 | ||
|
|
a62c5b5b24 | ||
|
|
3befab7244 | ||
|
|
8c006238c5 | ||
|
|
03d897886e | ||
|
|
ebe032070e | ||
|
|
4a29f41ab3 | ||
|
|
566cda359e | ||
|
|
5a1736ad31 | ||
|
|
eed3d50372 | ||
|
|
901cf6f017 | ||
|
|
83458ab25e | ||
|
|
9ab4e0e888 | ||
|
|
89ac67555e | ||
|
|
4d7e58c8d7 | ||
|
|
14c4203593 | ||
|
|
ccec964c24 | ||
|
|
d65e1a799a | ||
|
|
451f216c31 | ||
|
|
270afad6cf | ||
|
|
ccae8bcc69 | ||
|
|
07f96a22af | ||
|
|
3f6cf95307 | ||
|
|
6f2d5090e6 | ||
|
|
9cedb3bb66 | ||
|
|
9751d3584b | ||
|
|
13ced12cc9 | ||
|
|
fdd60b364f | ||
|
|
dde63d1e96 | ||
|
|
174f7c0b1a | ||
|
|
887d7179c4 | ||
|
|
fc84cf39fc | ||
|
|
849c11b5f4 | ||
|
|
66b4fe8e32 | ||
|
|
9d1823426c | ||
|
|
c004274108 | ||
|
|
0b89ee4653 | ||
|
|
caff2e5caa | ||
|
|
aa98f22a04 | ||
|
|
db8915f154 | ||
|
|
ce9a5c05fb | ||
|
|
246725515d | ||
|
|
be4c59e73d | ||
|
|
40e047a47c | ||
|
|
048ef7234c | ||
|
|
bd29bdbb2e | ||
|
|
13252bb0af | ||
|
|
07a709d59a | ||
|
|
55f80695b0 | ||
|
|
991512bc17 | ||
|
|
5e58818043 | ||
|
|
224998c62a | ||
|
|
9a31077a99 | ||
|
|
ab39ed2898 | ||
|
|
cb4fbf81a2 | ||
|
|
7c6b95e71d | ||
|
|
f7b57fa580 | ||
|
|
6e32f5b9f2 | ||
|
|
1a748c2141 | ||
|
|
99ed076c0c | ||
|
|
8a7dd3b46a | ||
|
|
6e28f949fb | ||
|
|
a9ccfb8b42 | ||
|
|
1aba777b61 | ||
|
|
1894df49fa | ||
|
|
200131bb45 | ||
|
|
5e25ba0cf6 | ||
|
|
184d208020 | ||
|
|
610fe2a8a2 | ||
|
|
068ce57b24 | ||
|
|
af61784a28 | ||
|
|
871d8d6b6a | ||
|
|
ea1fac76a3 | ||
|
|
ed380fefaa | ||
|
|
cc9e89bb69 | ||
|
|
e9aeb11685 | ||
|
|
cc2dcb25b6 | ||
|
|
bfb73166c6 | ||
|
|
30adfccd79 | ||
|
|
c3b6de55c0 | ||
|
|
fa2cae1753 | ||
|
|
b337a50fcc | ||
|
|
3d178f9a60 | ||
|
|
a0219bf354 | ||
|
|
ec41077dc1 | ||
|
|
15f9a063ae | ||
|
|
a15085dc93 | ||
|
|
78ae9ac647 | ||
|
|
f31ec7b1dd | ||
|
|
85916efa81 | ||
|
|
31b6e6ff0f | ||
|
|
f20774b6c2 | ||
|
|
dac6cabd1e | ||
|
|
51949230d6 | ||
|
|
81386bcf37 | ||
|
|
67118ee1aa | ||
|
|
e863d83bf4 | ||
|
|
d958817b10 | ||
|
|
450631d6ce | ||
|
|
8b5a0206c2 | ||
|
|
49848a193a | ||
|
|
0f9d5219ef | ||
|
|
3cb14786f5 | ||
|
|
8e432200aa | ||
|
|
30dd030a9d | ||
|
|
fc3fc0e84a | ||
|
|
24b70e66af | ||
|
|
76a1b9fdbe | ||
|
|
e310f9d522 | ||
|
|
86a0e74db8 | ||
|
|
30a70338ba | ||
|
|
b242dbb531 | ||
|
|
ca47b0e6f7 | ||
|
|
7c992c53eb | ||
|
|
4deb150a89 | ||
|
|
63f0a8cc20 | ||
|
|
7e4f5e1e03 | ||
|
|
6f1fed47b3 | ||
|
|
4505437097 | ||
|
|
2ea2df5943 | ||
|
|
135ffd693a | ||
|
|
0f82d4e17b | ||
|
|
b7a43feeca | ||
|
|
47dcead383 | ||
|
|
0da39edf1a | ||
|
|
7845ad5ff7 | ||
|
|
3808b451c6 | ||
|
|
c78789a670 | ||
|
|
2cd08d25a0 | ||
|
|
09ed4d5ede | ||
|
|
1e97a0ce9f | ||
|
|
61cb203ce7 | ||
|
|
58c0c5c099 | ||
|
|
8072b06246 | ||
|
|
65f2c2136b | ||
|
|
8b9a9e9ac4 | ||
|
|
30a494dab0 | ||
|
|
06065badd4 | ||
|
|
3dc62e3c85 | ||
|
|
73b2c5d38e |
13
Dockerfile
13
Dockerfile
@@ -7,7 +7,9 @@ RUN apt-get update && \
|
||||
# Postgres client
|
||||
libpq-dev \
|
||||
# Additional packages required for data sources:
|
||||
libssl-dev libmysqlclient-dev
|
||||
libssl-dev libmysqlclient-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Users creation
|
||||
RUN useradd --system --comment " " --create-home redash
|
||||
@@ -17,6 +19,7 @@ RUN pip install -U setuptools && \
|
||||
pip install supervisor==3.1.2
|
||||
|
||||
COPY . /opt/redash/current
|
||||
RUN chown -R redash /opt/redash/current
|
||||
|
||||
# Setting working directory
|
||||
WORKDIR /opt/redash/current
|
||||
@@ -25,6 +28,14 @@ WORKDIR /opt/redash/current
|
||||
RUN pip install -r requirements_all_ds.txt && \
|
||||
pip install -r requirements.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 && \
|
||||
apt-get purge -y nodejs && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Setup supervisord
|
||||
RUN mkdir -p /opt/redash/supervisord && \
|
||||
mkdir -p /opt/redash/logs && \
|
||||
|
||||
7
Makefile
7
Makefile
@@ -6,10 +6,9 @@ BASE_VERSION=$(shell python ./manage.py version | cut -d + -f 1)
|
||||
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(VERSION).tar.gz
|
||||
|
||||
deps:
|
||||
cd rd_ui && npm install
|
||||
cd rd_ui && npm install -g bower grunt-cli
|
||||
cd rd_ui && bower install
|
||||
cd rd_ui && grunt build
|
||||
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
|
||||
|
||||
pack:
|
||||
sed -ri "s/^__version__ = '([0-9.]*)'/__version__ = '$(FULL_VERSION)'/" redash/__init__.py
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
Some of you read the news about EverythingMe closing down. While more detailed announcement will come later (once more details are clear), **I just wanted to reassure you that you shouldn't worry -- this won't affect the future of re:dash.** I will keep maintaining re:dash, and might even be able to work more on it.
|
||||
|
||||
If you still have concerns, you're welcome to reach out to me directly -- arik@arikfr.com.
|
||||
|
||||
Arik.
|
||||
More details about the future of re:dash : http://bit.ly/journey-first-step
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ machine:
|
||||
2.7.3
|
||||
dependencies:
|
||||
pre:
|
||||
- make deps
|
||||
- pip install -r requirements_dev.txt
|
||||
- pip install -r requirements.txt
|
||||
cache_directories:
|
||||
@@ -17,13 +16,13 @@ dependencies:
|
||||
- rd_ui/app/bower_components/
|
||||
test:
|
||||
override:
|
||||
- make test
|
||||
post:
|
||||
- make pack
|
||||
- nosetests --with-xunit --xunit-file=$CIRCLE_TEST_REPORTS/junit.xml --with-coverage --cover-package=redash tests/
|
||||
deployment:
|
||||
github_and_docker:
|
||||
branch: master
|
||||
commands:
|
||||
- make deps
|
||||
- make pack
|
||||
- make upload
|
||||
- echo "rd_ui/app" >> .dockerignore
|
||||
- docker build -t redash/redash:$(./manage.py version | sed -e "s/\+/./") .
|
||||
|
||||
@@ -20,7 +20,7 @@ PostgreSQL / Redshift / Greenplum
|
||||
- Password
|
||||
- Host
|
||||
- Port
|
||||
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- None
|
||||
@@ -216,3 +216,18 @@ Oracle
|
||||
- **Additional requirements**
|
||||
|
||||
- ``cx_Oracle`` python package. This requires the installation of the Oracle `instant client <http://www.oracle.com/technetwork/database/features/instant-client/index-097480.html>`__.
|
||||
|
||||
Treasure Data
|
||||
------
|
||||
|
||||
- **Options**
|
||||
|
||||
- Type (TreasureData)
|
||||
- API Key
|
||||
- Database Name
|
||||
- Type (Presto/Hive[default])
|
||||
|
||||
- **Additional requirements**
|
||||
- Must have account on https://console.treasuredata.com
|
||||
|
||||
Documentation: https://docs.treasuredata.com/articles/redash
|
||||
|
||||
74
docs/misc/backup_restore.rst
Normal file
74
docs/misc/backup_restore.rst
Normal file
@@ -0,0 +1,74 @@
|
||||
How To: Backup your re:dash database and restore it on a different server
|
||||
=================
|
||||
|
||||
**Note:** This guide assumes that the default database name (redash) has not been changed.
|
||||
|
||||
1. Check the size of your redash database. This can be done by creating a query within redash itself against the 're:dash metadata' data source.
|
||||
|
||||
.. code::
|
||||
|
||||
select t1.datname AS db_name, pg_size_pretty(pg_database_size(t1.datname)) as db_size
|
||||
from pg_database t1
|
||||
where t1.datname = 'redash'
|
||||
|
||||
|
||||
2. Check the amount of available disk space on your existing server.
|
||||
|
||||
.. code::
|
||||
|
||||
df -hT
|
||||
|
||||
|
||||
3. Backup the existing redash database.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo -u redash pg_dump redash | gzip > redash_backup.gz
|
||||
|
||||
|
||||
4. Transfer the backup to the new server.
|
||||
|
||||
5. `Perform a clean install of re:dash <http://docs.redash.io/en/latest/setup.html>`__ on the new server.
|
||||
|
||||
6. Check the amount of available disk space on the new server.
|
||||
|
||||
.. code::
|
||||
|
||||
df -hT
|
||||
|
||||
|
||||
7. Login as postgres user on the new server.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo -u postgres -i
|
||||
|
||||
|
||||
8. drop the current redash database, create a new database named redash, and then restore the backup into the new database.
|
||||
|
||||
.. code::
|
||||
|
||||
dbdrop redash
|
||||
createdb -T template0 redash
|
||||
gunzip -c redash_backup.gz | psql redash
|
||||
|
||||
|
||||
9. Set a new password of your choosing for the 'redash_reader' user (since the new installation generated a random password).
|
||||
|
||||
.. code::
|
||||
|
||||
psql -c "ALTER ROLE redash_reader WITH PASSWORD 'yourpasswordgoeshere';"
|
||||
|
||||
|
||||
**Note:** Then you must navigate to the 're:dash metadata' data source (/data_sources/1) in the new re:dash installation and change the password to match the one entered above.
|
||||
|
||||
10. Grant permissions on the redash database to the redash_reader user.
|
||||
|
||||
.. code::
|
||||
|
||||
psql -c "grant select(id,name,type) ON data_sources to redash_reader;" redash
|
||||
psql -c "grant select(id,name) ON users to redash_reader;" redash
|
||||
psql -c "grant select on events, queries, dashboards, widgets, visualizations, query_results to redash_reader;" redash
|
||||
|
||||
|
||||
Create a new query in redash (using re:dash metadata as the data source) to test that everything is working as expected.
|
||||
141
docs/misc/letsencrypt.rst
Normal file
141
docs/misc/letsencrypt.rst
Normal file
@@ -0,0 +1,141 @@
|
||||
How To: Encrypt your re:dash installation with a free SSL certificate from Let's Encrypt
|
||||
=================
|
||||
|
||||
**Note:** This below steps were tested on Ubuntu 14.04, but *should* work with any Debian-based distro.
|
||||
|
||||
`Let's Encrypt <https://letsencrypt.org/>`__ is a new certificate authority sponsored by major tech companies including Mozilla, Google, Cisco, and Facebook. Unlike traditional CA authorities, Let's Encrypt allows you to generate and renew an SSL certificate quickly and **at no cost**.
|
||||
|
||||
1. Open port 443 in your security group (if using AWS or GCE).
|
||||
|
||||
2. Update package lists, install git, and clone the letsencrypt repository.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install git
|
||||
sudo git clone https://github.com/letsencrypt/letsencrypt /opt/letsencrypt
|
||||
|
||||
|
||||
3. Stop nginx and redash, then ensure that no processes are still listening on port 80.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo supervisorctl stop redash_server
|
||||
sudo service nginx stop
|
||||
netstat -na | grep ':80.*LISTEN'
|
||||
|
||||
|
||||
4. Generate your letsencrypt certificate.
|
||||
|
||||
.. code::
|
||||
|
||||
cd /opt/letsencrypt
|
||||
sudo pip install urllib3[secure] --upgrade
|
||||
./letsencrypt-auto certonly --standalone
|
||||
|
||||
|
||||
In most cases you'll want to enter 'example.com www.example.com' when prompted for your domain so that you can use the certificate on http://example.com and http://www.example.com.
|
||||
|
||||
5. Optionally generate a stronger Diffie-Hellman ephemeral parameter. Without this step, you will not achieve higher than a B score on `SSLLabs <https://www.ssllabs.com/ssltest/>`__. Please note that on a low-end server (VPS or micro/small GCE instance) this step can take approximately 20-30 minutes.
|
||||
|
||||
.. code::
|
||||
|
||||
cd /etc/ssl/certs
|
||||
sudo openssl dhparam -out dhparam.pem 3072
|
||||
|
||||
|
||||
6. Backup the existing nginx redash config, delete it, and then create a new version with the code supplied below.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo cp /etc/nginx/sites-available/redash /etc/nginx/sites-available/redash.bak
|
||||
sudo rm /etc/nginx/sites-available/redash
|
||||
sudo nano /etc/nginx/sites-available/redash
|
||||
|
||||
|
||||
.. code:: nginx
|
||||
|
||||
upstream redash_servers {
|
||||
server 127.0.0.1:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Allow accessing /ping without https. Useful when placing behind load balancer.
|
||||
location /ping {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_pass http://redash_servers;
|
||||
}
|
||||
|
||||
location / {
|
||||
# Enforce SSL.
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
ssl on;
|
||||
|
||||
# Make sure to set paths to your certificate .pem and .key files.
|
||||
ssl_certificate /etc/letsencrypt/live/YOURDOMAIN.TLD/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/YOURDOMAIN.TLD/privkey.pem;
|
||||
ssl_dhparam /etc/ssl/certs/dhparam.pem;
|
||||
|
||||
# Use secure protocols and ciphers which are compatible with modern browsers
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers AES256+EECDH:AES256+EDH;
|
||||
ssl_session_cache shared:SSL:20m;
|
||||
|
||||
# Enforce strict transport security
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains;";
|
||||
|
||||
access_log /var/log/nginx/redash.access.log;
|
||||
|
||||
gzip on;
|
||||
gzip_types *;
|
||||
gzip_proxied any;
|
||||
|
||||
location / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://redash_servers;
|
||||
proxy_redirect off;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7. Start the nginx and redash servers again.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo service nginx start
|
||||
sudo supervisorctl start redash_server
|
||||
|
||||
|
||||
8. Verify the installation by running a `SSLLabs test <https://www.ssllabs.com/ssltest/>`__. This guide *should* yield an A+ score. If everything is working as expected, optionally delete the old redash nginx config:
|
||||
|
||||
.. code::
|
||||
|
||||
sudo rm /etc/nginx/sites-available/redash.bak
|
||||
|
||||
|
||||
**Important Note:** letsencrypt certificates only remain valid for 90 days. To renew your certificate, simply follow steps 3 and 4 again:
|
||||
|
||||
.. code::
|
||||
|
||||
sudo supervisorctl stop redash_server
|
||||
sudo service nginx stop
|
||||
netstat -na | grep ':80.*LISTEN'
|
||||
|
||||
cd /opt/letsencrypt
|
||||
./letsencrypt-auto certonly --standalone
|
||||
|
||||
sudo service nginx start
|
||||
sudo supervisorctl start redash_server
|
||||
@@ -28,6 +28,7 @@ t2.micro should be enough):
|
||||
- ap-southeast-2: `ami-7559134f <https://console.aws.amazon.com/ec2/home?region=ap-southeast-2#LaunchInstanceWizard:ami=ami-7559134f>`__
|
||||
- ap-southeast-1: `ami-a0786bf2 <https://console.aws.amazon.com/ec2/home?region=ap-southeast-1#LaunchInstanceWizard:ami=ami-a0786bf2>`__
|
||||
|
||||
When launching the instance make sure to use a security grop, that only allows incoming traffic on: port 22 (SSH), 80 (HTTP) and 443 (HTTPS).
|
||||
|
||||
Now proceed to `"Setup" <#setup>`__.
|
||||
|
||||
@@ -91,10 +92,11 @@ file.
|
||||
1. Update the cookie secret (important! otherwise anyone can sign new
|
||||
cookies and impersonate users): change "veryverysecret" in the line:
|
||||
``export REDASH_COOKIE_SECRET=veryverysecret`` to something else (you
|
||||
can use ``pwgen 32 -1`` to generate random string).
|
||||
can run the command ``pwgen 32 -1`` to generate a random string).
|
||||
|
||||
2. By default we create an admin user with the password "admin". You
|
||||
can change this password at: ``/users/me#password``.
|
||||
can change this password opening the: ``/users/me#password`` page after
|
||||
logging in as admin.
|
||||
|
||||
3. If you want to use Google OAuth to authenticate users, you need to
|
||||
create a Google Developers project (see :doc:`instructions </misc/google_developers_project>`)
|
||||
@@ -104,22 +106,29 @@ file.
|
||||
|
||||
export REDASH_GOOGLE_CLIENT_ID=""
|
||||
export REDASH_GOOGLE_CLIENT_SECRET=""
|
||||
export REDASH_GOOGLE_APPS_DOMAIN=""
|
||||
|
||||
|
||||
4. Configure the domain(s) you want to allow to use with Google Apps, by running the command:
|
||||
|
||||
``REDASH_GOOGLE_CLIENT_ID`` and ``REDASH_GOOGLE_CLIENT_SECRET`` are the values you get after registering with Google. ``READASH_GOOGLE_APPS_DOMAIN`` is used in case you want to limit access to single Google apps domain (*if you leave it empty anyone with a Google account can access your instance*).
|
||||
.. code::
|
||||
|
||||
4. Restart the web server to apply the configuration changes:
|
||||
cd /opt/redash/current
|
||||
sudo -u redash bin/run ./manage.py set_google_apps_domains {{domains}}
|
||||
|
||||
|
||||
If you're passing multiple domains, separate them with commas.
|
||||
|
||||
|
||||
5. Restart the web server to apply the configuration changes:
|
||||
``sudo supervisorctl restart redash_server``.
|
||||
|
||||
5. Once you have Google OAuth enabled, you can login using your Google
|
||||
6. Once you have Google OAuth enabled, you can login using your Google
|
||||
Apps account. If you want to grant admin permissions to some users,
|
||||
you can do this by editing the user profile and enabling admin
|
||||
permission for it.
|
||||
|
||||
6. If you don't use Google OAuth or just need username/password logins,
|
||||
you can create additional users at: ``/users/new``.
|
||||
7. If you don't use Google OAuth or just need username/password logins,
|
||||
you can create additional users by opening the ``/users/new`` page.
|
||||
|
||||
Datasources
|
||||
-----------
|
||||
|
||||
@@ -8,15 +8,15 @@ from flask.ext.script import Manager
|
||||
|
||||
from redash import settings, models, __version__
|
||||
from redash.wsgi import app
|
||||
from redash.import_export import import_manager
|
||||
from redash.cli import users, database, data_sources
|
||||
from redash.cli import users, 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("import", import_manager)
|
||||
manager.add_command("ds", data_sources.manager)
|
||||
manager.add_command("org", organization.manager)
|
||||
|
||||
|
||||
|
||||
@manager.command
|
||||
|
||||
@@ -69,5 +69,5 @@ def update(data_source):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for data_source in DataSource.all():
|
||||
for data_source in DataSource.select():
|
||||
update(data_source)
|
||||
@@ -12,7 +12,7 @@ def convert_p12_to_pem(p12file):
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
for ds in DataSource.all():
|
||||
for ds in DataSource.select():
|
||||
|
||||
if ds.type == 'bigquery':
|
||||
options = json.loads(ds.options)
|
||||
|
||||
14
migrations/0014_add_alert_rearm_seconds.py
Normal file
14
migrations/0014_add_alert_rearm_seconds.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.add_column('alerts', 'rearm', models.Alert.rearm),
|
||||
)
|
||||
db.close_db(None)
|
||||
@@ -4,7 +4,7 @@ from redash.models import DataSource
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
for ds in DataSource.all():
|
||||
for ds in DataSource.select():
|
||||
if ds.type == 'elasticsearch':
|
||||
ds.type = 'kibana'
|
||||
ds.save()
|
||||
|
||||
18
migrations/0016_drop_tables_from_group.py
Normal file
18
migrations/0016_drop_tables_from_group.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.drop_column('groups', 'tables')
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
|
||||
|
||||
|
||||
35
migrations/0017_add_organization.py
Normal file
35
migrations/0017_add_organization.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from redash.models import db, Organization, Group
|
||||
from redash import settings
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
Organization.create_table()
|
||||
|
||||
default_org = Organization.create(name="Default", slug='default', settings={
|
||||
Organization.SETTING_GOOGLE_APPS_DOMAINS: list(settings.GOOGLE_APPS_DOMAIN)
|
||||
})
|
||||
|
||||
column = Group.org
|
||||
column.default = default_org
|
||||
|
||||
migrate(
|
||||
migrator.add_column('groups', 'org_id', column),
|
||||
migrator.add_column('events', 'org_id', column),
|
||||
migrator.add_column('data_sources', 'org_id', column),
|
||||
migrator.add_column('users', 'org_id', column),
|
||||
migrator.add_column('dashboards', 'org_id', column),
|
||||
migrator.add_column('queries', 'org_id', column),
|
||||
migrator.add_column('query_results', 'org_id', column),
|
||||
)
|
||||
|
||||
# Change the uniqueness constraint on user email to be (org, email):
|
||||
migrate(
|
||||
migrator.drop_index('users', 'users_email'),
|
||||
migrator.add_index('users', ('org_id', 'email'), unique=True)
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
45
migrations/0018_add_groups_refs.py
Normal file
45
migrations/0018_add_groups_refs.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from collections import defaultdict
|
||||
from redash.models import db, DataSourceGroup, DataSource, Group, Organization, User
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
import peewee
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
# Add type to groups
|
||||
migrate(
|
||||
migrator.add_column('groups', 'type', Group.type)
|
||||
)
|
||||
|
||||
for name in ['default', 'admin']:
|
||||
group = Group.get(Group.name==name)
|
||||
group.type = Group.BUILTIN_GROUP
|
||||
group.save()
|
||||
|
||||
# Create association table between data sources and groups
|
||||
DataSourceGroup.create_table()
|
||||
|
||||
# add default to existing data source:
|
||||
default_org = Organization.get_by_id(1)
|
||||
default_group = Group.get(Group.name=="default")
|
||||
for ds in DataSource.all(default_org):
|
||||
DataSourceGroup.create(data_source=ds, group=default_group)
|
||||
|
||||
# change the groups list on a user object to be an ids list
|
||||
migrate(
|
||||
migrator.rename_column('users', 'groups', 'old_groups'),
|
||||
)
|
||||
|
||||
migrate(migrator.add_column('users', 'groups', User.groups))
|
||||
|
||||
group_map = dict(map(lambda g: (g.name, g.id), Group.select()))
|
||||
user_map = defaultdict(list)
|
||||
for user in User.select(User, peewee.SQL('old_groups')):
|
||||
group_ids = [group_map[group] for group in user.old_groups]
|
||||
user.update_instance(groups=group_ids)
|
||||
|
||||
migrate(migrator.drop_column('users', 'old_groups'))
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
6
migrations/0019_add_super_admin_permission.py
Normal file
6
migrations/0019_add_super_admin_permission.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
admin_group = models.Group.get(models.Group.name=='admin')
|
||||
admin_group.permissions.append('super_admin')
|
||||
admin_group.save()
|
||||
19
migrations/0020_change_ds_name_to_non_uniqe.py
Normal file
19
migrations/0020_change_ds_name_to_non_uniqe.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from redash.models import db
|
||||
import peewee
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
# Change the uniqueness constraint on data source name to be (org, name):
|
||||
# In some cases it's a constraint:
|
||||
db.database.execute_sql('ALTER TABLE data_sources DROP CONSTRAINT IF EXISTS unique_name')
|
||||
# In others only an index:
|
||||
db.database.execute_sql('DROP INDEX IF EXISTS data_sources_name')
|
||||
|
||||
migrate(
|
||||
migrator.add_index('data_sources', ('org_id', 'name'), unique=True)
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,13 +0,0 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.Dashboard, models.Dashboard.created_at, 'created_at')
|
||||
migrator.add_column(models.Widget, models.Widget.created_at, 'created_at')
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,12 +0,0 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import models
|
||||
from redash.models import db
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.Dashboard, models.Dashboard.dashboard_filters_enabled, 'dashboard_filters_enabled')
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,12 +0,0 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.User, models.User.password_hash, 'password_hash')
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,13 +0,0 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.User, models.User.permissions, 'permissions')
|
||||
models.User.update(permissions=['admin'] + models.User.DEFAULT_PERMISSIONS).where(models.User.is_admin == True).execute()
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,13 +0,0 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.DataSource, models.DataSource.queue_name, 'queue_name')
|
||||
migrator.add_column(models.DataSource, models.DataSource.scheduled_queue_name, 'scheduled_queue_name')
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,13 +0,0 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.Widget, models.Widget.text, 'text')
|
||||
migrator.set_nullable(models.Widget, models.Widget.visualization, True)
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,13 +0,0 @@
|
||||
import peewee
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
previous_default_permissions = models.User.DEFAULT_PERMISSIONS[:]
|
||||
previous_default_permissions.remove('view_query')
|
||||
models.User.update(permissions=peewee.fn.array_append(models.User.permissions, 'view_query')).where(peewee.SQL("'view_source' = any(permissions)")).execute()
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,12 +0,0 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.set_nullable(models.Query, models.Query.description, True)
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,13 +0,0 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.set_nullable(models.Widget, models.Widget.query_id, True)
|
||||
migrator.set_nullable(models.Widget, models.Widget.type, True)
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,11 +0,0 @@
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
if not models.ActivityLog.table_exists():
|
||||
print "Creating activity_log table..."
|
||||
models.ActivityLog.create_table()
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,48 +0,0 @@
|
||||
import logging
|
||||
import peewee
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
from redash import settings
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
if not models.DataSource.table_exists():
|
||||
print "Creating data_sources table..."
|
||||
models.DataSource.create_table()
|
||||
|
||||
default_data_source = models.DataSource.create(name="Default",
|
||||
type=settings.CONNECTION_ADAPTER,
|
||||
options=settings.CONNECTION_STRING)
|
||||
else:
|
||||
default_data_source = models.DataSource.select().first()
|
||||
|
||||
migrator = Migrator(db.database)
|
||||
models.Query.data_source.null = True
|
||||
models.QueryResult.data_source.null = True
|
||||
try:
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.Query, models.Query.data_source, "data_source_id")
|
||||
except peewee.ProgrammingError:
|
||||
print "Failed to create data_source_id column -- assuming it already exists"
|
||||
|
||||
try:
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.QueryResult, models.QueryResult.data_source, "data_source_id")
|
||||
except peewee.ProgrammingError:
|
||||
print "Failed to create data_source_id column -- assuming it already exists"
|
||||
|
||||
print "Updating data source to existing one..."
|
||||
models.Query.update(data_source=default_data_source.id).execute()
|
||||
models.QueryResult.update(data_source=default_data_source.id).execute()
|
||||
|
||||
with db.database.transaction():
|
||||
print "Setting data source to non nullable..."
|
||||
migrator.set_nullable(models.Query, models.Query.data_source, False)
|
||||
|
||||
with db.database.transaction():
|
||||
print "Setting data source to non nullable..."
|
||||
migrator.set_nullable(models.QueryResult, models.QueryResult.data_source, False)
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,12 +0,0 @@
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
if not models.Event.table_exists():
|
||||
print "Creating events table..."
|
||||
models.Event.create_table()
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,56 +0,0 @@
|
||||
import json
|
||||
import itertools
|
||||
import peewee
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db, settings
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
if not models.User.table_exists():
|
||||
print "Creating user table..."
|
||||
models.User.create_table()
|
||||
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
print "Creating user field on dashboard and queries..."
|
||||
try:
|
||||
migrator.rename_column(models.Query, '"user"', "user_email")
|
||||
migrator.rename_column(models.Dashboard, '"user"', "user_email")
|
||||
except peewee.ProgrammingError:
|
||||
print "Failed to rename user column -- assuming it already exists"
|
||||
|
||||
with db.database.transaction():
|
||||
models.Query.user.null = True
|
||||
models.Dashboard.user.null = True
|
||||
|
||||
try:
|
||||
migrator.add_column(models.Query, models.Query.user, "user_id")
|
||||
migrator.add_column(models.Dashboard, models.Dashboard.user, "user_id")
|
||||
except peewee.ProgrammingError:
|
||||
print "Failed to create user_id column -- assuming it already exists"
|
||||
|
||||
print "Creating user for all queries and dashboards..."
|
||||
for obj in itertools.chain(models.Query.select(), models.Dashboard.select()):
|
||||
# Some old databases might have queries with empty string as user email:
|
||||
email = obj.user_email or settings.ADMINS[0]
|
||||
email = email.split(',')[0]
|
||||
|
||||
print ".. {} , {}, {}".format(type(obj), obj.id, email)
|
||||
|
||||
try:
|
||||
user = models.User.get(models.User.email == email)
|
||||
except models.User.DoesNotExist:
|
||||
is_admin = email in settings.ADMINS
|
||||
user = models.User.create(email=email, name=email, is_admin=is_admin)
|
||||
|
||||
obj.user = user
|
||||
obj.save()
|
||||
|
||||
print "Set user_id to non null..."
|
||||
with db.database.transaction():
|
||||
migrator.set_nullable(models.Query, models.Query.user, False)
|
||||
migrator.set_nullable(models.Dashboard, models.Dashboard.user, False)
|
||||
migrator.set_nullable(models.Query, models.Query.user_email, True)
|
||||
migrator.set_nullable(models.Dashboard, models.Dashboard.user_email, True)
|
||||
@@ -1,70 +0,0 @@
|
||||
import json
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
default_options = {"series": {"type": "column"}}
|
||||
|
||||
db.connect_db()
|
||||
|
||||
if not models.Visualization.table_exists():
|
||||
print "Creating visualization table..."
|
||||
models.Visualization.create_table()
|
||||
|
||||
with db.database.transaction():
|
||||
migrator = Migrator(db.database)
|
||||
print "Adding visualization_id to widgets:"
|
||||
field = models.Widget.visualization
|
||||
field.null = True
|
||||
migrator.add_column(models.Widget, models.Widget.visualization, 'visualization_id')
|
||||
|
||||
print 'Creating TABLE visualizations for all queries...'
|
||||
for query in models.Query.select():
|
||||
vis = models.Visualization(query=query, name="Table",
|
||||
description=query.description or "",
|
||||
type="TABLE", options="{}")
|
||||
vis.save()
|
||||
|
||||
print 'Creating COHORT visualizations for all queries named like %cohort%...'
|
||||
for query in models.Query.select().where(models.Query.name ** "%cohort%"):
|
||||
vis = models.Visualization(query=query, name="Cohort",
|
||||
description=query.description or "",
|
||||
type="COHORT", options="{}")
|
||||
vis.save()
|
||||
|
||||
print 'Create visualization for all widgets (unless exists already):'
|
||||
for widget in models.Widget.select():
|
||||
print 'Processing widget id: %d:' % widget.id
|
||||
vis_type = widget.type.upper()
|
||||
if vis_type == 'GRID':
|
||||
vis_type = 'TABLE'
|
||||
|
||||
query = models.Query.get_by_id(widget.query_id)
|
||||
vis = query.visualizations.where(models.Visualization.type == vis_type).first()
|
||||
if vis:
|
||||
print '... visualization type (%s) found.' % vis_type
|
||||
widget.visualization = vis
|
||||
widget.save()
|
||||
else:
|
||||
vis_name = vis_type.title()
|
||||
|
||||
options = json.loads(widget.options)
|
||||
vis_options = {"series": options} if options else default_options
|
||||
vis_options = json.dumps(vis_options)
|
||||
|
||||
vis = models.Visualization(query=query, name=vis_name,
|
||||
description=query.description or "",
|
||||
type=vis_type, options=vis_options)
|
||||
|
||||
print '... Created visualization for type: %s' % vis_type
|
||||
vis.save()
|
||||
widget.visualization = vis
|
||||
widget.save()
|
||||
|
||||
with db.database.transaction():
|
||||
migrator = Migrator(db.database)
|
||||
print "Setting visualization_id as not null..."
|
||||
migrator.set_nullable(models.Widget, models.Widget.visualization, False)
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,29 +0,0 @@
|
||||
import peewee
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import models
|
||||
from redash.models import db
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
|
||||
if not models.Group.table_exists():
|
||||
print "Creating groups table..."
|
||||
models.Group.create_table()
|
||||
|
||||
with db.database.transaction():
|
||||
models.Group.insert(name='admin', permissions=['admin'], tables=['*']).execute()
|
||||
models.Group.insert(name='api', permissions=['view_query'], tables=['*']).execute()
|
||||
models.Group.insert(name='default', permissions=models.Group.DEFAULT_PERMISSIONS, tables=['*']).execute()
|
||||
|
||||
migrator.add_column(models.User, models.User.groups, 'groups')
|
||||
|
||||
models.User.update(groups=['admin', 'default']).where(peewee.SQL("is_admin = true")).execute()
|
||||
models.User.update(groups=['admin', 'default']).where(peewee.SQL("'admin' = any(permissions)")).execute()
|
||||
models.User.update(groups=['default']).where(peewee.SQL("is_admin = false")).execute()
|
||||
|
||||
migrator.drop_column(models.User, 'permissions')
|
||||
migrator.drop_column(models.User, 'is_admin')
|
||||
|
||||
db.close_db(None)
|
||||
@@ -186,7 +186,7 @@ module.exports = function (grunt) {
|
||||
// concat, minify and revision files. Creates configurations in memory so
|
||||
// additional tasks can operate on them
|
||||
useminPrepare: {
|
||||
html: ['<%= yeoman.app %>/index.html', '<%= yeoman.app %>/login.html'],
|
||||
html: ['<%= yeoman.app %>/index.html', '<%= yeoman.app %>/login.html', '<%= yeoman.app %>/embed.html'],
|
||||
options: {
|
||||
dest: '<%= yeoman.dist %>',
|
||||
flow: {
|
||||
|
||||
127
rd_ui/app/embed.html
Normal file
127
rd_ui/app/embed.html
Normal file
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7" ng-app="redash" ng-controller='MainCtrl'> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8" ng-app="redash" ng-controller='MainCtrl'> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js lt-ie9" ng-app="redash" ng-controller='MainCtrl'> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" ng-app="redash" ng-controller='EmbedCtrl'> <!--<![endif]-->
|
||||
<head>
|
||||
<title ng-bind="'{{name}} | ' + pageTitle"></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<!-- build:css /styles/embed.css -->
|
||||
<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">
|
||||
<link rel="stylesheet" href="/bower_components/codemirror/lib/codemirror.css">
|
||||
<link rel="stylesheet" href="/bower_components/gridster/dist/jquery.gridster.css">
|
||||
<link rel="stylesheet" href="/bower_components/pivottable/dist/pivot.css">
|
||||
<link rel="stylesheet" href="/bower_components/cornelius/src/cornelius.css">
|
||||
<link rel="stylesheet" href="/bower_components/angular-ui-select/dist/select.css">
|
||||
<link rel="stylesheet" href="/bower_components/pace/themes/pace-theme-minimal.css">
|
||||
<link rel="stylesheet" href="/bower_components/font-awesome/css/font-awesome.css">
|
||||
<link rel="stylesheet" href="/bower_components/codemirror/addon/hint/show-hint.css">
|
||||
<link rel="stylesheet" href="/bower_components/leaflet/dist/leaflet.css">
|
||||
<link rel="stylesheet" href="/styles/redash.css">
|
||||
<!-- endbuild -->
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
|
||||
|
||||
<style>
|
||||
body { padding:0; }
|
||||
.col-lg-12, .row, .container, .panel { margin:0; padding:0; }
|
||||
.container::after, .row::after { display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div growl></div>
|
||||
<div ng-view></div>
|
||||
|
||||
<script src="/bower_components/jquery/jquery.js"></script>
|
||||
|
||||
<!-- build:js /scripts/embed-plugins.js -->
|
||||
<script src="/bower_components/angular/angular.js"></script>
|
||||
<script src="/bower_components/angular-sanitize/angular-sanitize.js"></script>
|
||||
<script src="/bower_components/jquery-ui/ui/jquery-ui.js"></script>
|
||||
<script src="/bower_components/bootstrap/js/collapse.js"></script>
|
||||
<script src="/bower_components/bootstrap/js/modal.js"></script>
|
||||
<script src="/bower_components/angular-resource/angular-resource.js"></script>
|
||||
<script src="/bower_components/angular-route/angular-route.js"></script>
|
||||
<script src="/bower_components/underscore/underscore.js"></script>
|
||||
<script src="/bower_components/moment/moment.js"></script>
|
||||
<script src="/bower_components/angular-moment/angular-moment.js"></script>
|
||||
<script src="/bower_components/codemirror/lib/codemirror.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/edit/matchbrackets.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/edit/closebrackets.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/hint/show-hint.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/hint/anyword-hint.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/sql/sql.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/python/python.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/javascript/javascript.js"></script>
|
||||
<script src="/bower_components/gridster/dist/jquery.gridster.js"></script>
|
||||
<script src="/bower_components/angular-growl/build/angular-growl.js"></script>
|
||||
<script src="/bower_components/pivottable/dist/pivot.js"></script>
|
||||
<script src="/bower_components/cornelius/src/cornelius.js"></script>
|
||||
<script src="/bower_components/mousetrap/mousetrap.js"></script>
|
||||
<script src="/bower_components/mousetrap/plugins/global-bind/mousetrap-global-bind.js"></script>
|
||||
<script src="/bower_components/angular-ui-select/dist/select.js"></script>
|
||||
<script src="/bower_components/underscore.string/lib/underscore.string.js"></script>
|
||||
<script src="/bower_components/marked/lib/marked.js"></script>
|
||||
<script src="/bower_components/angular-base64-upload/dist/angular-base64-upload.js"></script>
|
||||
<script src="/bower_components/plotly/plotly.js"></script>
|
||||
<script src="/bower_components/angular-plotly/src/angular-plotly.js"></script>
|
||||
<script src="/scripts/directives/plotly.js"></script>
|
||||
<script src="/scripts/ng_smart_table.js"></script>
|
||||
<script src="/bower_components/angular-ui-bootstrap-bower/ui-bootstrap-tpls.js"></script>
|
||||
<script src="/bower_components/bucky/bucky.js"></script>
|
||||
<script src="/bower_components/pace/pace.js"></script>
|
||||
<script src="/bower_components/mustache/mustache.js"></script>
|
||||
<script src="/bower_components/canvg/rgbcolor.js"></script>
|
||||
<script src="/bower_components/canvg/StackBlur.js"></script>
|
||||
<script src="/bower_components/canvg/canvg.js"></script>
|
||||
<script src="/bower_components/leaflet/dist/leaflet.js"></script>
|
||||
<script src="/bower_components/angular-bootstrap-show-errors/src/showErrors.js"></script>
|
||||
<script src="/bower_components/d3/d3.min.js"></script>
|
||||
<script src="/bower_components/angular-ui-sortable/sortable.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<!-- build:js({.tmp,app}) /scripts/embed-scripts.js -->
|
||||
<script src="/scripts/embed.js"></script>
|
||||
<script src="/scripts/services/services.js"></script>
|
||||
<script src="/scripts/services/resources.js"></script>
|
||||
<script src="/scripts/services/notifications.js"></script>
|
||||
<script src="/scripts/services/dashboards.js"></script>
|
||||
<script src="/scripts/controllers/controllers.js"></script>
|
||||
<script src="/scripts/controllers/dashboard.js"></script>
|
||||
<script src="/scripts/controllers/admin_controllers.js"></script>
|
||||
<script src="/scripts/controllers/data_sources.js"></script>
|
||||
<script src="/scripts/controllers/query_view.js"></script>
|
||||
<script src="/scripts/controllers/query_source.js"></script>
|
||||
<script src="/scripts/controllers/users.js"></script>
|
||||
<script src="/scripts/visualizations/base.js"></script>
|
||||
<script src="/scripts/visualizations/chart.js"></script>
|
||||
<script src="/scripts/visualizations/cohort.js"></script>
|
||||
<script src="/scripts/visualizations/map.js"></script>
|
||||
<script src="/scripts/visualizations/counter.js"></script>
|
||||
<script src="/scripts/visualizations/boxplot.js"></script>
|
||||
<script src="/scripts/visualizations/box.js"></script>
|
||||
<script src="/scripts/visualizations/table.js"></script>
|
||||
<script src="/scripts/visualizations/pivot.js"></script>
|
||||
<script src="/scripts/visualizations/date_range_selector.js"></script>
|
||||
<script src="/scripts/directives/directives.js"></script>
|
||||
<script src="/scripts/directives/query_directives.js"></script>
|
||||
<script src="/scripts/directives/data_source_directives.js"></script>
|
||||
<script src="/scripts/directives/dashboard_directives.js"></script>
|
||||
<script src="/scripts/filters.js"></script>
|
||||
<script src="/scripts/controllers/alerts.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<script>
|
||||
var clientConfig = {{ client_config|safe }};
|
||||
var visualization = {{ visualization|safe }};
|
||||
var query_result = {{ query_result|safe }};
|
||||
|
||||
{{ analytics|safe }}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,7 @@
|
||||
<!--[if IE 8]> <html class="no-js lt-ie9" ng-app="redash" ng-controller='MainCtrl'> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" ng-app="redash" ng-controller='MainCtrl'> <!--<![endif]-->
|
||||
<head>
|
||||
<base href="{{base_href}}">
|
||||
<title ng-bind="'{{name}} | ' + pageTitle"></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
@@ -39,7 +40,7 @@
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/"><img src="/images/redash_icon_small.png"/></a>
|
||||
<a class="navbar-brand" href="{{base_href}}"><img src="/images/redash_icon_small.png"/></a>
|
||||
</div>
|
||||
{% raw %}
|
||||
<div class="collapse navbar-collapse navbar-ex1-collapse">
|
||||
@@ -52,13 +53,13 @@
|
||||
<a href="#" ng-bind="name"></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li ng-repeat="dashboard in group" role="presentation">
|
||||
<a role="menu-item" ng-href="/dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
|
||||
<a role="menu-item" ng-href="dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</span>
|
||||
<li ng-repeat="dashboard in otherDashboards">
|
||||
<a role="menu-item" ng-href="/dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
|
||||
<a role="menu-item" ng-href="dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
|
||||
</li>
|
||||
<li class="divider" ng-show="currentUser.hasPermission('create_dashboard') && (groupedDashboards.length > 0 || otherDashboards.length > 0)"></li>
|
||||
<li><a data-toggle="modal" href="#new_dashboard_dialog" ng-show="currentUser.hasPermission('create_dashboard')">New Dashboard</a></li>
|
||||
@@ -67,12 +68,12 @@
|
||||
<li class="dropdown" ng-show="currentUser.hasPermission('view_query')" dropdown>
|
||||
<a href="#" class="dropdown-toggle" dropdown-toggle>Queries <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu" dropdown-menu>
|
||||
<li ng-show="currentUser.hasPermission('create_query')"><a href="/queries/new">New Query</a></li>
|
||||
<li><a href="/queries">Queries</a></li>
|
||||
<li ng-show="currentUser.hasPermission('create_query')"><a href="queries/new">New Query</a></li>
|
||||
<li><a href="queries">Queries</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/alerts">Alerts</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="navbar-form navbar-left" role="search" ng-submit="searchQueries()">
|
||||
@@ -83,16 +84,16 @@
|
||||
</form>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<li ng-show="currentUser.hasPermission('admin')">
|
||||
<a href="/data_sources" title="Data Sources"><i class="fa fa-database"></i></a>
|
||||
<a href="data_sources" title="Data Sources"><i class="fa fa-database"></i></a>
|
||||
</li>
|
||||
<li ng-show="currentUser.hasPermission('list_users')">
|
||||
<a href="/users" title="Users"><i class="fa fa-users"></i></a>
|
||||
<a href="users" title="Users"><i class="fa fa-users"></i></a>
|
||||
</li>
|
||||
<li class="dropdown" dropdown>
|
||||
<a href="#" class="dropdown-toggle" dropdown-toggle><span ng-bind="currentUser.name"></span> <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu" dropdown-menu>
|
||||
<li style="width:300px">
|
||||
<a ng-href="/users/{{currentUser.id}}">
|
||||
<a ng-href="users/{{currentUser.id}}">
|
||||
<div class="row">
|
||||
<div class="col-sm-2">
|
||||
<img ng-src="{{currentUser.gravatar_url}}" size="40px" class="img-circle"/>
|
||||
@@ -106,7 +107,7 @@
|
||||
<li class="divider">
|
||||
</li>
|
||||
<li>
|
||||
<a href="/logout" target="_self">Log out</a>
|
||||
<a href="logout" target="_self">Log out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -119,6 +120,16 @@
|
||||
|
||||
<edit-dashboard-form dashboard="newDashboard" id="new_dashboard_dialog"></edit-dashboard-form>
|
||||
<div ng-view></div>
|
||||
<div ng-if="showPermissionError" class="ng-cloak container" ng-cloak>
|
||||
<div class="row">
|
||||
<div class="text-center">
|
||||
<h1><span class="glyphicon glyphicon-lock"></span></h1>
|
||||
<p class="text-muted">
|
||||
You do not have permission to view the requested page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% raw %}
|
||||
<div class="container-fluid footer">
|
||||
@@ -157,8 +168,6 @@
|
||||
<script src="/bower_components/codemirror/mode/sql/sql.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/python/python.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/javascript/javascript.js"></script>
|
||||
<script src="/bower_components/highcharts/highcharts.js"></script>
|
||||
<script src="/bower_components/highcharts/modules/exporting.js"></script>
|
||||
<script src="/bower_components/gridster/dist/jquery.gridster.js"></script>
|
||||
<script src="/bower_components/angular-growl/build/angular-growl.js"></script>
|
||||
<script src="/bower_components/pivottable/dist/pivot.js"></script>
|
||||
@@ -169,10 +178,11 @@
|
||||
<script src="/bower_components/underscore.string/lib/underscore.string.js"></script>
|
||||
<script src="/bower_components/marked/lib/marked.js"></script>
|
||||
<script src="/bower_components/angular-base64-upload/dist/angular-base64-upload.js"></script>
|
||||
<script src="/scripts/ng_highchart.js"></script>
|
||||
<script src="/bower_components/plotly/plotly.js"></script>
|
||||
<script src="/bower_components/angular-plotly/src/angular-plotly.js"></script>
|
||||
<script src="/scripts/directives/plotly.js"></script>
|
||||
<script src="/scripts/ng_smart_table.js"></script>
|
||||
<script src="/bower_components/angular-ui-bootstrap-bower/ui-bootstrap-tpls.js"></script>
|
||||
<script src="/bower_components/bucky/bucky.js"></script>
|
||||
<script src="/bower_components/pace/pace.js"></script>
|
||||
<script src="/bower_components/mustache/mustache.js"></script>
|
||||
<script src="/bower_components/canvg/rgbcolor.js"></script>
|
||||
@@ -219,6 +229,7 @@
|
||||
// TODO: move currentUser & features to be an Angular service
|
||||
var clientConfig = {{ client_config|safe }};
|
||||
var currentUser = {{ user|safe }};
|
||||
var currentOrgSlug = "{{ org_slug }}";
|
||||
|
||||
currentUser.canEdit = function(object) {
|
||||
var user_id = object.user_id || (object.user && object.user.id);
|
||||
@@ -229,6 +240,9 @@
|
||||
return this.permissions.indexOf(permission) != -1;
|
||||
};
|
||||
|
||||
currentUser.isAdmin = currentUser.hasPermission('admin');
|
||||
|
||||
|
||||
{{ analytics|safe }}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
{% if show_google_openid %}
|
||||
|
||||
<div class="row">
|
||||
<a href="/oauth/google?next={{next}}"><img src="/google_login.png" class="login-button"/></a>
|
||||
<a href="{{ google_auth_url }}"><img src="/google_login.png" class="login-button"/></a>
|
||||
</div>
|
||||
|
||||
<div class="login-or">
|
||||
|
||||
@@ -6,7 +6,8 @@ angular.module('redash', [
|
||||
'redash.services',
|
||||
'redash.renderers',
|
||||
'redash.visualization',
|
||||
'highchart',
|
||||
'plotly',
|
||||
'plotly-chart',
|
||||
'angular-growl',
|
||||
'angularMoment',
|
||||
'ui.bootstrap',
|
||||
@@ -20,15 +21,6 @@ angular.module('redash', [
|
||||
'ngSanitize'
|
||||
]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider', 'uiSelectConfig',
|
||||
function ($routeProvider, $locationProvider, $compileProvider, growlProvider, uiSelectConfig) {
|
||||
if (clientConfig.clientSideMetrics) {
|
||||
Bucky.setOptions({
|
||||
host: '/api/metrics'
|
||||
});
|
||||
|
||||
Bucky.requests.monitor('ajax_requsts');
|
||||
Bucky.requests.transforms.enable('dashboards', /dashboard\/[\w-]+/ig, '/dashboard');
|
||||
}
|
||||
|
||||
function getQuery(Query, $route) {
|
||||
var query = Query.get({'id': $route.current.params.queryId });
|
||||
return query.$promise;
|
||||
@@ -57,7 +49,8 @@ angular.module('redash', [
|
||||
resolve: {
|
||||
'query': ['Query', function newQuery(Query) {
|
||||
return Query.newQuery();
|
||||
}]
|
||||
}],
|
||||
'dataSources': ['DataSource', function (DataSource) { return DataSource.query().$promise }]
|
||||
}
|
||||
});
|
||||
$routeProvider.when('/queries/search', {
|
||||
@@ -117,15 +110,24 @@ angular.module('redash', [
|
||||
templateUrl: '/views/users/list.html',
|
||||
controller: 'UsersCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/', {
|
||||
templateUrl: '/views/personal.html',
|
||||
controller: 'PersonalIndexCtrl'
|
||||
$routeProvider.when('/groups/:groupId/data_sources', {
|
||||
templateUrl: '/views/groups/show_data_sources.html',
|
||||
controller: 'GroupDataSourcesCtrl'
|
||||
});
|
||||
$routeProvider.when('/groups/:groupId', {
|
||||
templateUrl: '/views/groups/show.html',
|
||||
controller: 'GroupCtrl'
|
||||
});
|
||||
$routeProvider.when('/groups', {
|
||||
templateUrl: '/views/groups/list.html',
|
||||
controller: 'GroupsCtrl'
|
||||
})
|
||||
$routeProvider.when('/', {
|
||||
templateUrl: '/views/index.html',
|
||||
controller: 'IndexCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/personal', {
|
||||
templateUrl: '/views/personal.html',
|
||||
controller: 'PersonalIndexCtrl'
|
||||
redirectTo: '/'
|
||||
});
|
||||
$routeProvider.otherwise({
|
||||
redirectTo: '/'
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplate": '<a href="/alerts/{{dataRow.id}}">{{dataRow.name}}</a> (<a href="/queries/{{dataRow.query.id}}">query</a>)'
|
||||
"cellTemplate": '<a href="alerts/{{dataRow.id}}">{{dataRow.name}}</a> (<a href="queries/{{dataRow.query.id}}">query</a>)'
|
||||
},
|
||||
{
|
||||
'label': 'Created By',
|
||||
@@ -96,7 +96,9 @@
|
||||
if ($scope.alert.name === undefined || $scope.alert.name === '') {
|
||||
$scope.alert.name = $scope.getDefaultName();
|
||||
}
|
||||
|
||||
if ($scope.alert.rearm === '' || $scope.alert.rearm === 0) {
|
||||
$scope.alert.rearm = null;
|
||||
}
|
||||
$scope.alert.$save(function(alert) {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
if ($scope.alertId === "new") {
|
||||
@@ -171,4 +173,4 @@
|
||||
.controller('AlertsCtrl', ['$scope', 'Events', 'Alert', AlertsCtrl])
|
||||
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', AlertCtrl])
|
||||
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -151,16 +151,21 @@
|
||||
}
|
||||
|
||||
var MainCtrl = function ($scope, $location, Dashboard, notifications) {
|
||||
$scope.$on("$routeChangeSuccess", function (event, current, previous, rejection) {
|
||||
if ($scope.showPermissionError) {
|
||||
$scope.showPermissionError = false;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$on("$routeChangeError", function (event, current, previous, rejection) {
|
||||
if (rejection.status === 403) {
|
||||
$scope.showPermissionError = true;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.version = clientConfig.version;
|
||||
$scope.newVersionAvailable = clientConfig.newVersionAvailable && currentUser.hasPermission("admin");
|
||||
|
||||
if (clientConfig.clientSideMetrics) {
|
||||
$scope.$on('$locationChangeSuccess', function(event, newLocation, oldLocation) {
|
||||
// This will be called once per actual page load.
|
||||
Bucky.sendPagePerformance();
|
||||
});
|
||||
}
|
||||
|
||||
$scope.dashboards = [];
|
||||
$scope.reloadDashboards = function () {
|
||||
Dashboard.query(function (dashboards) {
|
||||
@@ -194,12 +199,7 @@
|
||||
});
|
||||
};
|
||||
|
||||
var IndexCtrl = function ($scope, Events, Dashboard) {
|
||||
Events.record(currentUser, "view", "page", "homepage");
|
||||
$scope.$parent.pageTitle = "Home";
|
||||
};
|
||||
|
||||
var PersonalIndexCtrl = function ($scope, Events, Dashboard, Query) {
|
||||
var IndexCtrl = function ($scope, Events, Dashboard, Query) {
|
||||
Events.record(currentUser, "view", "page", "personal_homepage");
|
||||
$scope.$parent.pageTitle = "Home";
|
||||
|
||||
@@ -209,8 +209,7 @@
|
||||
|
||||
angular.module('redash.controllers', [])
|
||||
.controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl])
|
||||
.controller('IndexCtrl', ['$scope', 'Events', 'Dashboard', IndexCtrl])
|
||||
.controller('PersonalIndexCtrl', ['$scope', 'Events', 'Dashboard', 'Query', PersonalIndexCtrl])
|
||||
.controller('IndexCtrl', ['$scope', 'Events', 'Dashboard', 'Query', IndexCtrl])
|
||||
.controller('MainCtrl', ['$scope', '$location', 'Dashboard', 'notifications', MainCtrl])
|
||||
.controller('QuerySearchCtrl', ['$scope', '$location', '$filter', 'Events', 'Query', QuerySearchCtrl]);
|
||||
})();
|
||||
|
||||
@@ -3,71 +3,69 @@
|
||||
$scope.refreshEnabled = false;
|
||||
$scope.refreshRate = 60;
|
||||
|
||||
var loadDashboard = _.throttle(function() {
|
||||
$scope.dashboard = Dashboard.get({ slug: $routeParams.dashboardSlug }, function (dashboard) {
|
||||
Events.record(currentUser, "view", "dashboard", dashboard.id);
|
||||
var renderDashboard = function (dashboard) {
|
||||
$scope.$parent.pageTitle = dashboard.name;
|
||||
|
||||
$scope.$parent.pageTitle = dashboard.name;
|
||||
var promises = [];
|
||||
|
||||
var promises = [];
|
||||
_.each($scope.dashboard.widgets, function (row) {
|
||||
return _.each(row, function (widget) {
|
||||
if (widget.visualization) {
|
||||
var queryResult = widget.getQuery().getQueryResult();
|
||||
if (angular.isDefined(queryResult))
|
||||
promises.push(queryResult.toPromise());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$scope.dashboard.widgets = _.map($scope.dashboard.widgets, function (row) {
|
||||
return _.map(row, function (widget) {
|
||||
var w = new Widget(widget);
|
||||
$q.all(promises).then(function(queryResults) {
|
||||
var filters = {};
|
||||
_.each(queryResults, function(queryResult) {
|
||||
var queryFilters = queryResult.getFilters();
|
||||
_.each(queryFilters, function (queryFilter) {
|
||||
var hasQueryStringValue = _.has($location.search(), queryFilter.name);
|
||||
|
||||
if (w.visualization) {
|
||||
var queryResult = w.getQuery().getQueryResult();
|
||||
if (angular.isDefined(queryResult))
|
||||
promises.push(queryResult.toPromise());
|
||||
if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) {
|
||||
// If dashboard filters not enabled, or no query string value given, skip filters linking.
|
||||
return;
|
||||
}
|
||||
|
||||
return w;
|
||||
});
|
||||
});
|
||||
|
||||
$q.all(promises).then(function(queryResults) {
|
||||
var filters = {};
|
||||
_.each(queryResults, function(queryResult) {
|
||||
var queryFilters = queryResult.getFilters();
|
||||
_.each(queryFilters, function (queryFilter) {
|
||||
var hasQueryStringValue = _.has($location.search(), queryFilter.name);
|
||||
|
||||
if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) {
|
||||
// If dashboard filters not enabled, or no query string value given, skip filters linking.
|
||||
return;
|
||||
if (!_.has(filters, queryFilter.name)) {
|
||||
var filter = _.extend({}, queryFilter);
|
||||
filters[filter.name] = filter;
|
||||
filters[filter.name].originFilters = [];
|
||||
if (hasQueryStringValue) {
|
||||
filter.current = $location.search()[filter.name];
|
||||
}
|
||||
|
||||
if (!_.has(filters, queryFilter.name)) {
|
||||
var filter = _.extend({}, queryFilter);
|
||||
filters[filter.name] = filter;
|
||||
filters[filter.name].originFilters = [];
|
||||
if (hasQueryStringValue) {
|
||||
filter.current = $location.search()[filter.name];
|
||||
}
|
||||
|
||||
$scope.$watch(function () { return filter.current }, function (value) {
|
||||
_.each(filter.originFilters, function (originFilter) {
|
||||
originFilter.current = value;
|
||||
});
|
||||
$scope.$watch(function () { return filter.current }, function (value) {
|
||||
_.each(filter.originFilters, function (originFilter) {
|
||||
originFilter.current = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: merge values.
|
||||
filters[queryFilter.name].originFilters.push(queryFilter);
|
||||
});
|
||||
// TODO: merge values.
|
||||
filters[queryFilter.name].originFilters.push(queryFilter);
|
||||
});
|
||||
|
||||
$scope.filters = _.values(filters);
|
||||
});
|
||||
|
||||
|
||||
}, function () {
|
||||
// error...
|
||||
// try again. we wrap loadDashboard with throttle so it doesn't happen too often.\
|
||||
// we might want to consider exponential backoff and also move this as a general solution in $http/$resource for
|
||||
// all AJAX calls.
|
||||
loadDashboard();
|
||||
$scope.filters = _.values(filters);
|
||||
});
|
||||
}
|
||||
|
||||
var loadDashboard = _.throttle(function () {
|
||||
$scope.dashboard = Dashboard.get({slug: $routeParams.dashboardSlug}, function (dashboard) {
|
||||
Events.record(currentUser, "view", "dashboard", dashboard.id);
|
||||
renderDashboard(dashboard);
|
||||
}, function () {
|
||||
// error...
|
||||
// try again. we wrap loadDashboard with throttle so it doesn't happen too often.\
|
||||
// we might want to consider exponential backoff and also move this as a general solution in $http/$resource for
|
||||
// all AJAX calls.
|
||||
loadDashboard();
|
||||
}
|
||||
);
|
||||
}, 1000);
|
||||
|
||||
loadDashboard();
|
||||
@@ -134,12 +132,16 @@
|
||||
|
||||
Events.record(currentUser, "delete", "widget", $scope.widget.id);
|
||||
|
||||
$scope.widget.$delete(function() {
|
||||
$scope.widget.$delete(function(response) {
|
||||
$scope.dashboard.widgets = _.map($scope.dashboard.widgets, function(row) {
|
||||
return _.filter(row, function(widget) {
|
||||
return widget.id != undefined;
|
||||
})
|
||||
});
|
||||
|
||||
$scope.dashboard.widgets = _.filter($scope.dashboard.widgets, function(row) { return row.length > 0 });
|
||||
|
||||
$scope.dashboard.layout = response.layout;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -155,6 +157,8 @@
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
|
||||
|
||||
$scope.type = 'visualization';
|
||||
} else if ($scope.widget.restricted) {
|
||||
$scope.type = 'restricted';
|
||||
} else {
|
||||
$scope.type = 'textbox';
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
event.stopPropagation();
|
||||
Events.record(currentUser, "delete", "datasource", datasource.id);
|
||||
datasource.$delete(function(resource) {
|
||||
growl.addSuccessMessage("Data source deleted succesfully.");
|
||||
growl.addSuccessMessage("Data source deleted successfully.");
|
||||
this.$parent.dataSources = _.without(this.dataSources, resource);
|
||||
}.bind(this), function(httpResponse) {
|
||||
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
saveQuery = $scope.saveQuery;
|
||||
|
||||
$scope.sourceMode = true;
|
||||
$scope.canEdit = currentUser.canEdit($scope.query) || clientConfig.allowAllToEditQueries;
|
||||
$scope.canEdit = currentUser.canEdit($scope.query);// TODO: bring this back? || clientConfig.allowAllToEditQueries;
|
||||
$scope.isDirty = false;
|
||||
$scope.base_url = $location.protocol()+"://"+$location.host()+":"+$location.port();
|
||||
|
||||
$scope.newVisualization = undefined;
|
||||
|
||||
@@ -67,9 +68,9 @@
|
||||
|
||||
$scope.duplicateQuery = function() {
|
||||
Events.record(currentUser, 'fork', 'query', $scope.query.id);
|
||||
$scope.query.name = 'Copy of (#'+$scope.query.id+') '+$scope.query.name;
|
||||
$scope.query.id = null;
|
||||
$scope.query.schedule = null;
|
||||
|
||||
$scope.saveQuery({
|
||||
successMessage: 'Query forked',
|
||||
errorMessage: 'Query could not be forked'
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
function QueryViewCtrl($scope, Events, $route, $location, notifications, growl, $modal, Query, DataSource) {
|
||||
var DEFAULT_TAB = 'table';
|
||||
|
||||
$scope.base_url = $location.protocol()+"://"+$location.host()+":"+$location.port();
|
||||
|
||||
var getQueryResult = function(maxAge) {
|
||||
// Collect params, and getQueryResult with params; getQueryResult merges it into the query
|
||||
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
|
||||
@@ -33,6 +35,7 @@
|
||||
var isValidDataSourceId = !isNaN(dataSourceId) && _.some($scope.dataSources, function(ds) {
|
||||
return ds.id == dataSourceId;
|
||||
});
|
||||
|
||||
if (!isValidDataSourceId) {
|
||||
dataSourceId = $scope.dataSources[0].id;
|
||||
}
|
||||
@@ -41,13 +44,37 @@
|
||||
return dataSourceId;
|
||||
}
|
||||
|
||||
var updateDataSources = function(dataSources) {
|
||||
// Filter out data sources the user can't query (or used by current query):
|
||||
$scope.dataSources = _.filter(dataSources, function(dataSource) {
|
||||
return !dataSource.view_only || dataSource.id === $scope.query.data_source_id;
|
||||
});
|
||||
|
||||
if ($scope.dataSources.length == 0) {
|
||||
$scope.noDataSources = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.query.isNew()) {
|
||||
$scope.query.data_source_id = getDataSourceId();
|
||||
}
|
||||
|
||||
$scope.dataSource = _.find(dataSources, function(ds) { return ds.id == $scope.query.data_source_id; });
|
||||
|
||||
//$scope.canExecuteQuery = $scope.canExecuteQuery && _.some(dataSources, function(ds) { return !ds.view_only });
|
||||
$scope.canCreateQuery = _.any(dataSources, function(ds) { return !ds.view_only });
|
||||
|
||||
updateSchema();
|
||||
}
|
||||
|
||||
|
||||
$scope.dataSource = {};
|
||||
$scope.query = $route.current.locals.query;
|
||||
|
||||
var updateSchema = function() {
|
||||
$scope.hasSchema = false;
|
||||
$scope.editorSize = "col-md-12";
|
||||
DataSource.getSchema({id: getDataSourceId()}, function(data) {
|
||||
DataSource.getSchema({id: $scope.query.data_source_id}, function(data) {
|
||||
if (data && data.length > 0) {
|
||||
$scope.schema = data;
|
||||
_.each(data, function(table) {
|
||||
@@ -69,17 +96,19 @@
|
||||
|
||||
$scope.isQueryOwner = (currentUser.id === $scope.query.user.id) || currentUser.hasPermission('admin');
|
||||
$scope.canViewSource = currentUser.hasPermission('view_source');
|
||||
$scope.canExecuteQuery = currentUser.hasPermission('execute_query');
|
||||
|
||||
$scope.canExecuteQuery = function() {
|
||||
return currentUser.hasPermission('execute_query') && !$scope.dataSource.view_only;
|
||||
}
|
||||
|
||||
$scope.canScheduleQuery = currentUser.hasPermission('schedule_query');
|
||||
|
||||
$scope.dataSources = DataSource.query(function(dataSources) {
|
||||
updateSchema();
|
||||
|
||||
if ($scope.query.isNew()) {
|
||||
$scope.query.data_source_id = getDataSourceId();
|
||||
$scope.dataSource = _.find(dataSources, function(ds) { return ds.id == $scope.query.data_source_id; });
|
||||
}
|
||||
});
|
||||
if ($route.current.locals.dataSources) {
|
||||
$scope.dataSources = $route.current.locals.dataSources;
|
||||
updateDataSources($route.current.locals.dataSources);
|
||||
} else {
|
||||
$scope.dataSources = DataSource.query(updateDataSources);
|
||||
}
|
||||
|
||||
// in view mode, latest dataset is always visible
|
||||
// source mode changes this behavior
|
||||
@@ -127,7 +156,7 @@
|
||||
};
|
||||
|
||||
$scope.executeQuery = function() {
|
||||
if (!$scope.canExecuteQuery) {
|
||||
if (!$scope.canExecuteQuery()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,214 @@
|
||||
(function () {
|
||||
var GroupsCtrl = function ($scope, $location, $modal, growl, Events, Group) {
|
||||
Events.record(currentUser, "view", "page", "groups");
|
||||
$scope.$parent.pageTitle = "Groups";
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 20,
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplate": '<a href="groups/{{dataRow.id}}">{{dataRow.name}}</a>'
|
||||
}
|
||||
];
|
||||
|
||||
$scope.groups = [];
|
||||
Group.query(function(groups) {
|
||||
$scope.groups = groups;
|
||||
});
|
||||
|
||||
$scope.newGroup = function() {
|
||||
$modal.open({
|
||||
templateUrl: '/views/groups/edit_group_form.html',
|
||||
size: 'sm',
|
||||
resolve: {
|
||||
group: function() { return new Group({}); }
|
||||
},
|
||||
controller: ['$scope', '$modalInstance', 'group', function($scope, $modalInstance, group) {
|
||||
$scope.group = group;
|
||||
var newGroup = group.id === undefined;
|
||||
|
||||
if (newGroup) {
|
||||
$scope.saveButtonText = "Create";
|
||||
$scope.title = "Create a New Group";
|
||||
} else {
|
||||
$scope.saveButtonText = "Save";
|
||||
$scope.title = "Edit Group";
|
||||
}
|
||||
|
||||
$scope.ok = function() {
|
||||
$scope.group.$save(function(group) {
|
||||
if (newGroup) {
|
||||
$location.path('/groups/' + group.id).replace();
|
||||
$modalInstance.close();
|
||||
} else {
|
||||
$modalInstance.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$scope.cancel = function() {
|
||||
$modalInstance.close();
|
||||
}
|
||||
}]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var usersNav = function($location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
template:
|
||||
'<ul class="nav nav-tabs">' +
|
||||
'<li role="presentation" ng-class="{\'active\': usersPage }"><a href="users">Users</a></li>' +
|
||||
'<li role="presentation" ng-class="{\'active\': groupsPage }" ng-if="showGroupsLink"><a href="groups">Groups</a></li>' +
|
||||
'</ul>',
|
||||
controller: ['$scope', function ($scope) {
|
||||
$scope.usersPage = _.string.startsWith($location.path(), '/users');
|
||||
$scope.groupsPage = _.string.startsWith($location.path(), '/groups');
|
||||
$scope.showGroupsLink = currentUser.hasPermission('list_users');
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
var groupName = function ($location, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
'group': '='
|
||||
},
|
||||
transclude: true,
|
||||
template:
|
||||
'<h2>'+
|
||||
'<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>',
|
||||
replace: true,
|
||||
controller: ['$scope', function ($scope) {
|
||||
$scope.canEdit = function() {
|
||||
return currentUser.isAdmin && $scope.group.type != 'builtin';
|
||||
};
|
||||
|
||||
$scope.saveName = function() {
|
||||
$scope.group.$save();
|
||||
};
|
||||
|
||||
$scope.deleteGroup = function() {
|
||||
if (confirm("Are you sure you want to delete this group?")) {
|
||||
$scope.group.$delete(function() {
|
||||
$location.path('/groups').replace();
|
||||
growl.addSuccessMessage("Group deleted successfully.");
|
||||
})
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
var GroupDataSourcesCtrl = function($scope, $routeParams, $http, $location, growl, Events, Group, DataSource) {
|
||||
Events.record(currentUser, "view", "group_data_sources", $scope.groupId);
|
||||
$scope.group = Group.get({id: $routeParams.groupId});
|
||||
$scope.dataSources = Group.dataSources({id: $routeParams.groupId});
|
||||
$scope.newDataSource = {};
|
||||
|
||||
$scope.findDataSource = function(search) {
|
||||
if ($scope.foundDataSources === undefined) {
|
||||
DataSource.query(function(dataSources) {
|
||||
var existingIds = _.map($scope.dataSources, function(m) { return m.id; });
|
||||
$scope.foundDataSources = _.filter(dataSources, function(ds) { return !_.contains(existingIds, ds.id); });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$scope.addDataSource = function(dataSource) {
|
||||
// Clear selection, to clear up the input control.
|
||||
$scope.newDataSource.selected = undefined;
|
||||
|
||||
$http.post('api/groups/' + $routeParams.groupId + '/data_sources', {'data_source_id': dataSource.id}).success(function(user) {
|
||||
dataSource.view_only = false;
|
||||
$scope.dataSources.unshift(dataSource);
|
||||
|
||||
if ($scope.foundDataSources) {
|
||||
$scope.foundDataSources = _.filter($scope.foundDataSources, function(ds) { return ds != dataSource; });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$scope.changePermission = function(dataSource, viewOnly) {
|
||||
$http.post('api/groups/' + $routeParams.groupId + '/data_sources/' + dataSource.id, {view_only: viewOnly}).success(function() {
|
||||
dataSource.view_only = viewOnly;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.removeDataSource = function(dataSource) {
|
||||
$http.delete('api/groups/' + $routeParams.groupId + '/data_sources/' + dataSource.id).success(function() {
|
||||
$scope.dataSources = _.filter($scope.dataSources, function(ds) { return dataSource != ds; });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
var GroupCtrl = function($scope, $routeParams, $http, $location, growl, Events, Group, User) {
|
||||
Events.record(currentUser, "view", "group", $scope.groupId);
|
||||
$scope.group = Group.get({id: $routeParams.groupId});
|
||||
$scope.members = Group.members({id: $routeParams.groupId});
|
||||
$scope.newMember = {};
|
||||
|
||||
$scope.findUser = function(search) {
|
||||
if (search == "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.foundUsers === undefined) {
|
||||
User.query(function(users) {
|
||||
var existingIds = _.map($scope.members, function(m) { return m.id; });
|
||||
_.each(users, function(user) { user.alreadyMember = _.contains(existingIds, user.id); });
|
||||
$scope.foundUsers = users;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$scope.addMember = function(user) {
|
||||
// Clear selection, to clear up the input control.
|
||||
$scope.newMember.selected = undefined;
|
||||
|
||||
$http.post('api/groups/' + $routeParams.groupId + '/members', {'user_id': user.id}).success(function() {
|
||||
$scope.members.unshift(user);
|
||||
user.alreadyMember = true;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.removeMember = function(member) {
|
||||
$http.delete('api/groups/' + $routeParams.groupId + '/members/' + member.id).success(function() {
|
||||
$scope.members = _.filter($scope.members, function(m) { return m != member });
|
||||
|
||||
if ($scope.foundUsers) {
|
||||
_.each($scope.foundUsers, function(user) { if (user.id == member.id) { user.alreadyMember = false }; });
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
var UsersCtrl = function ($scope, $location, growl, Events, User) {
|
||||
Events.record(currentUser, "view", "page", "users");
|
||||
$scope.$parent.pageTitle = "Users";
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 50,
|
||||
itemsByPage: 20,
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "",
|
||||
"map": "gravatar_url",
|
||||
"cellTemplate": '<img src="{{dataRow.gravatar_url}}" height="40px"/>'
|
||||
},
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplate": '<a href="/users/{{dataRow.id}}">{{dataRow.name}}</a>'
|
||||
"cellTemplate": '<img src="{{dataRow.gravatar_url}}" height="40px"/> <a href="users/{{dataRow.id}}">{{dataRow.name}}</a>'
|
||||
},
|
||||
{
|
||||
'label': 'Joined',
|
||||
@@ -154,6 +344,11 @@
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('GroupsCtrl', ['$scope', '$location', '$modal', 'growl', 'Events', 'Group', GroupsCtrl])
|
||||
.directive('groupName', ['$location', 'growl', groupName])
|
||||
.directive('usersNav', ['$location', usersNav])
|
||||
.controller('GroupCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Group', 'User', GroupCtrl])
|
||||
.controller('GroupDataSourcesCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Group', 'DataSource', GroupDataSourcesCtrl])
|
||||
.controller('UsersCtrl', ['$scope', '$location', 'growl', 'Events', 'User', UsersCtrl])
|
||||
.controller('UserCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'User', UserCtrl])
|
||||
.controller('NewUserCtrl', ['$scope', '$location', 'growl', 'Events', 'User', NewUserCtrl])
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
'<div class="panel-heading">{name}' +
|
||||
'</div></li>';
|
||||
|
||||
$scope.$watch('dashboard.widgets && dashboard.widgets.length', function(widgets_length) {
|
||||
$scope.$watch('dashboard.layout', function() {
|
||||
$timeout(function() {
|
||||
gridster.remove_all_widgets();
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}, true);
|
||||
|
||||
$scope.saveDashboard = function() {
|
||||
$scope.saveInProgress = true;
|
||||
@@ -81,18 +81,15 @@
|
||||
$scope.dashboard.layout = layout;
|
||||
|
||||
layout = JSON.stringify(layout);
|
||||
$http.post('/api/dashboards/' + $scope.dashboard.id, {
|
||||
'name': $scope.dashboard.name,
|
||||
'layout': layout
|
||||
}).success(function(response) {
|
||||
$scope.dashboard = new Dashboard(response);
|
||||
Dashboard.save({slug: $scope.dashboard.id, name: $scope.dashboard.name, layout: layout}, function(dashboard) {
|
||||
$scope.dashboard = dashboard;
|
||||
$scope.saveInProgress = false;
|
||||
$(element).modal('hide');
|
||||
});
|
||||
Events.record(currentUser, 'edit', 'dashboard', $scope.dashboard.id);
|
||||
} else {
|
||||
|
||||
$http.post('/api/dashboards', {
|
||||
$http.post('api/dashboards', {
|
||||
'name': $scope.dashboard.name
|
||||
}).success(function(response) {
|
||||
$(element).modal('hide');
|
||||
@@ -223,4 +220,4 @@
|
||||
}
|
||||
}
|
||||
])
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
$http.get('/api/data_sources/types').success(function (types) {
|
||||
$http.get('api/data_sources/types').success(function (types) {
|
||||
setType(types);
|
||||
|
||||
$scope.dataSourceTypes = types;
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('rdTab', function () {
|
||||
directives.directive('rdTab', ['$location', function ($location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
@@ -48,9 +48,10 @@
|
||||
'name': '@'
|
||||
},
|
||||
transclude: true,
|
||||
template: '<li class="rd-tab" ng-class="{active: tabId==selectedTab}"><a href="#{{tabId}}">{{name}}<span ng-transclude></span></a></li>',
|
||||
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.$watch(function () {
|
||||
return scope.$parent.selectedTab
|
||||
}, function (tab) {
|
||||
@@ -58,7 +59,7 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}]);
|
||||
|
||||
directives.directive('rdTabs', ['$location', function ($location) {
|
||||
return {
|
||||
@@ -67,9 +68,10 @@
|
||||
tabsCollection: '=',
|
||||
selectedTab: '='
|
||||
},
|
||||
template: '<ul class="nav nav-tabs"><li ng-class="{active: tab==selectedTab}" ng-repeat="tab in tabsCollection"><a href="#{{tab.key}}">{{tab.name}}</a></li></ul>',
|
||||
template: '<ul class="nav nav-tabs"><li ng-class="{active: tab==selectedTab}" ng-repeat="tab in tabsCollection"><a href="{{basePath}}#{{tab.key}}">{{tab.name}}</a></li></ul>',
|
||||
replace: true,
|
||||
link: function ($scope, element, attrs) {
|
||||
$scope.basePath = $location.path().substring(1);
|
||||
$scope.selectTab = function (tabKey) {
|
||||
$scope.selectedTab = _.find($scope.tabsCollection, function (tab) {
|
||||
return tab.key == tabKey;
|
||||
@@ -295,7 +297,6 @@
|
||||
onDestroy: "&",
|
||||
},
|
||||
link: function(scope, elem, attrs) {
|
||||
console.log(scope.onDestroy);
|
||||
scope.$on('$destroy', function() {
|
||||
scope.onDestroy();
|
||||
});
|
||||
@@ -311,4 +312,19 @@
|
||||
};
|
||||
});
|
||||
|
||||
directives.directive('overlay', function() {
|
||||
return {
|
||||
restrict: "E",
|
||||
transclude: true,
|
||||
template: "" +
|
||||
'<div>' +
|
||||
'<div class="overlay"></div>' +
|
||||
'<div style="width: 100%; position:absolute; top:50px; z-index:2000">' +
|
||||
'<div class="well well-lg" style="width: 70%; margin: auto;" ng-transclude>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
}
|
||||
})
|
||||
|
||||
})();
|
||||
|
||||
251
rd_ui/app/scripts/directives/plotly.js
Normal file
251
rd_ui/app/scripts/directives/plotly.js
Normal file
@@ -0,0 +1,251 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var ColorPalette = {
|
||||
'Blue': '#4572A7',
|
||||
'Red': '#AA4643',
|
||||
'Green': '#89A54E',
|
||||
'Purple': '#80699B',
|
||||
'Cyan': '#3D96AE',
|
||||
'Orange': '#DB843D',
|
||||
'Light Blue': '#92A8CD',
|
||||
'Lilac': '#A47D7C',
|
||||
'Light Green': '#B5CA92',
|
||||
'Brown': '#A52A2A',
|
||||
'Black': '#000000',
|
||||
'Gray': '#808080',
|
||||
'Pink': '#FFC0CB',
|
||||
'Dark Blue': '#00008b'
|
||||
};
|
||||
var ColorPaletteArray = _.values(ColorPalette)
|
||||
|
||||
var fillXValues = function(seriesList) {
|
||||
var xValues = _.uniq(_.flatten(_.pluck(seriesList, 'x')));
|
||||
xValues.sort();
|
||||
_.each(seriesList, function(series) {
|
||||
series.x.sort();
|
||||
_.each(xValues, function(value, index) {
|
||||
if (series.x[index] != value) {
|
||||
series.x.splice(index, 0, value);
|
||||
series.y.splice(index, 0, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var normalAreaStacking = function(seriesList) {
|
||||
fillXValues(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 percentAreaStacking = function(seriesList) {
|
||||
if (seriesList.length == 0)
|
||||
return;
|
||||
fillXValues(seriesList);
|
||||
_.each(seriesList, function(series) {
|
||||
series.text = [];
|
||||
series.hoverinfo = 'text+name';
|
||||
});
|
||||
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';
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var normalizeValue = function(value) {
|
||||
if (moment.isMoment(value)) {
|
||||
return value.format("YYYY-MM-DD HH:MM:SS.ssssss");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
angular.module('plotly-chart', [])
|
||||
.constant('ColorPalette', ColorPalette)
|
||||
.directive('plotlyChart', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<plotly data="data" layout="layout" options="plotlyOptions"></plotly>',
|
||||
scope: {
|
||||
options: "=",
|
||||
series: "=",
|
||||
minHeight: "="
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
var getScaleType = function(scale) {
|
||||
if (scale == 'datetime')
|
||||
return 'date';
|
||||
if (scale == 'logarithmic')
|
||||
return 'log';
|
||||
return scale;
|
||||
}
|
||||
|
||||
var setType = function(series, type) {
|
||||
if (type == 'column') {
|
||||
series['type'] = 'bar';
|
||||
} else if (type == 'line') {
|
||||
series['mode'] = 'lines';
|
||||
} else if (type == 'area') {
|
||||
series['fill'] = scope.options.series.stacking == null ? 'tozeroy' : 'tonexty';
|
||||
series['mode'] = 'lines';
|
||||
} else if (type == 'scatter') {
|
||||
series['type'] = 'scatter';
|
||||
series['mode'] = 'markers';
|
||||
}
|
||||
}
|
||||
|
||||
var getColor = function(index) {
|
||||
return ColorPaletteArray[index % ColorPaletteArray.length];
|
||||
}
|
||||
|
||||
var bottomMargin = 50,
|
||||
pixelsPerLegendRow = 21;
|
||||
var redraw = function() {
|
||||
scope.data.length = 0;
|
||||
scope.layout.showlegend = _.has(scope.options, 'legend') ? scope.options.legend.enabled : true;
|
||||
delete scope.layout.barmode;
|
||||
delete scope.layout.xaxis;
|
||||
delete scope.layout.yaxis;
|
||||
delete scope.layout.yaxis2;
|
||||
|
||||
if (scope.options.globalSeriesType == 'pie') {
|
||||
var hasX = _.contains(_.values(scope.options.columnMapping), 'x');
|
||||
var rows = scope.series.length > 2 ? 2 : 1;
|
||||
var cellsInRow = Math.ceil(scope.series.length / rows)
|
||||
var cellWidth = 1 / cellsInRow;
|
||||
var cellHeight = 1 / rows;
|
||||
var xPadding = 0.02;
|
||||
var yPadding = 0.05;
|
||||
var largestXCount = 0;
|
||||
_.each(scope.series, function(series, index) {
|
||||
var xPosition = (index % cellsInRow) * cellWidth;
|
||||
var yPosition = Math.floor(index / cellsInRow) * cellHeight;
|
||||
var plotlySeries = {values: [], labels: [], type: 'pie', hole: .4,
|
||||
marker: {colors: ColorPaletteArray},
|
||||
text: series.name, textposition: 'inside', name: series.name,
|
||||
domain: {x: [xPosition, xPosition + cellWidth - xPadding],
|
||||
y: [yPosition, yPosition + cellHeight - yPadding]}};
|
||||
_.each(series.data, function(row, index) {
|
||||
plotlySeries.values.push(row.y);
|
||||
plotlySeries.labels.push(hasX ? row.x : 'Slice ' + index);
|
||||
});
|
||||
scope.data.push(plotlySeries);
|
||||
largestXCount = Math.max(largestXCount, plotlySeries.labels.length);
|
||||
});
|
||||
scope.layout.height = Math.max(scope.minHeight, pixelsPerLegendRow * largestXCount);
|
||||
scope.layout.margin.b = scope.layout.height - (scope.minHeight - bottomMargin);
|
||||
return;
|
||||
}
|
||||
scope.layout.height = Math.max(scope.minHeight, pixelsPerLegendRow * scope.series.length);
|
||||
scope.layout.margin.b = scope.layout.height - (scope.minHeight - bottomMargin);
|
||||
var hasY2 = false;
|
||||
_.each(scope.series, function(series, index) {
|
||||
var seriesOptions = scope.options.seriesOptions[series.name] || {};
|
||||
var plotlySeries = {x: [],
|
||||
y: [],
|
||||
name: seriesOptions.name || series.name,
|
||||
marker: {color: seriesOptions.color ? seriesOptions.color : getColor(index)}};
|
||||
if (seriesOptions.yAxis == 1 && (scope.options.series.stacking == null || seriesOptions.type == 'line')) {
|
||||
hasY2 = true;
|
||||
plotlySeries.yaxis = 'y2';
|
||||
}
|
||||
setType(plotlySeries, seriesOptions.type);
|
||||
var data = series.data;
|
||||
if (scope.options.sortX) {
|
||||
data = _.sortBy(data, 'x');
|
||||
}
|
||||
_.each(data, function(row) {
|
||||
plotlySeries.x.push(normalizeValue(row.x));
|
||||
plotlySeries.y.push(normalizeValue(row.y));
|
||||
});
|
||||
scope.data.push(plotlySeries)
|
||||
});
|
||||
|
||||
var getTitle = function(axis) {
|
||||
if (angular.isDefined(axis) && angular.isDefined(axis.title)) {
|
||||
return axis.title.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
scope.layout.xaxis = {title: getTitle(scope.options.xAxis),
|
||||
type: getScaleType(scope.options.xAxis.type)};
|
||||
if (angular.isDefined(scope.options.xAxis.labels)) {
|
||||
scope.layout.xaxis.showticklabels = scope.options.xAxis.labels.enabled;
|
||||
}
|
||||
if (angular.isArray(scope.options.yAxis)) {
|
||||
scope.layout.yaxis = {title: getTitle(scope.options.yAxis[0]),
|
||||
type: getScaleType(scope.options.yAxis[0].type)};
|
||||
}
|
||||
if (hasY2 && angular.isDefined(scope.options.yAxis)) {
|
||||
scope.layout.yaxis2 = {title: getTitle(scope.options.yAxis[1]),
|
||||
type: getScaleType(scope.options.yAxis[1].type),
|
||||
overlaying: 'y',
|
||||
side: 'right'};
|
||||
} else {
|
||||
delete scope.layout.yaxis2;
|
||||
}
|
||||
if (scope.options.series.stacking == 'normal') {
|
||||
scope.layout.barmode = 'stack';
|
||||
if (scope.options.globalSeriesType == 'area') {
|
||||
normalAreaStacking(scope.data);
|
||||
}
|
||||
} else if (scope.options.series.stacking == 'percent') {
|
||||
scope.layout.barmode = 'stack';
|
||||
if (scope.options.globalSeriesType == 'area') {
|
||||
percentAreaStacking(scope.data);
|
||||
} else if (scope.options.globalSeriesType == 'column') {
|
||||
percentBarStacking(scope.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.$watch('series', redraw);
|
||||
scope.$watch('options', redraw, true);
|
||||
scope.layout = {margin: {l: 50, r: 50, b: 50, t: 20, pad: 4}, hovermode: 'closest'};
|
||||
scope.plotlyOptions = {showLink: false, displaylogo: false};
|
||||
scope.data = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
template: '<small><span class="glyphicon glyphicon-link"></span></small> <a ng-href="{{link}}" class="query-link">{{query.name}}</a>',
|
||||
link: function(scope, element) {
|
||||
scope.link = '/queries/' + scope.query.id;
|
||||
scope.link = 'queries/' + scope.query.id;
|
||||
if (scope.visualization) {
|
||||
if (scope.visualization.type === 'TABLE') {
|
||||
// link to hard-coded table tab instead of the (hidden) visualization tab
|
||||
@@ -29,10 +29,10 @@
|
||||
restrict: 'E',
|
||||
template: '<span ng-show="query.id && canViewSource">\
|
||||
<a ng-show="!sourceMode"\
|
||||
ng-href="/queries/{{query.id}}/source#{{selectedTab}}">Show Source\
|
||||
ng-href="queries/{{query.id}}/source#{{selectedTab}}">Show Source\
|
||||
</a>\
|
||||
<a ng-show="sourceMode"\
|
||||
ng-href="/queries/{{query.id}}#{{selectedTab}}">Hide Source\
|
||||
ng-href="queries/{{query.id}}#{{selectedTab}}">Hide Source\
|
||||
</a>\
|
||||
</span>'
|
||||
}
|
||||
@@ -50,8 +50,8 @@
|
||||
if (scope.queryResult.getId() == null) {
|
||||
element.attr('href', '');
|
||||
} else {
|
||||
element.attr('href', '/api/queries/' + scope.query.id + '/results/' + scope.queryResult.getId() + '.csv');
|
||||
element.attr('download', scope.query.name.replace(" ", "_") + moment(scope.queryResult.getUpdatedAt()).format("_YYYY_MM_DD") + ".csv");
|
||||
element.attr('href', 'api/queries/' + scope.query.id + '/results/' + scope.queryResult.getId() + '.csv');
|
||||
element.attr('download', scope.query.name.replace(" ", "_") + moment(scope.queryResult.getUpdatedAt()).format("_YYYY_MM_DD") + ".csv");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -265,6 +265,10 @@
|
||||
value: String(7 * 24 * 3600),
|
||||
name: 'Once a week'
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(30 * 24 * 3600),
|
||||
name: 'Every 30d'
|
||||
});
|
||||
|
||||
$scope.$watch('refreshType', function() {
|
||||
if ($scope.refreshType == 'periodic') {
|
||||
@@ -287,4 +291,4 @@
|
||||
.directive('queryRefreshSelect', queryRefreshSelect)
|
||||
.directive('queryTimePicker', queryTimePicker)
|
||||
.directive('queryFormatter', ['$http', queryFormatter]);
|
||||
})();
|
||||
})();
|
||||
|
||||
56
rd_ui/app/scripts/embed.js
Normal file
56
rd_ui/app/scripts/embed.js
Normal file
@@ -0,0 +1,56 @@
|
||||
angular.module('redash', [
|
||||
'redash.directives',
|
||||
'redash.admin_controllers',
|
||||
'redash.controllers',
|
||||
'redash.filters',
|
||||
'redash.services',
|
||||
'redash.renderers',
|
||||
'redash.visualization',
|
||||
'plotly',
|
||||
'plotly-chart',
|
||||
'angular-growl',
|
||||
'angularMoment',
|
||||
'ui.bootstrap',
|
||||
'ui.sortable',
|
||||
'smartTable.table',
|
||||
'ngResource',
|
||||
'ngRoute',
|
||||
'ui.select',
|
||||
'naif.base64',
|
||||
'ui.bootstrap.showErrors',
|
||||
'ngSanitize'
|
||||
]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider', 'uiSelectConfig',
|
||||
function ($routeProvider, $locationProvider, $compileProvider, growlProvider, uiSelectConfig) {
|
||||
function getQuery(Query, $route) {
|
||||
var query = Query.get({'id': $route.current.params.queryId });
|
||||
return query.$promise;
|
||||
};
|
||||
|
||||
uiSelectConfig.theme = "bootstrap";
|
||||
|
||||
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|http|data):/);
|
||||
$locationProvider.html5Mode(true);
|
||||
growlProvider.globalTimeToLive(2000);
|
||||
|
||||
$routeProvider.when('/embed/query/:queryId/visualization/:visualizationId', {
|
||||
templateUrl: '/views/visualization-embed.html',
|
||||
controller: 'EmbedCtrl',
|
||||
reloadOnSearch: false
|
||||
});
|
||||
$routeProvider.otherwise({
|
||||
redirectTo: '/embed'
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
])
|
||||
.controller('EmbedCtrl', ['$scope', function ($scope) {} ])
|
||||
.controller('EmbeddedVisualizationCtrl', ['$scope', 'Query', 'QueryResult',
|
||||
function ($scope, Query, QueryResult) {
|
||||
$scope.embed = true;
|
||||
$scope.visualization = visualization;
|
||||
$scope.query = visualization.query;
|
||||
query = new Query(visualization.query);
|
||||
$scope.queryResult = new QueryResult({query_result:query_result});
|
||||
} ])
|
||||
;
|
||||
@@ -1,409 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var ColorPalette = {
|
||||
'Blue':'#4572A7',
|
||||
'Red':'#AA4643',
|
||||
'Green': '#89A54E',
|
||||
'Purple': '#80699B',
|
||||
'Cyan': '#3D96AE',
|
||||
'Orange': '#DB843D',
|
||||
'Light Blue': '#92A8CD',
|
||||
'Lilac': '#A47D7C',
|
||||
'Light Green': '#B5CA92',
|
||||
'Brown':'#A52A2A',
|
||||
'Black':'#000000',
|
||||
'Gray':'#808080',
|
||||
'Pink':'#FFC0CB',
|
||||
'Dark Blue':'#00008b'
|
||||
};
|
||||
|
||||
Highcharts.setOptions({
|
||||
colors: _.values(ColorPalette)
|
||||
});
|
||||
|
||||
var defaultOptions = {
|
||||
title: {
|
||||
"text": null
|
||||
},
|
||||
xAxis: {
|
||||
type: 'datetime'
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
title: {
|
||||
text: null
|
||||
},
|
||||
// showEmpty: true // by default
|
||||
},
|
||||
{
|
||||
title: {
|
||||
text: null
|
||||
},
|
||||
opposite: true,
|
||||
showEmpty: false
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
tooltip: {
|
||||
valueDecimals: 2,
|
||||
formatter: function () {
|
||||
if (!this.points) {
|
||||
this.points = [this.point];
|
||||
}
|
||||
;
|
||||
|
||||
if (moment.isMoment(this.x)) {
|
||||
var s = '<b>' + this.x.format(clientConfig.dateTimeFormat) + '</b>',
|
||||
pointsCount = this.points.length;
|
||||
|
||||
$.each(this.points, function (i, point) {
|
||||
s += '<br/><span style="color:' + point.series.color + '">' + point.series.name + '</span>: ' +
|
||||
Highcharts.numberFormat(point.y);
|
||||
|
||||
if (pointsCount > 1 && point.percentage) {
|
||||
s += " (" + Highcharts.numberFormat(point.percentage) + "%)";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var points = this.points;
|
||||
var name = points[0].key || points[0].name;
|
||||
|
||||
var s = "<b>" + name + "</b>";
|
||||
|
||||
$.each(points, function (i, point) {
|
||||
if (points.length > 1) {
|
||||
s += '<br/><span style="color:' + point.series.color + '">' + point.series.name + '</span>: ' + Highcharts.numberFormat(point.y);
|
||||
} else {
|
||||
s += ": " + Highcharts.numberFormat(point.y);
|
||||
if (point.percentage < 100) {
|
||||
s += ' (' + Highcharts.numberFormat(point.percentage) + '%)';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return s;
|
||||
},
|
||||
shared: true
|
||||
},
|
||||
exporting: {
|
||||
chartOptions: {
|
||||
title: {
|
||||
text: ''
|
||||
}
|
||||
},
|
||||
buttons: {
|
||||
contextButton: {
|
||||
menuItems: [
|
||||
{
|
||||
text: 'Select All',
|
||||
onclick: function () {
|
||||
_.each(this.series, function (s) {
|
||||
s.setVisible(true, false);
|
||||
});
|
||||
this.redraw();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Unselect All',
|
||||
onclick: function () {
|
||||
_.each(this.series, function (s) {
|
||||
s.setVisible(false, false);
|
||||
});
|
||||
this.redraw();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Show Total',
|
||||
onclick: function () {
|
||||
var hasTotalsAlready = _.some(this.series, function (s) {
|
||||
var res = (s.name == 'Total');
|
||||
//if 'Total' already exists - just make it visible
|
||||
if (res) s.setVisible(true, false);
|
||||
return res;
|
||||
})
|
||||
var data = {};
|
||||
_.each(this.series, function (s) {
|
||||
if (s.name != 'Total') s.setVisible(false, false);
|
||||
if (!hasTotalsAlready) {
|
||||
_.each(s.data, function (p) {
|
||||
data[p.x] = data[p.x] || {'x': p.x, 'y': 0};
|
||||
data[p.x].y = data[p.x].y + p.y;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasTotalsAlready) {
|
||||
this.addSeries({
|
||||
data: _.sortBy(_.values(data), 'x'),
|
||||
type: 'line',
|
||||
name: 'Total'
|
||||
}, false)
|
||||
}
|
||||
|
||||
this.redraw();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Save Image',
|
||||
onclick: function () {
|
||||
var canvas = document.createElement('canvas');
|
||||
window.canvg(canvas, this.getSVG());
|
||||
var href = canvas.toDataURL('image/png');
|
||||
var a = document.createElement('a');
|
||||
a.href = href;
|
||||
var filenameSuffix = new Date().toISOString().replace(/:/g,'_').replace('Z', '');
|
||||
if (this.title) {
|
||||
filenameSuffix = this.title.text;
|
||||
}
|
||||
a.download = 'redash_charts_'+filenameSuffix+'.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
credits: {
|
||||
enabled: false
|
||||
},
|
||||
plotOptions: {
|
||||
area: {
|
||||
marker: {
|
||||
enabled: false,
|
||||
symbol: 'circle',
|
||||
radius: 2,
|
||||
states: {
|
||||
hover: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
column: {
|
||||
stacking: "normal",
|
||||
pointPadding: 0,
|
||||
borderWidth: 1,
|
||||
groupPadding: 0,
|
||||
shadow: false
|
||||
},
|
||||
line: {
|
||||
marker: {
|
||||
radius: 1
|
||||
},
|
||||
lineWidth: 2,
|
||||
states: {
|
||||
hover: {
|
||||
lineWidth: 2,
|
||||
marker: {
|
||||
radius: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
pie: {
|
||||
allowPointSelect: true,
|
||||
cursor: 'pointer',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
color: '#000000',
|
||||
connectorColor: '#000000',
|
||||
format: '<b>{point.name}</b>: {point.y} ({point.percentage:.1f} %)'
|
||||
}
|
||||
},
|
||||
scatter: {
|
||||
marker: {
|
||||
radius: 5,
|
||||
states: {
|
||||
hover: {
|
||||
enabled: true,
|
||||
lineColor: 'rgb(100,100,100)'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
headerFormat: '<b>{series.name}</b><br>',
|
||||
pointFormat: '{point.x}, {point.y}'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: []
|
||||
};
|
||||
|
||||
angular.module('highchart', [])
|
||||
.constant('ColorPalette', ColorPalette)
|
||||
.directive('chart', ['$timeout', function ($timeout) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<div></div>',
|
||||
scope: {
|
||||
options: "=options",
|
||||
series: "=series"
|
||||
},
|
||||
transclude: true,
|
||||
replace: true,
|
||||
|
||||
link: function (scope, element, attrs) {
|
||||
var chartsDefaults = {
|
||||
chart: {
|
||||
renderTo: element[0],
|
||||
type: attrs.type || null,
|
||||
height: attrs.height || null,
|
||||
width: attrs.width || null
|
||||
}
|
||||
};
|
||||
|
||||
var chartOptions = $.extend(true, {}, defaultOptions, chartsDefaults);
|
||||
chartOptions.plotOptions.series = {
|
||||
turboThreshold: clientConfig.highChartsTurboThreshold
|
||||
}
|
||||
|
||||
// $timeout makes sure that this function invoked after the DOM ready. When draw/init
|
||||
// invoked after the DOM is ready, we see first an empty HighCharts objects and later
|
||||
// they get filled up. Which gives the feeling that the charts loading faster (otherwise
|
||||
// we stare at an empty screen until the HighCharts object is ready).
|
||||
$timeout(function () {
|
||||
// Update when options change
|
||||
scope.$watch('options', function (newOptions) {
|
||||
initChart(newOptions);
|
||||
}, true);
|
||||
|
||||
//Update when charts data changes
|
||||
scope.$watchCollection('series', function (series) {
|
||||
if (!series || series.length == 0) {
|
||||
scope.chart.showLoading();
|
||||
} else {
|
||||
drawChart();
|
||||
}
|
||||
;
|
||||
});
|
||||
});
|
||||
|
||||
function initChart(options) {
|
||||
if (scope.chart) {
|
||||
scope.chart.destroy();
|
||||
}
|
||||
;
|
||||
|
||||
$.extend(true, chartOptions, options);
|
||||
|
||||
scope.chart = new Highcharts.Chart(chartOptions);
|
||||
drawChart();
|
||||
}
|
||||
|
||||
function drawChart() {
|
||||
while (scope.chart.series.length > 0) {
|
||||
scope.chart.series[0].remove(false);
|
||||
};
|
||||
|
||||
// We check either for true or undefined for backward compatibility.
|
||||
var series = scope.series;
|
||||
|
||||
|
||||
// If this is a chart that has just one row for multiple columns, sort
|
||||
// by the Y values. For example:
|
||||
//
|
||||
// A | B | C
|
||||
// 20 | 30 | 15
|
||||
//
|
||||
// Will be sorted:
|
||||
// C | A | B
|
||||
// 15 | 20 | 30
|
||||
var sortable = _.every(series, function(s) { return s.data.length == 1 });
|
||||
|
||||
if (sortable) {
|
||||
series = _.sortBy(series, function (s) {
|
||||
return s.data[0].y
|
||||
});
|
||||
}
|
||||
|
||||
if (!('xAxis' in chartOptions && 'type' in chartOptions['xAxis'])) {
|
||||
if (series.length > 0 && _.some(series[0].data, function (p) {
|
||||
return (angular.isString(p.x) || angular.isDefined(p.name));
|
||||
})) {
|
||||
chartOptions['xAxis'] = chartOptions['xAxis'] || {};
|
||||
chartOptions['xAxis']['type'] = 'category';
|
||||
} else {
|
||||
chartOptions['xAxis'] = chartOptions['xAxis'] || {};
|
||||
chartOptions['xAxis']['type'] = 'datetime';
|
||||
}
|
||||
}
|
||||
|
||||
if (chartOptions['xAxis']['type'] == 'category' || chartOptions['series']['type']=='pie') {
|
||||
if (!angular.isDefined(series[0].data[0].name)) {
|
||||
// We need to make sure that for each category, each series has a value.
|
||||
var categories = _.union.apply(this, _.map(series, function (s) {
|
||||
return _.pluck(s.data, 'x')
|
||||
}));
|
||||
|
||||
_.each(series, function (s) {
|
||||
// TODO: move this logic to Query#getChartData
|
||||
var yValues = _.groupBy(s.data, 'x');
|
||||
|
||||
var newData = _.map(categories, function (category) {
|
||||
return {
|
||||
name: category,
|
||||
y: (yValues[category] && yValues[category][0].y) || 0
|
||||
}
|
||||
});
|
||||
|
||||
s.data = newData;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (chartOptions['sortX'] === true || chartOptions['sortX'] === undefined) {
|
||||
var seriesCopy = [];
|
||||
|
||||
_.each(series, function (s) {
|
||||
// make a copy of series data, so we don't override original.
|
||||
var fieldName = 'x';
|
||||
if (s.data.length > 0 && _.has(s.data[0], 'name')) {
|
||||
fieldName = 'name';
|
||||
};
|
||||
|
||||
var sorted = _.extend({}, s, {data: _.sortBy(s.data, fieldName)});
|
||||
seriesCopy.push(sorted);
|
||||
});
|
||||
|
||||
series = seriesCopy;
|
||||
}
|
||||
|
||||
scope.chart.counters.color = 0;
|
||||
|
||||
_.each(series, function (s) {
|
||||
// here we override the series with the visualization config
|
||||
s = _.extend(s, chartOptions['series']);
|
||||
|
||||
if (s.type == 'area') {
|
||||
_.each(s.data, function (p) {
|
||||
// This is an insane hack: somewhere deep in HighChart's code,
|
||||
// when you stack areas, it tries to convert the string representation
|
||||
// of point's x into a number. With the default implementation of toString
|
||||
// it fails....
|
||||
|
||||
if (moment.isMoment(p.x)) {
|
||||
p.x.toString = function () {
|
||||
return String(this.toDate().getTime());
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
;
|
||||
|
||||
scope.chart.addSeries(s, false);
|
||||
});
|
||||
|
||||
scope.chart.redraw();
|
||||
scope.chart.hideLoading();
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
}]);
|
||||
})();
|
||||
@@ -1,10 +1,32 @@
|
||||
(function () {
|
||||
var Dashboard = function($resource) {
|
||||
var resource = $resource('/api/dashboards/:slug', {slug: '@slug'}, {
|
||||
var Dashboard = function($resource, $http, Widget) {
|
||||
var transformSingle = function(dashboard) {
|
||||
dashboard.widgets = _.map(dashboard.widgets, function (row) {
|
||||
return _.map(row, function (widget) {
|
||||
return new Widget(widget);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var transform = $http.defaults.transformResponse.concat(function(data, headers) {
|
||||
if (_.isArray(data)) {
|
||||
_.each(data, transformSingle);
|
||||
} else {
|
||||
transformSingle(data);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
var resource = $resource('api/dashboards/:slug', {slug: '@slug'}, {
|
||||
'get': {method: 'GET', transformResponse: transform},
|
||||
'save': {method: 'POST', transformResponse: transform},
|
||||
'query': {method: 'GET', isArray: true, transformResponse: transform},
|
||||
recent: {
|
||||
method: 'get',
|
||||
isArray: true,
|
||||
url: "/api/dashboards/recent"
|
||||
url: "api/dashboards/recent",
|
||||
transformResponse: transform
|
||||
|
||||
}});
|
||||
|
||||
resource.prototype.canEdit = function() {
|
||||
@@ -14,5 +36,5 @@
|
||||
}
|
||||
|
||||
angular.module('redash.services')
|
||||
.factory('Dashboard', ['$resource', Dashboard])
|
||||
.factory('Dashboard', ['$resource', '$http', 'Widget', Dashboard])
|
||||
})();
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
};
|
||||
|
||||
var QueryResult = function ($resource, $timeout, $q) {
|
||||
var QueryResultResource = $resource('/api/query_results/:id', {id: '@id'}, {'post': {'method': 'POST'}});
|
||||
var Job = $resource('/api/jobs/:id', {id: '@id'});
|
||||
var QueryResultResource = $resource('api/query_results/:id', {id: '@id'}, {'post': {'method': 'POST'}});
|
||||
var Job = $resource('api/jobs/:id', {id: '@id'});
|
||||
|
||||
var updateFunction = function (props) {
|
||||
angular.extend(this, props);
|
||||
@@ -188,20 +188,6 @@
|
||||
return this.filteredData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to add a point into a series, also checks whether the point is within dateRange
|
||||
*/
|
||||
QueryResult.prototype._addPointToSeriesIfInDateRange = function (point, seriesCollection, seriesName, dateRange) {
|
||||
if (dateRange && moment.isMoment(point.x)) {
|
||||
// if dateRange is provided and x Axis is of type datetime
|
||||
if (point.x.isBefore(dateRange.min) || point.x.isAfter(dateRange.max)) {
|
||||
// if the point's date isn't within dateRange, then we will not add this point to series
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._addPointToSeries(point, seriesCollection, seriesName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to add a point into a series
|
||||
*/
|
||||
@@ -217,7 +203,7 @@
|
||||
seriesCollection[seriesName]['data'].push(point);
|
||||
};
|
||||
|
||||
QueryResult.prototype.getChartData = function (mapping, dateRange) {
|
||||
QueryResult.prototype.getChartData = function (mapping) {
|
||||
var series = {};
|
||||
|
||||
_.each(this.getData(), function (row) {
|
||||
@@ -260,11 +246,11 @@
|
||||
|
||||
if (seriesName === undefined) {
|
||||
_.each(yValues, function (yValue, seriesName) {
|
||||
this._addPointToSeriesIfInDateRange({'x': xValue, 'y': yValue}, series, seriesName, dateRange);
|
||||
this._addPointToSeries({'x': xValue, 'y': yValue}, series, seriesName);
|
||||
}.bind(this));
|
||||
}
|
||||
else {
|
||||
this._addPointToSeriesIfInDateRange(point, series, seriesName, dateRange);
|
||||
this._addPointToSeries(point, series, seriesName);
|
||||
}
|
||||
}.bind(this));
|
||||
|
||||
@@ -415,6 +401,10 @@
|
||||
if ('job' in response) {
|
||||
refreshStatus(queryResult, query);
|
||||
}
|
||||
}, function(error) {
|
||||
if (error.status === 403) {
|
||||
queryResult.update(error.data);
|
||||
}
|
||||
});
|
||||
|
||||
return queryResult;
|
||||
@@ -424,17 +414,17 @@
|
||||
};
|
||||
|
||||
var Query = function ($resource, QueryResult, DataSource) {
|
||||
var Query = $resource('/api/queries/:id', {id: '@id'},
|
||||
var Query = $resource('api/queries/:id', {id: '@id'},
|
||||
{
|
||||
search: {
|
||||
method: 'get',
|
||||
isArray: true,
|
||||
url: "/api/queries/search"
|
||||
url: "api/queries/search"
|
||||
},
|
||||
recent: {
|
||||
method: 'get',
|
||||
isArray: true,
|
||||
url: "/api/queries/recent"
|
||||
url: "api/queries/recent"
|
||||
}});
|
||||
|
||||
Query.newQuery = function () {
|
||||
@@ -556,10 +546,10 @@
|
||||
var actions = {
|
||||
'get': {'method': 'GET', 'cache': false, 'isArray': false},
|
||||
'query': {'method': 'GET', 'cache': false, 'isArray': true},
|
||||
'getSchema': {'method': 'GET', 'cache': true, 'isArray': true, 'url': '/api/data_sources/:id/schema'}
|
||||
'getSchema': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/data_sources/:id/schema'}
|
||||
};
|
||||
|
||||
var DataSourceResource = $resource('/api/data_sources/:id', {id: '@id'}, actions);
|
||||
var DataSourceResource = $resource('api/data_sources/:id', {id: '@id'}, actions);
|
||||
|
||||
return DataSourceResource;
|
||||
};
|
||||
@@ -587,13 +577,24 @@
|
||||
'delete': {method: 'DELETE', transformResponse: transform}
|
||||
};
|
||||
|
||||
var UserResource = $resource('/api/users/:id', {id: '@id'}, actions);
|
||||
var UserResource = $resource('api/users/:id', {id: '@id'}, actions);
|
||||
|
||||
return UserResource;
|
||||
};
|
||||
|
||||
var Group = function ($resource) {
|
||||
var actions = {
|
||||
'get': {'method': 'GET', 'cache': false, 'isArray': false},
|
||||
'query': {'method': 'GET', 'cache': false, 'isArray': true},
|
||||
'members': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/groups/:id/members'},
|
||||
'dataSources': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/groups/:id/data_sources'}
|
||||
};
|
||||
var resource = $resource('api/groups/:id', {id: '@id'}, actions);
|
||||
return resource;
|
||||
};
|
||||
|
||||
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/:userId', {alertId: '@alert_id', userId: '@user.id'});
|
||||
return resource;
|
||||
};
|
||||
|
||||
@@ -612,13 +613,13 @@
|
||||
}].concat($http.defaults.transformRequest)
|
||||
}
|
||||
};
|
||||
var resource = $resource('/api/alerts/:id', {id: '@id'}, actions);
|
||||
var resource = $resource('api/alerts/:id', {id: '@id'}, actions);
|
||||
|
||||
return resource;
|
||||
};
|
||||
|
||||
var Widget = function ($resource, Query) {
|
||||
var WidgetResource = $resource('/api/widgets/:id', {id: '@id'});
|
||||
var WidgetResource = $resource('api/widgets/:id', {id: '@id'});
|
||||
|
||||
WidgetResource.prototype.getQuery = function () {
|
||||
if (!this.query && this.visualization) {
|
||||
@@ -645,5 +646,6 @@
|
||||
.factory('Alert', ['$resource', '$http', Alert])
|
||||
.factory('AlertSubscription', ['$resource', AlertSubscription])
|
||||
.factory('Widget', ['$resource', 'Query', Widget])
|
||||
.factory('User', ['$resource', '$http', User]);
|
||||
.factory('User', ['$resource', '$http', User])
|
||||
.factory('Group', ['$resource', Group]);
|
||||
})();
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
var events = this.events;
|
||||
this.events = [];
|
||||
|
||||
$http.post('/api/events', events);
|
||||
$http.post('api/events', events);
|
||||
|
||||
}, 1000);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
this.$get = ['$resource', function ($resource) {
|
||||
var Visualization = $resource('/api/visualizations/:id', {id: '@id'});
|
||||
var Visualization = $resource('api/visualizations/:id', {id: '@id'});
|
||||
Visualization.visualizations = this.visualizations;
|
||||
Visualization.visualizationTypes = this.visualizationTypes;
|
||||
Visualization.renderVisualizationsTemplate = this.getSwitchTemplate('renderTemplate');
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
var defaultOptions = {
|
||||
globalSeriesType: 'column',
|
||||
sortX: true,
|
||||
legend: {enabled: true},
|
||||
yAxis: [{type: 'linear'}, {type: 'linear', opposite: true}],
|
||||
xAxis: {type: 'datetime', labels: {enabled: true}},
|
||||
series: {stacking: null},
|
||||
@@ -35,96 +36,25 @@
|
||||
replace: false,
|
||||
controller: ['$scope', function ($scope) {
|
||||
$scope.chartSeries = [];
|
||||
$scope.chartOptions = {};
|
||||
$scope.dateRangeEnabled = function() {
|
||||
return $scope.options.xAxis && $scope.options.xAxis.type === 'datetime';
|
||||
|
||||
var reloadChart = function() {
|
||||
reloadData();
|
||||
$scope.plotlyOptions = $scope.options;
|
||||
}
|
||||
$scope.dateRange = { min: moment('1970-01-01'), max: moment() };
|
||||
|
||||
/**
|
||||
* Update date range by finding date extremes
|
||||
*
|
||||
* ISSUE: chart.getExtreme() does not support getting Moment object out of box
|
||||
* TODO: Find a faster way to do this
|
||||
*/
|
||||
var setDateRangeToExtreme = function (allSeries) {
|
||||
if ($scope.dateRangeEnabled() && allSeries && allSeries.length > 0) {
|
||||
$scope.dateRange = {
|
||||
min: moment.min.apply(undefined, _.map(allSeries, function (series) {
|
||||
return moment.min(_.pluck(series.data, 'x'));
|
||||
})),
|
||||
max: moment.max.apply(undefined, _.map(allSeries, function (series) {
|
||||
return moment.max(_.pluck(series.data, 'x'));
|
||||
}))
|
||||
};
|
||||
var reloadData = function() {
|
||||
if (angular.isDefined($scope.queryResult)) {
|
||||
$scope.chartSeries = _.sortBy($scope.queryResult.getChartData($scope.options.columnMapping),
|
||||
function(series) {
|
||||
if ($scope.options.seriesOptions[series.name])
|
||||
return $scope.options.seriesOptions[series.name].zIndex;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var reloadData = function(data, options) {
|
||||
options = options || {};
|
||||
if (!data || ($scope.queryResult && $scope.queryResult.getData()) == null) {
|
||||
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
||||
} else {
|
||||
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
||||
var allSeries = $scope.queryResult.getChartData($scope.options.columnMapping);
|
||||
if (!options.preventSetExtreme) {
|
||||
setDateRangeToExtreme(allSeries);
|
||||
}
|
||||
var allSeries = $scope.queryResult.getChartData(
|
||||
$scope.options.columnMapping,
|
||||
$scope.dateRangeEnabled() ? $scope.dateRange : null
|
||||
);
|
||||
|
||||
_.each(allSeries, function (series) {
|
||||
var additional = {'stacking': 'normal'};
|
||||
if ('globalSeriesType' in $scope.options) {
|
||||
additional['type'] = $scope.options.globalSeriesType;
|
||||
}
|
||||
if ($scope.options.seriesOptions && $scope.options.seriesOptions[series.name]) {
|
||||
additional = $scope.options.seriesOptions[series.name];
|
||||
if (!additional.name || additional.name == "") {
|
||||
additional.name = series.name;
|
||||
}
|
||||
}
|
||||
$scope.chartSeries.push(_.extend(series, additional));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
$scope.$watch('options', function (chartOptions) {
|
||||
if (chartOptions) {
|
||||
$scope.chartOptions = chartOptions;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('options.seriesOptions', function () {
|
||||
reloadData(true);
|
||||
}, true);
|
||||
|
||||
|
||||
$scope.$watchCollection('options.columnMapping', function (chartOptions) {
|
||||
reloadData(true);
|
||||
});
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||
reloadData(data);
|
||||
});
|
||||
|
||||
$scope.$watch('dateRange.min', function(minDateRange, oldMinDateRange) {
|
||||
if (!minDateRange.isSame(oldMinDateRange)) {
|
||||
reloadData(true, {
|
||||
preventSetExtreme: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('dateRange.max', function (maxDateRange, oldMaxDateRange) {
|
||||
if (!maxDateRange.isSame(oldMaxDateRange)) {
|
||||
reloadData(true, {
|
||||
preventSetExtreme: true
|
||||
});
|
||||
}
|
||||
});
|
||||
$scope.$watch('options', reloadChart, true)
|
||||
$scope.$watch('queryResult && queryResult.getData()', reloadData)
|
||||
}]
|
||||
};
|
||||
});
|
||||
@@ -251,6 +181,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (!_.has(scope.options, 'legend')) {
|
||||
scope.options.legend = {enabled: true};
|
||||
}
|
||||
|
||||
if (scope.columnNames)
|
||||
_.each(scope.options.columnMapping, function(value, key) {
|
||||
if (scope.columnNames.length > 0 && !_.contains(scope.columnNames, key))
|
||||
|
||||
@@ -31,31 +31,33 @@
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/counter.html',
|
||||
link: function($scope, elm, attrs) {
|
||||
$scope.$watch('[queryResult && queryResult.getData(), visualization.options]',
|
||||
function() {
|
||||
var queryData = $scope.queryResult.getData();
|
||||
if (queryData) {
|
||||
var rowNumber = $scope.visualization.options.rowNumber - 1;
|
||||
var targetRowNumber = $scope.visualization.options.targetRowNumber - 1;
|
||||
var counterColName = $scope.visualization.options.counterColName;
|
||||
var targetColName = $scope.visualization.options.targetColName;
|
||||
var refreshData = function() {
|
||||
var queryData = $scope.queryResult.getData();
|
||||
if (queryData) {
|
||||
var rowNumber = $scope.visualization.options.rowNumber - 1;
|
||||
var targetRowNumber = $scope.visualization.options.targetRowNumber - 1;
|
||||
var counterColName = $scope.visualization.options.counterColName;
|
||||
var targetColName = $scope.visualization.options.targetColName;
|
||||
|
||||
if (counterColName) {
|
||||
$scope.counterValue = queryData[rowNumber][counterColName];
|
||||
}
|
||||
|
||||
if (targetColName) {
|
||||
$scope.targetValue = queryData[targetRowNumber][targetColName];
|
||||
|
||||
if ($scope.targetValue) {
|
||||
$scope.delta = $scope.counterValue - $scope.targetValue;
|
||||
$scope.trendPositive = $scope.delta >= 0;
|
||||
}
|
||||
} else {
|
||||
$scope.targetValue = null;
|
||||
}
|
||||
if (counterColName) {
|
||||
$scope.counterValue = queryData[rowNumber][counterColName];
|
||||
}
|
||||
}, true);
|
||||
|
||||
if (targetColName) {
|
||||
$scope.targetValue = queryData[targetRowNumber][targetColName];
|
||||
|
||||
if ($scope.targetValue) {
|
||||
$scope.delta = $scope.counterValue - $scope.targetValue;
|
||||
$scope.trendPositive = $scope.delta >= 0;
|
||||
}
|
||||
} else {
|
||||
$scope.targetValue = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch("visualization.options", refreshData, true);
|
||||
$scope.$watch("queryResult && queryResult.getData()", refreshData);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,10 +26,6 @@ a.navbar-brand img {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.graph {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
@@ -483,3 +479,36 @@ div.table-name {
|
||||
margin-bottom: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Embed Code */
|
||||
.embed-code code {
|
||||
display: block;
|
||||
white-space: normal;
|
||||
width: 100%;
|
||||
}
|
||||
.embed-code i {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.voffset { margin-top: 2px; }
|
||||
.voffset1 { margin-top: 5px; }
|
||||
.voffset2 { margin-top: 10px; }
|
||||
.voffset3 { margin-top: 15px; }
|
||||
.voffset4 { margin-top: 30px; }
|
||||
.voffset5 { margin-top: 40px; }
|
||||
.voffset6 { margin-top: 60px; }
|
||||
.voffset7 { margin-top: 80px; }
|
||||
.voffset8 { margin-top: 100px; }
|
||||
.voffset9 { margin-top: 150px; }
|
||||
|
||||
.overlay {
|
||||
background-color: #808080;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0;
|
||||
z-index: 1000;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="container">
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/alerts">Alerts</a></li>
|
||||
<li><a href="alerts">Alerts</a></li>
|
||||
<li class="active">{{alert.name || getDefaultName() || "New"}}</li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
@@ -44,6 +44,12 @@
|
||||
<input type="number" class="form-control" ng-model="alert.options.value" placeholder="reference value" 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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -55,4 +61,4 @@
|
||||
<alert-subscribers alert-id="alert.id"></alert-subscribers>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p>
|
||||
<a href="/alerts/new" class="btn btn-default"><i class="fa fa-plus"></i> New Alert</a>
|
||||
<a href="alerts/new" class="btn btn-default"><i class="fa fa-plus"></i> New Alert</a>
|
||||
</p>
|
||||
|
||||
<smart-table rows="alerts" columns="gridColumns"
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
tooltip-placement="bottom">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
|
||||
|
||||
<span class="pull-right">
|
||||
<a class="btn btn-default btn-xs" ng-href="/queries/{{query.id}}#{{widget.visualization.id}}" ng-show="currentUser.hasPermission('view_query')"><span class="glyphicon glyphicon-link"></span></a>
|
||||
<a class="btn btn-default btn-xs" ng-href="queries/{{query.id}}#{{widget.visualization.id}}" ng-show="currentUser.hasPermission('view_query')"><span class="glyphicon glyphicon-link"></span></a>
|
||||
<button type="button" class="btn btn-default btn-xs" ng-show="dashboard.canEdit()" ng-click="deleteWidget()" title="Remove Widget"><span class="glyphicon glyphicon-trash"></span></button>
|
||||
</span>
|
||||
|
||||
@@ -58,6 +58,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default rd-widget-textbox" ng-if="type=='restricted'">
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="text-center">
|
||||
<h1><span class="glyphicon glyphicon-lock"></span></h1>
|
||||
<p class="text-muted">
|
||||
This widget requires access to a data source you don't have access to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default rd-widget-textbox" ng-hide="widget.width == 0" ng-if="type=='textbox'" ng-mouseenter="showControls = true" ng-mouseleave="showControls = false">
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/data_sources">Data Sources</a></li>
|
||||
<li><a href="data_sources">Data Sources</a></li>
|
||||
<li class="active">{{dataSource.name || "New"}}</li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<i class="fa fa-database"></i> {{dataSource.name}}
|
||||
<button class="btn btn-xs btn-danger pull-right" ng-click="deleteDataSource($event, dataSource)">Delete</button>
|
||||
</div>
|
||||
<a ng-href="/data_sources/new" class="list-group-item">
|
||||
<a ng-href="data_sources/new" class="list-group-item">
|
||||
<i class="fa fa-plus"></i> Add Data Source
|
||||
</a>
|
||||
</div>
|
||||
|
||||
12
rd_ui/app/views/groups/edit_group_form.html
Normal file
12
rd_ui/app/views/groups/edit_group_form.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{title}}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form">
|
||||
<input type="text" ng-model="group.name" placeholder="Group Name" class="form-control"/>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-default" ng-click="cancel()">Cancel</button>
|
||||
<button class="btn btn-primary" ng-click="ok()">{{saveButtonText}}</button>
|
||||
</div>
|
||||
15
rd_ui/app/views/groups/list.html
Normal file
15
rd_ui/app/views/groups/list.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<div class="container">
|
||||
<users-nav></users-nav>
|
||||
|
||||
<div class="row voffset1">
|
||||
<div class="col-md-12">
|
||||
<p ng-if="currentUser.hasPermission('admin')">
|
||||
<a href="#" ng-click="newGroup()" class="btn btn-default"><i class="fa fa-plus"></i> New Group</a>
|
||||
</p>
|
||||
|
||||
<smart-table rows="groups" columns="gridColumns"
|
||||
config="gridConfig"
|
||||
class="table table-condensed table-hover"></smart-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
42
rd_ui/app/views/groups/show.html
Normal file
42
rd_ui/app/views/groups/show.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<div class="container">
|
||||
<users-nav></users-nav>
|
||||
|
||||
<group-name group="group"></group-name>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-4">
|
||||
<ul class="nav nav-pills">
|
||||
<li role="presentation" class="active"><a href="groups/{{group.id}}">Members</a></li>
|
||||
<li role="presentation" ng-if="currentUser.isAdmin"><a href="groups/{{group.id}}/data_sources">Data Sources</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8" ng-if="currentUser.isAdmin">
|
||||
<ui-select ng-model="newMember.selected" on-select="addMember($item)">
|
||||
<ui-select-match placeholder="Add New Member"></ui-select-match>
|
||||
<ui-select-choices repeat="user in foundUsers | filter:$select.search"
|
||||
refresh="findUser($select.search)"
|
||||
refresh-delay="0"
|
||||
ui-disable-choice="user.alreadyMember">
|
||||
<div>
|
||||
<img src="{{user.gravatar_url}}" height="24px"> {{user.name}}
|
||||
<small ng-if="user.alreadyMember">(already member in this group)</small>
|
||||
</div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row voffset1">
|
||||
<div class="col-lg-12">
|
||||
|
||||
<table class="table table-condensed table-hover">
|
||||
<tbody>
|
||||
<tr ng-repeat="member in members">
|
||||
<td width="50px"><img src="{{member.gravatar_url}}" height="40px"/></td>
|
||||
<td>{{member.name}} <button class="pull-right btn btn-sm btn-danger" ng-click="removeMember(member)" ng-if="currentUser.isAdmin">Remove</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
57
rd_ui/app/views/groups/show_data_sources.html
Normal file
57
rd_ui/app/views/groups/show_data_sources.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<div class="container">
|
||||
<users-nav></users-nav>
|
||||
|
||||
<group-name group="group"></group-name>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-4">
|
||||
<ul class="nav nav-pills">
|
||||
<li role="presentation"><a href="groups/{{group.id}}">Members</a></li>
|
||||
<li role="presentation" class="active"><a href="groups/{{group.id}}/data_sources">Data Sources</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-lg-8">
|
||||
<ui-select ng-model="newDataSource.selected" on-select="addDataSource($item)">
|
||||
<ui-select-match placeholder="Add Data Source"></ui-select-match>
|
||||
<ui-select-choices repeat="dataSource in foundDataSources | filter:$select.search"
|
||||
refresh="findDataSource($select.search)"
|
||||
refresh-delay="0">
|
||||
<div>
|
||||
{{dataSource.name}}
|
||||
</div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row voffset1">
|
||||
<div class="col-lg-12">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="dataSource in dataSources">
|
||||
<td> {{dataSource.name}}</td>
|
||||
<td width="180px">
|
||||
<div class="btn-group" dropdown>
|
||||
<button type="button" class="btn btn-sm btn-default dropdown-toggle" dropdown-toggle ng-if="dataSource.view_only">
|
||||
View Only <span class="caret"></span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-success dropdown-toggle" dropdown-toggle ng-if="!dataSource.view_only">
|
||||
Full Access <span class="caret"></span>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li><a href="#" ng-click="changePermission(dataSource, false)"><small ng-if="!dataSource.view_only"><span class="glyphicon glyphicon-ok"/></small> Full Access<br/></a></li>
|
||||
<li><a href="#" ng-click="changePermission(dataSource, true)"><small ng-if="dataSource.view_only"><span class="glyphicon glyphicon-ok"/></small> View Only</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button class="pull-right btn btn-sm btn-danger" ng-click="removeDataSource(dataSource)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,21 +1,34 @@
|
||||
<div class="container">
|
||||
<h2>Dashboards</h2>
|
||||
<div class="list-group" ng-repeat="(name, dashboards) in allDashboards">
|
||||
<div class="list-group-item active">
|
||||
{{name}}
|
||||
<button ng-show="currentUser.hasPermission('create_dashboard')" type="button" class="btn btn-sm btn-link" data-toggle="modal" href="#new_dashboard_dialog" tooltip="New Dashboard"><span class="glyphicon glyphicon-plus-sign"></span></button>
|
||||
<div class="row">
|
||||
<p>
|
||||
<a href="queries/new" class="btn btn-default">New Query</a>
|
||||
<button ng-show="currentUser.hasPermission('create_dashboard')" type="button" class="btn btn-default" data-toggle="modal" href="#new_dashboard_dialog">New Dashboard</button>
|
||||
<a href="alerts/new" class="btn btn-default">New Alert</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="list-group col-md-6">
|
||||
<div class="list-group-item active">
|
||||
Recent Dashboards
|
||||
</div>
|
||||
<a ng-href="dashboard/{{dashboard.slug}}" class="list-group-item" ng-repeat="dashboard in recentDashboards">
|
||||
{{dashboard.name}}
|
||||
</a>
|
||||
</div>
|
||||
<div class="list-group-item" ng-repeat="dashboard in dashboards" >
|
||||
<button type="button" class="close delete-button" aria-hidden="true" ng-show="dashboard.canEdit()" ng-click="archiveDashboard(dashboard)" tooltip="Delete Dashboard">×</button>
|
||||
<a ng-href="/dashboard/{{dashboard.slug}}">{{dashboard.name}}</a>
|
||||
|
||||
<div class="list-group col-md-6">
|
||||
<div class="list-group-item active">
|
||||
Recent Queries
|
||||
</div>
|
||||
<a ng-href="queries/{{query.id}}" class="list-group-item" ng-repeat="query in recentQueries">{{query.name}}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="currentUser.hasPermission('admin')">
|
||||
<div ng-show="currentUser.hasPermission('super_admin')" class="row">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item active">Admin</div>
|
||||
<a href="/admin/status" class="list-group-item">Status</a>
|
||||
<a href="admin/status" class="list-group-item">Status</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,34 +0,0 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<p>
|
||||
<a href="/queries/new" class="btn btn-default">New Query</a>
|
||||
<button ng-show="currentUser.hasPermission('create_dashboard')" type="button" class="btn btn-default" data-toggle="modal" href="#new_dashboard_dialog">New Dashboard</button>
|
||||
<a href="/alerts/new" class="btn btn-default">New Alert</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="list-group col-md-6">
|
||||
<div class="list-group-item active">
|
||||
Recent Dashboards
|
||||
</div>
|
||||
<a ng-href="/dashboard/{{dashboard.slug}}" class="list-group-item" ng-repeat="dashboard in recentDashboards">
|
||||
{{dashboard.name}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="list-group col-md-6">
|
||||
<div class="list-group-item active">
|
||||
Recent Queries
|
||||
</div>
|
||||
<a ng-href="/queries/{{query.id}}" class="list-group-item" ng-repeat="query in recentQueries">{{query.name}}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="currentUser.hasPermission('admin')" class="row">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item active">Admin</div>
|
||||
<a href="/admin/status" class="list-group-item">Status</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1 +1 @@
|
||||
<a ng-href="/queries/{{dataRow.id}}">{{dataRow.name}}</a>
|
||||
<a ng-href="queries/{{dataRow.id}}">{{dataRow.name}}</a>
|
||||
@@ -1,5 +1,17 @@
|
||||
<div class="container" style="position:relative">
|
||||
<overlay ng-if="canCreateQuery === false">
|
||||
You don't have permission to create new queries on any of the data sources available to you.
|
||||
You can either <a href="queries">browse existing queries</a>, or ask for additional permissions from your re:dash admin.
|
||||
</overlay>
|
||||
|
||||
<div class="container">
|
||||
<overlay ng-if="noDataSources && currentUser.isAdmin">
|
||||
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 Group Permissions</a>
|
||||
</overlay>
|
||||
|
||||
<overlay ng-if="noDataSources && !currentUser.isAdmin">
|
||||
Looks like no data sources were created yet (or none of them available to the group(s) you're member of). Please ask your re:dash admin to create one first.
|
||||
</overlay>
|
||||
|
||||
<p class="alert alert-warning" ng-if="query.is_archived">This query is archived and can't be used in dashboards, and won't appear in search results.</p>
|
||||
<alert-unsaved-changes ng-if="canEdit" is-dirty="isDirty"></alert-unsaved-changes>
|
||||
@@ -63,7 +75,7 @@
|
||||
<div ng-class="editorSize">
|
||||
<div>
|
||||
<p>
|
||||
<button type="button" class="btn btn-primary btn-xs" ng-disabled="queryExecuting" ng-click="executeQuery()" ng-if="canExecuteQuery">
|
||||
<button type="button" class="btn btn-primary btn-xs" ng-disabled="queryExecuting || !canExecuteQuery()" ng-click="executeQuery()">
|
||||
<span class="glyphicon glyphicon-play"></span> Execute
|
||||
</button>
|
||||
<query-formatter></query-formatter>
|
||||
@@ -90,7 +102,7 @@
|
||||
<div class="schema-browser">
|
||||
<div ng-repeat="table in schema | filter:schemaFilter track by table.name">
|
||||
<div class="table-name" ng-click="table.collapsed = !table.collapsed">
|
||||
<i class="fa fa-table"></i> <strong><span title="{{table.name}}">{{table.name}}</span></strong>
|
||||
<i class="fa fa-table"></i> <strong><span title="{{table.name}}">{{table.name}}</span><span ng-if="table.size !== undefined"> ({{table.size}})</span></strong>
|
||||
</div>
|
||||
<div collapse="table.collapsed && !schemaFilter">
|
||||
<div ng-repeat="column in table.columns track by column" style="padding-left:16px;">{{column}}</div>
|
||||
@@ -213,7 +225,7 @@
|
||||
<span class="remove" ng-click="deleteVisualization($event, vis)" ng-show="canEdit"> ×</span>
|
||||
</rd-tab>
|
||||
<rd-tab tab-id="add" name="+ New Visualization" removeable="true" ng-show="canEdit"></rd-tab>
|
||||
<li ng-if="!sourceMode && canExecuteQuery" class="rd-tab-btn"><button class="btn btn-sm btn-default" ng-click="executeQuery()" ng-disabled="queryExecuting" title="Refresh Dataset"><span class="glyphicon glyphicon-refresh"></span></button></li>
|
||||
<li ng-if="!sourceMode" class="rd-tab-btn"><button class="btn btn-sm btn-default" ng-click="executeQuery()" ng-disabled="queryExecuting || !canExecuteQuery()" title="Refresh Dataset"><span class="glyphicon glyphicon-refresh"></span></button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,6 +234,18 @@
|
||||
<div ng-show="selectedTab == 'table'" >
|
||||
<filters></filters>
|
||||
<grid-renderer query-result="queryResult" items-per-page="50"></grid-renderer>
|
||||
|
||||
<div class="row" ng-if="vis.type=='TABLE'" ng-repeat="vis in query.visualizations">
|
||||
<div class="col-lg-8 embed-code">
|
||||
<i class="fa fa-code" ng-click="show_code = show_code==true ? false : true;"></i>
|
||||
<div ng-show="show_code">
|
||||
<span class="text-muted">Embed code for this table: <small>(height should be adjusted)</small></span>
|
||||
<code><iframe src="{{ base_url }}/embed/query/{{query.id}}/visualization/{{ vis.id }}?api_key={{query.api_key}}"<br/>
|
||||
|
||||
width="720" height="1650"></iframe></code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pivot-table-renderer ng-show="selectedTab == 'pivot'" query-result="queryResult"></pivot-table-renderer>
|
||||
@@ -229,6 +253,18 @@
|
||||
<div ng-show="selectedTab == vis.id" ng-repeat="vis in query.visualizations">
|
||||
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8 embed-code">
|
||||
<i class="fa fa-code" ng-click="show_code = show_code==true ? false : true;"></i>
|
||||
<div ng-show="show_code">
|
||||
<span class="text-muted">Embed code for this chart: <small>(height should be adjusted)</small></span>
|
||||
<code><iframe src="{{ base_url }}/embed/query/{{query.id}}/visualization/{{ vis.id }}?api_key={{query.api_key}}"<br/>
|
||||
|
||||
width="720" height="391"></iframe></code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<edit-visulatization-form visualization="vis" query="query" query-result="queryResult" ng-show="canEdit"></edit-visulatization-form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li class="active">Users</li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<users-nav></users-nav>
|
||||
|
||||
<div class="row voffset1">
|
||||
<div class="col-md-12">
|
||||
<p ng-if="currentUser.hasPermission('admin')">
|
||||
<a href="/users/new" class="btn btn-default"><i class="fa fa-plus"></i> New User</a>
|
||||
<a href="users/new" class="btn btn-default"><i class="fa fa-plus"></i> New User</a>
|
||||
</p>
|
||||
|
||||
<smart-table rows="users" columns="gridColumns"
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/users">Users</a></li>
|
||||
<li class="active">New</li>
|
||||
</ol>
|
||||
<users-nav></users-nav>
|
||||
|
||||
<form class="form" name="userForm" ng-submit="saveUser()" novalidate>
|
||||
<div class="form-group required" show-errors>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li ng-if="currentUser.hasPermission('list_users')"><a href="/users">Users</a></li>
|
||||
<li ng-if="!currentUser.hasPermission('list_users')">Users</li>
|
||||
<li class="active">{{user.name}}</li>
|
||||
</ol>
|
||||
<users-nav></users-nav>
|
||||
|
||||
<h2>{{user.name}}</h2>
|
||||
|
||||
<tabset>
|
||||
<tab heading="Profile" active="tabs['profile']" select="setTab('profile')">
|
||||
|
||||
34
rd_ui/app/views/visualization-embed.html
Normal file
34
rd_ui/app/views/visualization-embed.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<div class="container" id="widget">
|
||||
<div class="row">
|
||||
<div class="col-lg-12" ng-controller='EmbeddedVisualizationCtrl'>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
<p>
|
||||
<visualization-name visualization="visualization"/>
|
||||
</p>
|
||||
<div class="text-muted" ng-bind-html="query.description | markdown"></div>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<visualization-renderer visualization="visualization" query-result="queryResult">
|
||||
</visualization-renderer>
|
||||
|
||||
<div class="panel-footer">
|
||||
<span class="label label-default">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
|
||||
|
||||
<span class="pull-right">
|
||||
<a class="btn btn-default btn-xs" ng-href="queries/{{query.id}}#{{widget.visualization.id}}" target="_blank"><span class="glyphicon glyphicon-link"></span></a>
|
||||
</span>
|
||||
|
||||
<span class="pull-right">
|
||||
<a class="btn btn-default btn-xs" ng-disabled="!queryResult.getData()" query-result-link target="_self">
|
||||
<span class="glyphicon glyphicon-cloud-download"></span>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,3 @@
|
||||
<div>
|
||||
<section class="clearfix">
|
||||
<date-range-selector ng-if="dateRangeEnabled()" date-range='dateRange' class='pull-right'></date-range-selector>
|
||||
</section>
|
||||
<section>
|
||||
<chart options='chartOptions' series='chartSeries' class='graph'></chart>
|
||||
</section>
|
||||
<div style="max-height: 300px;">
|
||||
<plotly-chart options='plotlyOptions' series='chartSeries' min-height="300"></plotly-chart>
|
||||
</div>
|
||||
|
||||
@@ -89,6 +89,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group row">
|
||||
|
||||
<label class="control-label col-sm-5">Legend</label>
|
||||
|
||||
<div class="col-sm-7">
|
||||
<input type="checkbox" ng-model="options.legend.enabled">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"angular-moment": "0.10.3",
|
||||
"moment": "~2.8.0",
|
||||
"codemirror": "4.8.0",
|
||||
"highcharts": "3.0.10",
|
||||
"underscore": "1.5.1",
|
||||
"pivottable": "~1.1.1",
|
||||
"cornelius": "https://github.com/restorando/cornelius.git",
|
||||
@@ -22,7 +21,6 @@
|
||||
"jquery-ui": "~1.10.4",
|
||||
"underscore.string": "~2.3.3",
|
||||
"marked": "~0.3.2",
|
||||
"bucky": "~0.2.6",
|
||||
"pace": "~0.5.1",
|
||||
"font-awesome": "~4.2.0",
|
||||
"mustache": "~1.0.0",
|
||||
@@ -34,7 +32,9 @@
|
||||
"angular-bootstrap-show-errors": "~2.3.0",
|
||||
"angular-sanitize": "1.2.18",
|
||||
"d3": "3.5.6",
|
||||
"angular-ui-sortable": "~0.13.4"
|
||||
"angular-ui-sortable": "~0.13.4",
|
||||
"angular-plotly": "~0.1.2",
|
||||
"plotly": "~0.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"angular-mocks": "1.2.18",
|
||||
|
||||
@@ -29,13 +29,16 @@
|
||||
"grunt-karma": "~0.8.3",
|
||||
"karma-phantomjs-launcher": "~0.1.4",
|
||||
"karma": "~0.12.19",
|
||||
"karma-ng-html2js-preprocessor": "~0.1.0"
|
||||
"karma-ng-html2js-preprocessor": "~0.1.0",
|
||||
"bower": "~1.7.1",
|
||||
"grunt-cli": "~0.1.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "grunt test",
|
||||
"build": "grunt build",
|
||||
"bower": "bower"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ module.exports = function(config) {
|
||||
'app/bower_components/codemirror/mode/sql/sql.js',
|
||||
'app/bower_components/codemirror/mode/javascript/javascript.js',
|
||||
'app/bower_components/angular-ui-codemirror/ui-codemirror.js',
|
||||
'app/bower_components/highcharts/highcharts.js',
|
||||
'app/bower_components/highcharts/modules/exporting.js',
|
||||
'app/bower_components/plotly/plotly.js',
|
||||
'app/bower_components/angular-plotly/src/angular-plotly.js',
|
||||
'app/bower_components/gridster/dist/jquery.gridster.js',
|
||||
'app/bower_components/angular-growl/build/angular-growl.js',
|
||||
'app/bower_components/pivottable/dist/pivot.js',
|
||||
@@ -50,7 +50,6 @@ module.exports = function(config) {
|
||||
'app/bower_components/angular-ui-select/dist/select.js',
|
||||
'app/bower_components/underscore.string/lib/underscore.string.js',
|
||||
'app/bower_components/marked/lib/marked.js',
|
||||
'app/scripts/ng_highchart.js',
|
||||
'app/scripts/ng_smart_table.js',
|
||||
'app/scripts/ui-bootstrap-tpls-0.5.0.min.js',
|
||||
'app/bower_components/bucky/bucky.js',
|
||||
@@ -75,6 +74,7 @@ module.exports = function(config) {
|
||||
'app/scripts/directives/directives.js',
|
||||
'app/scripts/directives/query_directives.js',
|
||||
'app/scripts/directives/dashboard_directives.js',
|
||||
'app/scripts/directives/plotly.js',
|
||||
'app/scripts/filters.js',
|
||||
|
||||
'app/views/**/*.html',
|
||||
|
||||
@@ -7,7 +7,15 @@ from flask_mail import Mail
|
||||
from redash import settings
|
||||
from redash.query_runner import import_query_runners
|
||||
|
||||
__version__ = '0.8.3'
|
||||
__version__ = '0.9.1'
|
||||
|
||||
|
||||
if settings.FEATURE_TABLES_PERMISSIONS:
|
||||
# TODO(@arikfr): remove this warning on next version release
|
||||
print "You have table based permissions enabled, but this feature was removed."
|
||||
print "Please use new data sources based permission model."
|
||||
print "(re:dash won't load until you turn off this feature)"
|
||||
exit(1)
|
||||
|
||||
|
||||
def setup_logging():
|
||||
|
||||
@@ -9,7 +9,7 @@ from wtforms import fields
|
||||
from wtforms.widgets import TextInput
|
||||
|
||||
from redash import models
|
||||
from redash.permissions import require_permission
|
||||
from redash.permissions import require_super_admin
|
||||
|
||||
|
||||
class ArrayListField(fields.Field):
|
||||
@@ -44,10 +44,14 @@ class PgModelConverter(CustomModelConverter):
|
||||
def __init__(self, view, additional=None):
|
||||
additional = {ArrayField: self.handle_array_field,
|
||||
DateTimeTZField: self.handle_datetime_tz_field,
|
||||
models.JSONField: self.handle_json_field,
|
||||
}
|
||||
super(PgModelConverter, self).__init__(view, additional)
|
||||
self.view = view
|
||||
|
||||
def handle_json_field(self, model, field, **kwargs):
|
||||
return field.name, JSONTextAreaField(**kwargs)
|
||||
|
||||
def handle_array_field(self, model, field, **kwargs):
|
||||
return field.name, ArrayListField(**kwargs)
|
||||
|
||||
@@ -60,7 +64,7 @@ class BaseModelView(ModelView):
|
||||
column_display_pk = True
|
||||
model_form_converter = PgModelConverter
|
||||
|
||||
@require_permission('admin')
|
||||
@require_super_admin
|
||||
def is_accessible(self):
|
||||
return True
|
||||
|
||||
@@ -85,7 +89,7 @@ def init_admin(app):
|
||||
admin.add_view(DashboardModelView(models.Dashboard))
|
||||
logout_link = MenuLink('Logout', '/logout', 'logout')
|
||||
|
||||
for m in (models.Visualization, models.Widget, models.ActivityLog, models.Group, models.Event):
|
||||
for m in (models.Visualization, models.Widget, models.Event, models.Organization):
|
||||
admin.add_view(BaseModelView(m))
|
||||
|
||||
admin.add_link(logout_link)
|
||||
@@ -3,10 +3,14 @@ import hmac
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import redirect, request, jsonify
|
||||
from flask.ext.login import LoginManager
|
||||
from flask.ext.login import user_logged_in
|
||||
|
||||
from redash import models, settings, google_oauth, saml_auth
|
||||
from redash import models, settings
|
||||
from redash.authentication import google_oauth, saml_auth
|
||||
from redash.authentication.org_resolving import current_org
|
||||
from redash.authentication.helper import get_login_url
|
||||
from redash.tasks import record_event
|
||||
|
||||
login_manager = LoginManager()
|
||||
@@ -25,7 +29,10 @@ def sign(key, path, expires):
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return models.User.get_by_id(user_id)
|
||||
try:
|
||||
return models.User.get_by_id_and_org(user_id, current_org.id)
|
||||
except models.User.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def hmac_load_user_from_request(request):
|
||||
@@ -48,7 +55,7 @@ def hmac_load_user_from_request(request):
|
||||
calculated_signature = sign(query.api_key, request.path, expires)
|
||||
|
||||
if query.api_key and signature == calculated_signature:
|
||||
return models.ApiUser(query.api_key)
|
||||
return models.ApiUser(query.api_key, query.org, query.groups.keys())
|
||||
|
||||
return None
|
||||
|
||||
@@ -58,13 +65,14 @@ def get_user_from_api_key(api_key, query_id):
|
||||
return None
|
||||
|
||||
user = None
|
||||
|
||||
try:
|
||||
user = models.User.get_by_api_key(api_key)
|
||||
user = models.User.get_by_api_key_and_org(api_key, current_org.id)
|
||||
except models.User.DoesNotExist:
|
||||
if query_id:
|
||||
query = models.Query.get_by_id(query_id)
|
||||
query = models.Query.get_by_id_and_org(query_id, current_org.id)
|
||||
if query and query.api_key == api_key:
|
||||
user = models.ApiUser(api_key)
|
||||
user = models.ApiUser(api_key, query.org, query.groups.keys())
|
||||
|
||||
return user
|
||||
|
||||
@@ -89,6 +97,7 @@ def api_key_load_user_from_request(request):
|
||||
|
||||
def log_user_logged_in(app, user):
|
||||
event = {
|
||||
'org_id': current_org.id,
|
||||
'user_id': user.id,
|
||||
'action': 'login',
|
||||
'object_type': 'redash',
|
||||
@@ -98,10 +107,22 @@ def log_user_logged_in(app, user):
|
||||
record_event.delay(event)
|
||||
|
||||
|
||||
@login_manager.unauthorized_handler
|
||||
def redirect_to_login():
|
||||
if request.is_xhr or '/api/' in request.path:
|
||||
response = jsonify({'message': "Couldn't find resource. Please login and try again."})
|
||||
response.status_code = 404
|
||||
return response
|
||||
|
||||
login_url = get_login_url(next=request.url, external=False)
|
||||
|
||||
return redirect(login_url)
|
||||
|
||||
|
||||
def setup_authentication(app):
|
||||
login_manager.init_app(app)
|
||||
login_manager.anonymous_user = models.AnonymousUser
|
||||
login_manager.login_view = 'login'
|
||||
|
||||
app.secret_key = settings.COOKIE_SECRET
|
||||
app.register_blueprint(google_oauth.blueprint)
|
||||
app.register_blueprint(saml_auth.blueprint)
|
||||
109
redash/authentication/google_oauth.py
Normal file
109
redash/authentication/google_oauth.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import logging
|
||||
from flask.ext.login import login_user
|
||||
import requests
|
||||
from flask import redirect, url_for, Blueprint, flash, request, session
|
||||
from flask_oauthlib.client import OAuth
|
||||
from redash import models, settings
|
||||
from redash.authentication.org_resolving import current_org
|
||||
|
||||
logger = logging.getLogger('google_oauth')
|
||||
|
||||
oauth = OAuth()
|
||||
blueprint = Blueprint('google_oauth', __name__)
|
||||
|
||||
|
||||
def google_remote_app():
|
||||
if 'google' not in oauth.remote_apps:
|
||||
oauth.remote_app('google',
|
||||
base_url='https://www.google.com/accounts/',
|
||||
authorize_url='https://accounts.google.com/o/oauth2/auth',
|
||||
request_token_url=None,
|
||||
request_token_params={
|
||||
'scope': 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile',
|
||||
},
|
||||
access_token_url='https://accounts.google.com/o/oauth2/token',
|
||||
access_token_method='POST',
|
||||
consumer_key=settings.GOOGLE_CLIENT_ID,
|
||||
consumer_secret=settings.GOOGLE_CLIENT_SECRET)
|
||||
|
||||
return oauth.google
|
||||
|
||||
|
||||
def get_user_profile(access_token):
|
||||
headers = {'Authorization': 'OAuth {}'.format(access_token)}
|
||||
response = requests.get('https://www.googleapis.com/oauth2/v1/userinfo', headers=headers)
|
||||
|
||||
if response.status_code == 401:
|
||||
logger.warning("Failed getting user profile (response code 401).")
|
||||
return None
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
def verify_profile(org, profile):
|
||||
if org.is_public:
|
||||
return True
|
||||
|
||||
domain = profile['email'].split('@')[-1]
|
||||
return domain in org.google_apps_domains
|
||||
|
||||
|
||||
def create_and_login_user(org, name, email):
|
||||
try:
|
||||
user_object = models.User.get_by_email_and_org(email, org)
|
||||
if user_object.name != name:
|
||||
logger.debug("Updating user name (%r -> %r)", user_object.name, name)
|
||||
user_object.name = name
|
||||
user_object.save()
|
||||
except models.User.DoesNotExist:
|
||||
logger.debug("Creating user object (%r)", name)
|
||||
user_object = models.User.create(org=org, name=name, email=email, groups=[org.default_group.id])
|
||||
|
||||
login_user(user_object, remember=True)
|
||||
|
||||
|
||||
@blueprint.route('/<org_slug>/oauth/google', endpoint="authorize_org")
|
||||
def org_login(org_slug):
|
||||
session['org_slug'] = current_org.slug
|
||||
return redirect(url_for(".authorize", next=request.args.get('next', None)))
|
||||
|
||||
|
||||
@blueprint.route('/oauth/google', endpoint="authorize")
|
||||
def login():
|
||||
callback = url_for('.callback', _external=True)
|
||||
next = request.args.get('next', url_for("index", org_slug=session.get('org_slug')))
|
||||
logger.debug("Callback url: %s", callback)
|
||||
logger.debug("Next is: %s", next)
|
||||
return google_remote_app().authorize(callback=callback, state=next)
|
||||
|
||||
|
||||
@blueprint.route('/oauth/google_callback', endpoint="callback")
|
||||
def authorized():
|
||||
resp = google_remote_app().authorized_response()
|
||||
access_token = resp['access_token']
|
||||
|
||||
if access_token is None:
|
||||
logger.warning("Access token missing in call back request.")
|
||||
flash("Validation error. Please retry.")
|
||||
return redirect(url_for('login'))
|
||||
|
||||
profile = get_user_profile(access_token)
|
||||
if profile is None:
|
||||
flash("Validation error. Please retry.")
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if 'org_slug' in session:
|
||||
org = models.Organization.get_by_slug(session.pop('org_slug'))
|
||||
else:
|
||||
org = current_org
|
||||
|
||||
if not verify_profile(org, profile):
|
||||
logger.warning("User tried to login with unauthorized domain name: %s (org: %s)", profile['email'], org)
|
||||
flash("Your Google Apps domain name isn't allowed.")
|
||||
return redirect(url_for('login', org_slug=org.slug))
|
||||
|
||||
create_and_login_user(org, profile['name'], profile['email'])
|
||||
|
||||
next = request.args.get('state') or url_for("index", org_slug=org.slug)
|
||||
|
||||
return redirect(next)
|
||||
13
redash/authentication/helper.py
Normal file
13
redash/authentication/helper.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from redash import settings
|
||||
from redash.authentication.org_resolving import current_org
|
||||
from flask import url_for, request
|
||||
|
||||
|
||||
# TODO: move this back to authentication/__init__.py after resolving circular depdency between redash.wsgi and redash.handler
|
||||
def get_login_url(external=False, next="/"):
|
||||
if settings.MULTI_ORG:
|
||||
login_url = url_for('login', org_slug=current_org.slug, next=next, _external=external)
|
||||
else:
|
||||
login_url = url_for('login', next=next, _external=external)
|
||||
|
||||
return login_url
|
||||
22
redash/authentication/org_resolving.py
Normal file
22
redash/authentication/org_resolving.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
This module implements different strategies to resolve the current Organization we are using. By default we use the simple
|
||||
single_org strategy, which assumes you have a single Organization in your installation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from redash.models import Organization
|
||||
from werkzeug.local import LocalProxy
|
||||
from flask import request
|
||||
|
||||
|
||||
def _get_current_org():
|
||||
slug = request.view_args.get('org_slug', 'default')
|
||||
org = Organization.get_by_slug(slug)
|
||||
logging.debug("Current organization: %s (slug: %s)", org, slug)
|
||||
return org
|
||||
|
||||
|
||||
# TODO: move to authentication
|
||||
current_org = LocalProxy(_get_current_org)
|
||||
|
||||
|
||||
@@ -1,36 +1,18 @@
|
||||
# Copyright 2015 Okta, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
from flask.ext.login import login_user
|
||||
import requests
|
||||
from flask import redirect, url_for, Blueprint, request
|
||||
from flask_oauth import OAuth
|
||||
from redash import models, settings
|
||||
from saml2 import (
|
||||
BINDING_HTTP_POST,
|
||||
BINDING_HTTP_REDIRECT,
|
||||
entity,
|
||||
)
|
||||
from redash.authentication.google_oauth import create_and_login_user
|
||||
from redash.authentication.org_resolving import current_org
|
||||
from redash import settings
|
||||
from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT, entity
|
||||
from saml2.client import Saml2Client
|
||||
from saml2.config import Config as Saml2Config
|
||||
|
||||
|
||||
logger = logging.getLogger('saml_auth')
|
||||
|
||||
blueprint = Blueprint('saml_auth', __name__)
|
||||
|
||||
|
||||
def get_saml_client():
|
||||
'''
|
||||
Return saml configuation.
|
||||
@@ -38,9 +20,9 @@ def get_saml_client():
|
||||
'''
|
||||
|
||||
if settings.SAML_CALLBACK_SERVER_NAME:
|
||||
acs_url=settings.SAML_CALLBACK_SERVER_NAME + url_for("saml_auth.idp_initiated")
|
||||
else:
|
||||
acs_url = url_for("saml_auth.idp_initiated",_external=True)
|
||||
acs_url = settings.SAML_CALLBACK_SERVER_NAME + url_for("saml_auth.idp_initiated")
|
||||
else:
|
||||
acs_url = url_for("saml_auth.idp_initiated", _external=True)
|
||||
|
||||
# NOTE:
|
||||
# Ideally, this should fetch the metadata and pass it to
|
||||
@@ -60,7 +42,7 @@ def get_saml_client():
|
||||
'metadata': {
|
||||
# 'inline': metadata,
|
||||
"local": [tmp.name]
|
||||
},
|
||||
},
|
||||
'service': {
|
||||
'sp': {
|
||||
'endpoints': {
|
||||
@@ -103,26 +85,17 @@ 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
|
||||
try:
|
||||
user_object = models.User.get(models.User.email == email)
|
||||
if user_object.name != name:
|
||||
logger.debug("Updating user name (%r -> %r)", user_object.name, name)
|
||||
user_object.name = name
|
||||
user_object.save()
|
||||
except models.User.DoesNotExist:
|
||||
logger.debug("Creating user object (%r)", name)
|
||||
user_object = models.User.create(name=name, email=email, groups=models.User.DEFAULT_GROUPS)
|
||||
|
||||
login_user(user_object, remember=True)
|
||||
create_and_login_user(current_org, name, email)
|
||||
url = url_for('index')
|
||||
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@blueprint.route("/saml/login")
|
||||
def sp_initiated():
|
||||
if not settings.SAML_METADATA_URL:
|
||||
logger.error("Cannot invoke saml endpoint without metadata url in settings.")
|
||||
return redirect(url_for('index'))
|
||||
logger.error("Cannot invoke saml endpoint without metadata url in settings.")
|
||||
return redirect(url_for('index'))
|
||||
|
||||
saml_client = get_saml_client()
|
||||
reqid, info = saml_client.prepare_for_authenticate()
|
||||
@@ -142,4 +115,4 @@ def sp_initiated():
|
||||
# since enterprise environments don't always conform to RFCs
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
return response
|
||||
return response
|
||||
@@ -6,6 +6,7 @@ from redash.query_runner import query_runners, validate_configuration
|
||||
|
||||
manager = Manager(help="Data sources management commands.")
|
||||
|
||||
|
||||
@manager.command
|
||||
def list():
|
||||
"""List currently configured data sources"""
|
||||
@@ -27,6 +28,7 @@ def validate_data_source_options(type, options):
|
||||
print "Error: invalid configuration."
|
||||
exit()
|
||||
|
||||
|
||||
@manager.command
|
||||
def new(name=None, type=None, options=None):
|
||||
"""Create new data source"""
|
||||
@@ -82,7 +84,8 @@ def new(name=None, type=None, options=None):
|
||||
|
||||
data_source = models.DataSource.create(name=name,
|
||||
type=type,
|
||||
options=options)
|
||||
options=options,
|
||||
org=models.Organization.get_by_slug('default'))
|
||||
print "Id: {}".format(data_source.id)
|
||||
|
||||
|
||||
|
||||
20
redash/cli/organization.py
Normal file
20
redash/cli/organization.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from flask.ext.script import Manager
|
||||
from redash import models
|
||||
|
||||
manager = Manager(help="Organization management commands.")
|
||||
|
||||
|
||||
@manager.option('domains', help="comma separated list of domains to allow")
|
||||
def set_google_apps_domains(domains):
|
||||
organization = models.Organization.select().first()
|
||||
|
||||
organization.settings[models.Organization.SETTING_GOOGLE_APPS_DOMAINS] = domains.split(',')
|
||||
organization.save()
|
||||
|
||||
print "Updated list of allowed domains to: {}".format(organization.google_apps_domains)
|
||||
|
||||
|
||||
@manager.command
|
||||
def show_google_apps_domains():
|
||||
organization = models.Organization.select().first()
|
||||
print "Current list of Google Apps domains: {}".format(organization.google_apps_domains)
|
||||
@@ -1,12 +1,13 @@
|
||||
from flask.ext.script import Manager, prompt_pass
|
||||
from redash import models
|
||||
|
||||
manager = Manager(help="Users management commands.")
|
||||
manager = Manager(help="Users management commands. This commands assume single organization operation.")
|
||||
|
||||
|
||||
@manager.option('email', help="email address of the user to grant admin to")
|
||||
def grant_admin(email):
|
||||
try:
|
||||
user = models.User.get_by_email(email)
|
||||
user = models.User.get_by_email_and_org(email, models.Organization.get_by_slug('default'))
|
||||
|
||||
user.groups.append('admin')
|
||||
user.save()
|
||||
@@ -21,19 +22,25 @@ def grant_admin(email):
|
||||
@manager.option('--admin', dest='is_admin', action="store_true", default=False, help="set user as admin")
|
||||
@manager.option('--google', dest='google_auth', action="store_true", default=False, help="user uses Google Auth to login")
|
||||
@manager.option('--password', dest='password', default=None, help="Password for users who don't use Google Auth (leave blank for prompt).")
|
||||
@manager.option('--groups', dest='groups', default=models.User.DEFAULT_GROUPS, help="Comma seperated list of groups (leave blank for default).")
|
||||
@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)
|
||||
print "Admin: %r" % is_admin
|
||||
print "Login with Google Auth: %r\n" % google_auth
|
||||
|
||||
org = models.Organization.get_by_slug('default')
|
||||
if isinstance(groups, basestring):
|
||||
groups= groups.split(',')
|
||||
groups.remove('') # in case it was empty string
|
||||
groups = [int(g) for g in groups]
|
||||
|
||||
if groups is None:
|
||||
groups = [models.Group.get(models.Group.name=="default", models.Group.org==org).id]
|
||||
|
||||
if is_admin:
|
||||
groups += ['admin']
|
||||
groups += [models.Group.get(models.Group.name=="admin", models.Group.org==org).id]
|
||||
|
||||
user = models.User(email=email, name=name, groups=groups)
|
||||
user = models.User(org=org, email=email, name=name, groups=groups)
|
||||
if not google_auth:
|
||||
password = password or prompt_pass("Password")
|
||||
user.hash_password(password)
|
||||
@@ -54,7 +61,7 @@ def delete(email):
|
||||
@manager.option('email', help="email address of the user to change password for")
|
||||
def password(email, password):
|
||||
try:
|
||||
user = models.User.get_by_email(email)
|
||||
user = models.User.get_by_email_and_org(email, models.Organization.get_by_slug('default'))
|
||||
|
||||
user.hash_password(password)
|
||||
user.save()
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import logging
|
||||
from flask.ext.login import login_user
|
||||
import requests
|
||||
from flask import redirect, url_for, Blueprint, flash
|
||||
from flask_oauth import OAuth
|
||||
from redash import models, settings
|
||||
|
||||
logger = logging.getLogger('google_oauth')
|
||||
oauth = OAuth()
|
||||
|
||||
|
||||
if not settings.GOOGLE_APPS_DOMAIN:
|
||||
logger.warning("No Google Apps domain defined, all Google accounts allowed.")
|
||||
|
||||
google = oauth.remote_app('google',
|
||||
base_url='https://www.google.com/accounts/',
|
||||
authorize_url='https://accounts.google.com/o/oauth2/auth',
|
||||
request_token_url=None,
|
||||
request_token_params={
|
||||
'scope': 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile',
|
||||
'response_type': 'code'
|
||||
},
|
||||
access_token_url='https://accounts.google.com/o/oauth2/token',
|
||||
access_token_method='POST',
|
||||
access_token_params={'grant_type': 'authorization_code'},
|
||||
consumer_key=settings.GOOGLE_CLIENT_ID,
|
||||
consumer_secret=settings.GOOGLE_CLIENT_SECRET)
|
||||
|
||||
|
||||
blueprint = Blueprint('google_oauth', __name__)
|
||||
|
||||
|
||||
def get_user_profile(access_token):
|
||||
headers = {'Authorization': 'OAuth {}'.format(access_token)}
|
||||
response = requests.get('https://www.googleapis.com/oauth2/v1/userinfo', headers=headers)
|
||||
|
||||
if response.status_code == 401:
|
||||
logger.warning("Failed getting user profile (response code 401).")
|
||||
return None
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
def verify_profile(profile):
|
||||
if not settings.GOOGLE_APPS_DOMAIN:
|
||||
return True
|
||||
|
||||
domain = profile['email'].split('@')[-1]
|
||||
return domain in settings.GOOGLE_APPS_DOMAIN
|
||||
|
||||
|
||||
def create_and_login_user(name, email):
|
||||
try:
|
||||
user_object = models.User.get_by_email(email)
|
||||
if user_object.name != name:
|
||||
logger.debug("Updating user name (%r -> %r)", user_object.name, name)
|
||||
user_object.name = name
|
||||
user_object.save()
|
||||
except models.User.DoesNotExist:
|
||||
logger.debug("Creating user object (%r)", name)
|
||||
user_object = models.User.create(name=name, email=email, groups=models.User.DEFAULT_GROUPS)
|
||||
|
||||
login_user(user_object, remember=True)
|
||||
|
||||
|
||||
@blueprint.route('/oauth/google', endpoint="authorize")
|
||||
def login():
|
||||
# TODO, suport next
|
||||
callback=url_for('.callback', _external=True)
|
||||
logger.debug("Callback url: %s", callback)
|
||||
return google.authorize(callback=callback)
|
||||
|
||||
|
||||
@blueprint.route('/oauth/google_callback', endpoint="callback")
|
||||
@google.authorized_handler
|
||||
def authorized(resp):
|
||||
access_token = resp['access_token']
|
||||
|
||||
if access_token is None:
|
||||
logger.warning("Access token missing in call back request.")
|
||||
flash("Validation error. Please retry.")
|
||||
return redirect(url_for('login'))
|
||||
|
||||
profile = get_user_profile(access_token)
|
||||
if profile is None:
|
||||
flash("Validation error. Please retry.")
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if not verify_profile(profile):
|
||||
logger.warning("User tried to login with unauthorized domain name: %s", profile['email'])
|
||||
flash("Your Google Apps domain name isn't allowed.")
|
||||
return redirect(url_for('login'))
|
||||
|
||||
create_and_login_user(profile['name'], profile['email'])
|
||||
|
||||
return redirect(url_for('index'))
|
||||
@@ -1,11 +1,19 @@
|
||||
from flask import jsonify
|
||||
from flask import jsonify, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
from redash import settings
|
||||
from redash.wsgi import app
|
||||
from redash.permissions import require_permission
|
||||
from redash.permissions import require_super_admin
|
||||
from redash.monitor import get_status
|
||||
|
||||
|
||||
def org_scoped_rule(rule):
|
||||
if settings.MULTI_ORG:
|
||||
return "/<org_slug:org_slug>{}".format(rule)
|
||||
|
||||
return rule
|
||||
|
||||
|
||||
@app.route('/ping', methods=['GET'])
|
||||
def ping():
|
||||
return 'PONG.'
|
||||
@@ -13,7 +21,7 @@ def ping():
|
||||
|
||||
@app.route('/status.json')
|
||||
@login_required
|
||||
@require_permission('admin')
|
||||
@require_super_admin
|
||||
def status_api():
|
||||
status = get_status()
|
||||
|
||||
@@ -21,4 +29,4 @@ def status_api():
|
||||
|
||||
|
||||
from redash.handlers import alerts, authentication, base, dashboards, data_sources, events, queries, query_results, \
|
||||
static, users, visualizations, widgets
|
||||
static, users, visualizations, widgets, embed, groups
|
||||
|
||||
@@ -5,26 +5,28 @@ from funcy import project
|
||||
|
||||
from redash import models
|
||||
from redash.wsgi import api
|
||||
from redash.tasks import record_event
|
||||
from redash.handlers.base import BaseResource, require_fields
|
||||
from redash.permissions import require_access, require_admin_or_owner, view_only
|
||||
from redash.handlers.base import BaseResource, require_fields, get_object_or_404
|
||||
|
||||
|
||||
class AlertAPI(BaseResource):
|
||||
class AlertResource(BaseResource):
|
||||
def get(self, alert_id):
|
||||
alert = models.Alert.get_by_id(alert_id)
|
||||
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
|
||||
require_access(alert.groups, self.current_user, view_only)
|
||||
return alert.to_dict()
|
||||
|
||||
def post(self, alert_id):
|
||||
req = request.get_json(True)
|
||||
params = project(req, ('options', 'name', 'query_id'))
|
||||
alert = models.Alert.get_by_id(alert_id)
|
||||
params = project(req, ('options', 'name', 'query_id', 'rearm'))
|
||||
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
|
||||
require_admin_or_owner(alert.user.id)
|
||||
|
||||
if 'query_id' in params:
|
||||
params['query'] = params.pop('query_id')
|
||||
|
||||
alert.update_instance(**params)
|
||||
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
self.record_event({
|
||||
'action': 'edit',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert.id,
|
||||
@@ -34,20 +36,22 @@ class AlertAPI(BaseResource):
|
||||
return alert.to_dict()
|
||||
|
||||
|
||||
class AlertListAPI(BaseResource):
|
||||
class AlertListResource(BaseResource):
|
||||
def post(self):
|
||||
req = request.get_json(True)
|
||||
require_fields(req, ('options', 'name', 'query_id'))
|
||||
|
||||
query = models.Query.get_by_id_and_org(req['query_id'], self.current_org)
|
||||
require_access(query.groups, self.current_user, view_only)
|
||||
|
||||
alert = models.Alert.create(
|
||||
name=req['name'],
|
||||
query=req['query_id'],
|
||||
query=query,
|
||||
user=self.current_user,
|
||||
options=req['options']
|
||||
)
|
||||
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
self.record_event({
|
||||
'action': 'create',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert.id,
|
||||
@@ -57,8 +61,7 @@ class AlertListAPI(BaseResource):
|
||||
# TODO: should be in model?
|
||||
models.AlertSubscription.create(alert=alert, user=self.current_user)
|
||||
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
self.record_event({
|
||||
'action': 'subscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert.id,
|
||||
@@ -68,22 +71,28 @@ class AlertListAPI(BaseResource):
|
||||
return alert.to_dict()
|
||||
|
||||
def get(self):
|
||||
return [alert.to_dict() for alert in models.Alert.all()]
|
||||
return [alert.to_dict() for alert in models.Alert.all(groups=self.current_user.groups)]
|
||||
|
||||
|
||||
class AlertSubscriptionListResource(BaseResource):
|
||||
def post(self, alert_id):
|
||||
alert = models.Alert.get_by_id_and_org(alert_id, self.current_org)
|
||||
require_access(alert.groups, self.current_user, view_only)
|
||||
|
||||
subscription = models.AlertSubscription.create(alert=alert_id, user=self.current_user)
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
self.record_event({
|
||||
'action': 'subscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert_id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
return subscription.to_dict()
|
||||
|
||||
def get(self, alert_id):
|
||||
alert = models.Alert.get_by_id_and_org(alert_id, self.current_org)
|
||||
require_access(alert.groups, self.current_user, view_only)
|
||||
|
||||
subscriptions = models.AlertSubscription.all(alert_id)
|
||||
return [s.to_dict() for s in subscriptions]
|
||||
|
||||
@@ -91,15 +100,16 @@ class AlertSubscriptionListResource(BaseResource):
|
||||
class AlertSubscriptionResource(BaseResource):
|
||||
def delete(self, alert_id, subscriber_id):
|
||||
models.AlertSubscription.unsubscribe(alert_id, subscriber_id)
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
require_admin_or_owner(subscriber_id)
|
||||
|
||||
self.record_event({
|
||||
'action': 'unsubscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert_id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
api.add_resource(AlertAPI, '/api/alerts/<alert_id>', endpoint='alert')
|
||||
api.add_resource(AlertSubscriptionListResource, '/api/alerts/<alert_id>/subscriptions', endpoint='alert_subscriptions')
|
||||
api.add_resource(AlertSubscriptionResource, '/api/alerts/<alert_id>/subscriptions/<subscriber_id>', endpoint='alert_subscription')
|
||||
api.add_resource(AlertListAPI, '/api/alerts', endpoint='alerts')
|
||||
api.add_org_resource(AlertResource, '/api/alerts/<alert_id>', endpoint='alert')
|
||||
api.add_org_resource(AlertSubscriptionListResource, '/api/alerts/<alert_id>/subscriptions', endpoint='alert_subscriptions')
|
||||
api.add_org_resource(AlertSubscriptionResource, '/api/alerts/<alert_id>/subscriptions/<subscriber_id>', endpoint='alert_subscription')
|
||||
api.add_org_resource(AlertListResource, '/api/alerts', endpoint='alerts')
|
||||
|
||||
@@ -1,44 +1,55 @@
|
||||
from flask import render_template, request, redirect, session, url_for, flash
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import current_user, login_user, logout_user
|
||||
|
||||
from redash import models, settings
|
||||
from redash.wsgi import app
|
||||
from redash.handlers import org_scoped_rule
|
||||
from redash.authentication.org_resolving import current_org
|
||||
from redash.authentication.helper import get_login_url
|
||||
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated():
|
||||
return redirect(request.args.get('next') or '/')
|
||||
@app.route(org_scoped_rule('/login'), methods=['GET', 'POST'])
|
||||
def login(org_slug=None):
|
||||
index_url = url_for("index", org_slug=org_slug)
|
||||
next_path = request.args.get('next', index_url)
|
||||
|
||||
if current_user.is_authenticated:
|
||||
return redirect(next_path)
|
||||
|
||||
if not settings.PASSWORD_LOGIN_ENABLED:
|
||||
if settings.SAML_LOGIN_ENABLED:
|
||||
return redirect(url_for("saml_auth.sp_initiated", next=request.args.get('next')))
|
||||
return redirect(url_for("saml_auth.sp_initiated", next=next_path))
|
||||
else:
|
||||
return redirect(url_for("google_oauth.authorize", next=request.args.get('next')))
|
||||
return redirect(url_for("google_oauth.authorize", next=next_path))
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
user = models.User.get_by_email(request.form['email'])
|
||||
user = models.User.get_by_email_and_org(request.form['email'], current_org.id)
|
||||
if user and user.verify_password(request.form['password']):
|
||||
remember = ('remember' in request.form)
|
||||
login_user(user, remember=remember)
|
||||
return redirect(request.args.get('next') or '/')
|
||||
return redirect(next_path)
|
||||
else:
|
||||
flash("Wrong email or password.")
|
||||
except models.User.DoesNotExist:
|
||||
flash("Wrong email or password.")
|
||||
|
||||
if settings.MULTI_ORG:
|
||||
google_auth_url = url_for('google_oauth.authorize_org', next=next_path, org_slug=current_org.slug)
|
||||
else:
|
||||
google_auth_url = url_for('google_oauth.authorize', next=next_path)
|
||||
|
||||
return render_template("login.html",
|
||||
name=settings.NAME,
|
||||
analytics=settings.ANALYTICS,
|
||||
next=request.args.get('next'),
|
||||
next=next_path,
|
||||
username=request.form.get('username', ''),
|
||||
show_google_openid=settings.GOOGLE_OAUTH_ENABLED,
|
||||
google_auth_url=google_auth_url,
|
||||
show_saml_login=settings.SAML_LOGIN_ENABLED)
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
logout_user()
|
||||
session.pop('openid', None)
|
||||
|
||||
return redirect('/login')
|
||||
@app.route(org_scoped_rule('/logout'))
|
||||
def logout(org_slug=None):
|
||||
logout_user()
|
||||
return redirect(get_login_url())
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from flask import request
|
||||
from flask.ext.restful import Resource, abort
|
||||
from flask_login import current_user, login_required
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from redash import statsd_client
|
||||
from redash.authentication.org_resolving import current_org
|
||||
from redash.tasks import record_event
|
||||
|
||||
|
||||
class BaseResource(Resource):
|
||||
@@ -12,14 +13,26 @@ class BaseResource(Resource):
|
||||
super(BaseResource, self).__init__(*args, **kwargs)
|
||||
self._user = None
|
||||
|
||||
def dispatch_request(self, *args, **kwargs):
|
||||
kwargs.pop('org_slug', None)
|
||||
|
||||
return super(BaseResource, self).dispatch_request(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def current_user(self):
|
||||
return current_user._get_current_object()
|
||||
|
||||
def dispatch_request(self, *args, **kwargs):
|
||||
with statsd_client.timer('requests.{}.{}'.format(request.endpoint, request.method.lower())):
|
||||
response = super(BaseResource, self).dispatch_request(*args, **kwargs)
|
||||
return response
|
||||
@property
|
||||
def current_org(self):
|
||||
return current_org._get_current_object()
|
||||
|
||||
def record_event(self, options):
|
||||
options.update({
|
||||
'user_id': self.current_user.id,
|
||||
'org_id': self.current_org.id
|
||||
})
|
||||
|
||||
record_event.delay(options)
|
||||
|
||||
|
||||
def require_fields(req, fields):
|
||||
@@ -27,3 +40,9 @@ def require_fields(req, fields):
|
||||
if f not in req:
|
||||
abort(400)
|
||||
|
||||
|
||||
def get_object_or_404(fn, *args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except DoesNotExist:
|
||||
abort(404)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from flask import request
|
||||
from flask.ext.restful import abort
|
||||
from flask_login import current_user
|
||||
|
||||
from funcy import distinct, take
|
||||
from itertools import chain
|
||||
@@ -8,24 +6,23 @@ from itertools import chain
|
||||
from redash import models
|
||||
from redash.wsgi import api
|
||||
from redash.permissions import require_permission
|
||||
from redash.handlers.base import BaseResource
|
||||
from redash.handlers.base import BaseResource, get_object_or_404
|
||||
|
||||
|
||||
class DashboardRecentAPI(BaseResource):
|
||||
def get(self):
|
||||
recent = [d.to_dict() for d in models.Dashboard.recent(current_user.id)]
|
||||
recent = [d.to_dict() for d in models.Dashboard.recent(self.current_org, self.current_user.id)]
|
||||
|
||||
global_recent = []
|
||||
if len(recent) < 10:
|
||||
global_recent = [d.to_dict() for d in models.Dashboard.recent()]
|
||||
global_recent = [d.to_dict() for d in models.Dashboard.recent(self.current_org)]
|
||||
|
||||
return take(20, distinct(chain(recent, global_recent), key=lambda d: d['id']))
|
||||
|
||||
|
||||
class DashboardListAPI(BaseResource):
|
||||
def get(self):
|
||||
dashboards = [d.to_dict() for d in
|
||||
models.Dashboard.select().where(models.Dashboard.is_archived==False)]
|
||||
dashboards = [d.to_dict() for d in models.Dashboard.all(self.current_org)]
|
||||
|
||||
return dashboards
|
||||
|
||||
@@ -33,6 +30,7 @@ class DashboardListAPI(BaseResource):
|
||||
def post(self):
|
||||
dashboard_properties = request.get_json(force=True)
|
||||
dashboard = models.Dashboard(name=dashboard_properties['name'],
|
||||
org=self.current_org,
|
||||
user=self.current_user,
|
||||
layout='[]')
|
||||
dashboard.save()
|
||||
@@ -41,33 +39,30 @@ class DashboardListAPI(BaseResource):
|
||||
|
||||
class DashboardAPI(BaseResource):
|
||||
def get(self, dashboard_slug=None):
|
||||
try:
|
||||
dashboard = models.Dashboard.get_by_slug(dashboard_slug)
|
||||
except models.Dashboard.DoesNotExist:
|
||||
abort(404)
|
||||
dashboard = get_object_or_404(models.Dashboard.get_by_slug_and_org, dashboard_slug, self.current_org)
|
||||
|
||||
return dashboard.to_dict(with_widgets=True)
|
||||
return dashboard.to_dict(with_widgets=True, user=self.current_user)
|
||||
|
||||
@require_permission('edit_dashboard')
|
||||
def post(self, dashboard_slug):
|
||||
dashboard_properties = request.get_json(force=True)
|
||||
# TODO: either convert all requests to use slugs or ids
|
||||
dashboard = models.Dashboard.get_by_id(dashboard_slug)
|
||||
dashboard = models.Dashboard.get_by_id_and_org(dashboard_slug, self.current_org)
|
||||
dashboard.layout = dashboard_properties['layout']
|
||||
dashboard.name = dashboard_properties['name']
|
||||
dashboard.save()
|
||||
|
||||
return dashboard.to_dict(with_widgets=True)
|
||||
return dashboard.to_dict(with_widgets=True, user=self.current_user)
|
||||
|
||||
@require_permission('edit_dashboard')
|
||||
def delete(self, dashboard_slug):
|
||||
dashboard = models.Dashboard.get_by_slug(dashboard_slug)
|
||||
dashboard = models.Dashboard.get_by_slug_and_org(dashboard_slug, self.current_org)
|
||||
dashboard.is_archived = True
|
||||
dashboard.save()
|
||||
|
||||
return dashboard.to_dict(with_widgets=True)
|
||||
return dashboard.to_dict(with_widgets=True, user=self.current_user)
|
||||
|
||||
api.add_resource(DashboardListAPI, '/api/dashboards', endpoint='dashboards')
|
||||
api.add_resource(DashboardRecentAPI, '/api/dashboards/recent', endpoint='recent_dashboards')
|
||||
api.add_resource(DashboardAPI, '/api/dashboards/<dashboard_slug>', endpoint='dashboard')
|
||||
api.add_org_resource(DashboardListAPI, '/api/dashboards', endpoint='dashboards')
|
||||
api.add_org_resource(DashboardRecentAPI, '/api/dashboards/recent', endpoint='recent_dashboards')
|
||||
api.add_org_resource(DashboardAPI, '/api/dashboards/<dashboard_slug>', endpoint='dashboard')
|
||||
|
||||
|
||||
@@ -2,31 +2,32 @@ import json
|
||||
|
||||
from flask import make_response, request
|
||||
from flask.ext.restful import abort
|
||||
from funcy import project
|
||||
|
||||
from redash import models
|
||||
from redash.wsgi import api
|
||||
from redash.permissions import require_permission
|
||||
from redash.permissions import require_admin
|
||||
from redash.query_runner import query_runners, validate_configuration
|
||||
from redash.handlers.base import BaseResource
|
||||
from redash.handlers.base import BaseResource, get_object_or_404
|
||||
|
||||
|
||||
class DataSourceTypeListAPI(BaseResource):
|
||||
@require_permission("admin")
|
||||
@require_admin
|
||||
def get(self):
|
||||
return [q.to_dict() for q in query_runners.values()]
|
||||
|
||||
api.add_resource(DataSourceTypeListAPI, '/api/data_sources/types', endpoint='data_source_types')
|
||||
api.add_org_resource(DataSourceTypeListAPI, '/api/data_sources/types', endpoint='data_source_types')
|
||||
|
||||
|
||||
class DataSourceAPI(BaseResource):
|
||||
@require_permission('admin')
|
||||
@require_admin
|
||||
def get(self, data_source_id):
|
||||
data_source = models.DataSource.get_by_id(data_source_id)
|
||||
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
|
||||
return data_source.to_dict(all=True)
|
||||
|
||||
@require_permission('admin')
|
||||
@require_admin
|
||||
def post(self, data_source_id):
|
||||
data_source = models.DataSource.get_by_id(data_source_id)
|
||||
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
|
||||
req = request.get_json(True)
|
||||
|
||||
data_source.replace_secret_placeholders(req['options'])
|
||||
@@ -41,9 +42,9 @@ class DataSourceAPI(BaseResource):
|
||||
|
||||
return data_source.to_dict(all=True)
|
||||
|
||||
@require_permission('admin')
|
||||
@require_admin
|
||||
def delete(self, data_source_id):
|
||||
data_source = models.DataSource.get_by_id(data_source_id)
|
||||
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
|
||||
data_source.delete_instance(recursive=True)
|
||||
|
||||
return make_response('', 204)
|
||||
@@ -51,10 +52,23 @@ class DataSourceAPI(BaseResource):
|
||||
|
||||
class DataSourceListAPI(BaseResource):
|
||||
def get(self):
|
||||
data_sources = [ds.to_dict() for ds in models.DataSource.all()]
|
||||
return data_sources
|
||||
if self.current_user.has_permission('admin'):
|
||||
data_sources = models.DataSource.all(self.current_org)
|
||||
else:
|
||||
data_sources = models.DataSource.all(self.current_org, groups=self.current_user.groups)
|
||||
|
||||
@require_permission("admin")
|
||||
response = {}
|
||||
for ds in data_sources:
|
||||
if ds.id in response:
|
||||
continue
|
||||
|
||||
d = ds.to_dict()
|
||||
d['view_only'] = all(project(ds.groups, self.current_user.groups).values())
|
||||
response[ds.id] = d
|
||||
|
||||
return response.values()
|
||||
|
||||
@require_admin
|
||||
def post(self):
|
||||
req = request.get_json(True)
|
||||
required_fields = ('options', 'name', 'type')
|
||||
@@ -65,19 +79,21 @@ class DataSourceListAPI(BaseResource):
|
||||
if not validate_configuration(req['type'], req['options']):
|
||||
abort(400)
|
||||
|
||||
datasource = models.DataSource.create(name=req['name'], type=req['type'], options=json.dumps(req['options']))
|
||||
datasource = models.DataSource.create_with_group(org=self.current_org,
|
||||
name=req['name'],
|
||||
type=req['type'], options=json.dumps(req['options']))
|
||||
|
||||
return datasource.to_dict(all=True)
|
||||
|
||||
api.add_resource(DataSourceListAPI, '/api/data_sources', endpoint='data_sources')
|
||||
api.add_resource(DataSourceAPI, '/api/data_sources/<data_source_id>', endpoint='data_source')
|
||||
api.add_org_resource(DataSourceListAPI, '/api/data_sources', endpoint='data_sources')
|
||||
api.add_org_resource(DataSourceAPI, '/api/data_sources/<data_source_id>', endpoint='data_source')
|
||||
|
||||
|
||||
class DataSourceSchemaAPI(BaseResource):
|
||||
def get(self, data_source_id):
|
||||
data_source = models.DataSource.get_by_id(data_source_id)
|
||||
data_source = get_object_or_404(models.DataSource.get_by_id_and_org, data_source_id, self.current_org)
|
||||
schema = data_source.get_schema()
|
||||
|
||||
return schema
|
||||
|
||||
api.add_resource(DataSourceSchemaAPI, '/api/data_sources/<data_source_id>/schema')
|
||||
api.add_org_resource(DataSourceSchemaAPI, '/api/data_sources/<data_source_id>/schema')
|
||||
|
||||
39
redash/handlers/embed.py
Normal file
39
redash/handlers/embed.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from flask import render_template
|
||||
from flask.ext.restful import abort
|
||||
|
||||
from flask_login import login_required
|
||||
|
||||
from redash import models, settings
|
||||
from redash.wsgi import app
|
||||
from redash.utils import json_dumps
|
||||
from redash.handlers import org_scoped_rule
|
||||
from redash.authentication.org_resolving import current_org
|
||||
|
||||
|
||||
@app.route(org_scoped_rule('/embed/query/<query_id>/visualization/<visualization_id>'), methods=['GET'])
|
||||
@login_required
|
||||
def embed(query_id, visualization_id, org_slug=None):
|
||||
# TODO: add event for embed access
|
||||
query = models.Query.get_by_id_and_org(query_id, current_org)
|
||||
vis = query.visualizations.where(models.Visualization.id == visualization_id).first()
|
||||
qr = {}
|
||||
|
||||
if vis is not None:
|
||||
vis = vis.to_dict()
|
||||
qr = query.latest_query_data
|
||||
if qr is None:
|
||||
abort(400, message="No Results for this query")
|
||||
else:
|
||||
qr = qr.to_dict()
|
||||
else:
|
||||
abort(404, message="Visualization not found.")
|
||||
|
||||
client_config = {}
|
||||
client_config.update(settings.COMMON_CLIENT_CONFIG)
|
||||
|
||||
return render_template("embed.html",
|
||||
name=settings.NAME,
|
||||
client_config=json_dumps(client_config),
|
||||
visualization=json_dumps(vis),
|
||||
query_result=json_dumps(qr),
|
||||
analytics=settings.ANALYTICS)
|
||||
@@ -2,7 +2,6 @@ from flask import request
|
||||
|
||||
from redash import statsd_client
|
||||
from redash.wsgi import api
|
||||
from redash.tasks import record_event
|
||||
from redash.handlers.base import BaseResource
|
||||
|
||||
|
||||
@@ -10,10 +9,10 @@ class EventAPI(BaseResource):
|
||||
def post(self):
|
||||
events_list = request.get_json(force=True)
|
||||
for event in events_list:
|
||||
record_event.delay(event)
|
||||
self.record_event(event)
|
||||
|
||||
|
||||
api.add_resource(EventAPI, '/api/events', endpoint='events')
|
||||
api.add_org_resource(EventAPI, '/api/events', endpoint='events')
|
||||
|
||||
|
||||
class MetricsAPI(BaseResource):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user