Compare commits

...

251 Commits

Author SHA1 Message Date
Arik Fraimovich
14ea79b1e9 Fix build configuration 2016-08-02 14:02:30 +03:00
Arik Fraimovich
432fe9b8a3 Update version 2016-08-02 13:59:06 +03:00
Arik Fraimovich
33909a1b32 Skip email sending if there are no recipients 2016-08-02 13:56:39 +03:00
Arik Fraimovich
9bca3933e7 Fix #1212: email alerts not being sent 2016-08-02 13:56:22 +03:00
Arik Fraimovich
e1dd1b3f71 Update cirlce.yml to build release_* branches 2016-08-02 13:51:05 +03:00
Arik Fraimovich
bba801f9d5 Merge pull request #1167 from ariarijp/fix-bootstrap-script-for-ubuntu
Fix the version of setuptools on bootstrap script for Ubuntu
2016-07-03 14:17:03 +03:00
Arik Fraimovich
b41041014f Merge pull request #1166 from getredash/refersh_14_days
Feature: add "every 14 days" refresh option
2016-07-03 14:14:34 +03:00
Arik Fraimovich
31edf9cf80 Add every 14 days refresh option 2016-07-03 14:12:09 +03:00
Takuya Arita
522e07ac95 Fix the version of setuptools on bootstrap script for Ubuntu 2016-07-03 20:10:01 +09:00
Arik Fraimovich
837073144f Dockerfile: pin setuptools version to 23.1.0 until they resolve bug introduced in 24.0 2016-07-03 13:39:38 +03:00
Arik Fraimovich
9895e28a3f Cleanup/fix script tags. 2016-07-03 13:31:59 +03:00
Arik Fraimovich
ae9e295d2f Fix #1165: don't use hard coded org/user. 2016-07-03 13:26:54 +03:00
Arik Fraimovich
7681d3ee84 Merge pull request #1164 from darabos/patch-1
Docs: update permission documentation
2016-07-01 08:30:15 +03:00
Daniel Darabos
458f5eb032 Update permission documentation
https://github.com/getredash/redash/pull/957 has been merged.
2016-06-30 13:22:55 +02:00
Arik Fraimovich
ce81d69f91 Merge pull request #1160 from 5t111111/fix-indentation-in-docker-compose-example
Fix indentation in docker-compose-example.yml
2016-06-28 16:12:07 +03:00
Hirofumi Wakasugi
0456caf798 Fix indentation in docker-compose-example.yml 2016-06-28 22:05:23 +09:00
Arik Fraimovich
bcd3670282 Merge pull request #1156 from jcox92/patch-1
Docs: add SSL parameters to nginx configuration
2016-06-26 08:56:19 +03:00
Josh Cox
f7e556969a Add SSL parameters to nginx configuration
These parameters prevent some common exploits and vulnerabilities. My primary reference was here: https://raymii.org/s/tutorials/Strong_SSL_Security_On_nginx.html
2016-06-23 15:24:17 -04:00
Arik Fraimovich
dd759fe4b0 Merge pull request #1155 from getredash/random
Fix: make all configuration values of Oracle required
2016-06-23 21:24:09 +03:00
Arik Fraimovich
988b301f65 Fix: make all configuration values of Oracle required 2016-06-23 21:21:04 +03:00
Arik Fraimovich
923b3b18e4 Merge pull request #1152 from getredash/random
Fix: add missing paddings in titles
2016-06-22 16:08:45 +03:00
Arik Fraimovich
95c47138ab Fix: add missing paddings in titles 2016-06-22 14:12:27 +03:00
Arik Fraimovich
31e7375a30 Merge pull request #1150 from getredash/random
UI Fixes for v0.11
2016-06-21 14:07:50 +03:00
Arik Fraimovich
8808e38de9 Fix: shared dashboard fail to render queries. 2016-06-21 11:56:02 +03:00
Arik Fraimovich
0314313285 Merge pull request #1149 from toru-takahashi/patch-2
Presto QueryRunner supports tinyint and smallint
2016-06-21 11:50:32 +03:00
Toru Takahashi
9c0d1da7f9 Presto QueryRunner supports tinyint and smallint
Presto0.148 started to support tinyint and smallint.
https://prestodb.io/docs/current/release/release-0.148.html
2016-06-21 17:36:23 +09:00
Arik Fraimovich
904ea9f90a Fix: after creating new alert, it uses wrong id to load subscribers 2016-06-20 19:36:27 +03:00
Arik Fraimovich
6bb09d8446 Merge pull request #1146 from someones/doc-add-settings
Docs: add a link to settings documentation.
2016-06-20 09:31:50 +03:00
Arik Fraimovich
d5e5b2438b Merge pull request #1136 from someones/org-aware-cli
Feature: add --org option to all relevant CLI commands.
2016-06-20 09:27:38 +03:00
Arik Fraimovich
dbd48e15bc Merge pull request #1143 from ariarijp/fix/email-input
Fix: use the email input type where needed
2016-06-20 08:56:43 +03:00
Takuya Arita
21fdd6b69d Revert input type 2016-06-20 14:05:51 +09:00
Adam Griffiths
a666adeaa7 Add a link to settings.
The settings page doesn't seem to have any links to it.
The only way to find it is through searching for it.
So we'll add one to the setup.rst page.
2016-06-20 11:05:28 +10:00
Adam Griffiths
15361cc81c Improve the help text text
Add "(leave blank for 'default')." or
"(leave blank for all organizations)" to organization options.
Remove the text "This commands assume single organization operation."
from the `users` and `groups` managers help text.
2016-06-20 09:56:05 +10:00
Adam Griffiths
704a167c74 Fix organization parameters.
Move organization to the end of the parameter
list to prevent breaking any existing code.
Provide a default for all organization parameters
to allow people to call the CLI functions without
breaking existing code.
Add missing organization parameter to `list` in
`users.py`.
2016-06-20 09:49:37 +10:00
ariarijp
df2c8d83b0 Use the email input type 2016-06-19 20:48:01 +09:00
Arik Fraimovich
7445972c10 Merge pull request #1142 from getredash/random
Fix: dates in filters might be duplicated
2016-06-19 08:54:02 +03:00
Arik Fraimovich
1933995a28 Fix: dates in filters might be duplicated 2016-06-19 08:51:45 +03:00
Arik Fraimovich
8df822eee2 Merge pull request #1141 from getredash/random
Make create data source button more prominent
2016-06-17 18:10:28 +03:00
Arik Fraimovich
227fe9b44a Make create data source button more prominent 2016-06-17 18:07:48 +03:00
Arik Fraimovich
5d0ed02caa Merge pull request #1140 from getredash/random
Fix: Hive should use the enabled variable
2016-06-17 18:06:54 +03:00
Arik Fraimovich
392627d6d6 Merge pull request #1139 from getredash/random
Fix: Impala data source referencing wrong variable
2016-06-17 17:53:30 +03:00
Arik Fraimovich
72d02e9e9d Fix: Hive should use the enabled variable 2016-06-17 17:50:26 +03:00
Arik Fraimovich
902ce24f6f Fix: Impala data source referencing wrong variable 2016-06-17 17:49:34 +03:00
Arik Fraimovich
c5bfbbaef7 Merge pull request #1133 from vishesh92/fix-scroll
Fix: query scrolling issues
2016-06-17 17:43:04 +03:00
Arik Fraimovich
a03f5f88fb Limit Celery concurrency 2016-06-17 10:19:33 +03:00
Adam Griffiths
a66e182f73 Add --org option to all relevant CLI commands.
--org is the organization slug, not the name.
Allows the management of users, datasources and groups
with respect to organisations.
All commands default to 'default' slug, or None where
relevant, which means the commands will still work
as they did before without any changes.
2016-06-17 12:03:09 +10:00
Arik Fraimovich
96dd811607 Merge pull request #1127 from someones/schema_secrets
Mark basic_auth_password as secret
2016-06-16 21:24:16 +03:00
Vishesh Jindal
409200188e Fix query scrolling #1024 2016-06-16 17:40:48 +05:30
Arik Fraimovich
ad65391914 Merge pull request #1130 from AntoineAugusti/patch-1
Improve Slack notification style
2016-06-15 17:22:31 +03:00
Antoine Augusti
203f6afa09 Improve Slack notification style 2016-06-15 15:56:04 +02:00
Arik Fraimovich
2b710420ab Docs: start worker process on Heroku 2016-06-15 16:17:11 +03:00
Arik Fraimovich
01116f41ed Don't auto reload/run in debug mode on Heroku 2016-06-15 16:17:11 +03:00
Arik Fraimovich
87e25f2107 Merge pull request #1129 from getredash/fix-viz-options
Feature: support for JSON query formatting (Mongo, ElasticSearch)
2016-06-15 15:21:41 +03:00
Arik Fraimovich
c495250a54 Feature: support for JSON query formatting (Mongo, ElasticSearch) 2016-06-15 15:06:50 +03:00
Arik Fraimovich
c01d266030 Merge pull request #1128 from getredash/fix-viz-options
Fix: visualization options not updating after changing type
2016-06-15 12:49:43 +03:00
Arik Fraimovich
8515ac25bc Fix: visualization options not updating after changing type 2016-06-15 12:42:18 +03:00
Adam Griffiths
23988a72aa Mark basic_auth_password as secret 2016-06-15 11:34:02 +10:00
Arik Fraimovich
6bc0e7a716 Merge pull request #1126 from getredash/fix_669
Fix #669: save fails when doing partial save of new query
2016-06-14 15:39:58 +03:00
Arik Fraimovich
2c2ff0d252 Fix #669: save fails when doing partial save of new query 2016-06-14 15:39:12 +03:00
Arik Fraimovich
69cefee0d4 Update Heroku instructions. 2016-06-14 14:36:41 +03:00
Arik Fraimovich
02c065751a Update Heroku pre_compile hook 2016-06-14 14:23:56 +03:00
Arik Fraimovich
aed65f4bad Deduplicate parameter names 2016-06-14 14:00:38 +03:00
Arik Fraimovich
6bb2716fe3 Merge pull request #1121 from getredash/fix_949
Show error when failing to communicate with server
2016-06-14 13:18:06 +03:00
Arik Fraimovich
efaeb08178 Merge pull request #1120 from getredash/feature/params_ui
Fix: default vale for parameters should be [] and not {}
2016-06-14 13:17:21 +03:00
Arik Fraimovich
e18a073128 Show error when failing to communicate with server
Closes #949.
2016-06-14 12:12:31 +03:00
Arik Fraimovich
f21276ec06 Merge pull request #1119 from getredash/feature/params_ui
Feature: add UI to delete alerts
2016-06-14 12:00:53 +03:00
Arik Fraimovich
b0c0582e41 Fix: default vale for parameters should be [] and not {} 2016-06-14 11:59:11 +03:00
Arik Fraimovich
9ad85091ed Add UI to delete alerts (closes #731) 2016-06-14 11:55:00 +03:00
Arik Fraimovich
2d2fb69b7b Add API to delete alerts (#731). 2016-06-14 11:21:05 +03:00
Arik Fraimovich
3ce27b9652 Merge pull request #1118 from getredash/feature/params_ui
Fix: remove alerts for archived queries
2016-06-14 11:15:50 +03:00
Arik Fraimovich
da4db94cf8 Close #930: remove alerts for archived queries 2016-06-14 11:09:35 +03:00
Arik Fraimovich
4cbc79a7aa Use default user for alert_subscription factory 2016-06-14 11:04:41 +03:00
Arik Fraimovich
4fabaaea8a Merge pull request #1117 from getredash/feature/params_ui
Fix #1052: filter not working for date/time values
2016-06-14 11:02:02 +03:00
Arik Fraimovich
a7af596da0 Fix #1052: filter not working for date/time values 2016-06-14 10:58:33 +03:00
Arik Fraimovich
df637e3f6b Merge pull request #1116 from getredash/feature/params_ui
Return meaningful error when there is no cached result.
2016-06-14 10:44:24 +03:00
Arik Fraimovich
68465b0c60 Return meaningful error when there is no cached result.
Previously it was crashing as it was trying to access an unreferenced
variables (query_result).
2016-06-14 10:41:01 +03:00
Arik Fraimovich
86565402fa Merge pull request #1069 from getredash/feature/params_ui
Feature: UI for query parameters
2016-06-14 10:15:36 +03:00
Arik Fraimovich
c2e3637dce Feature: UI for query parameters
This pull request implements UI for parameters and also allows to set the default value and type of a parameter.
(Closes #583)

Other changes in this pull request:

- Loading/error state for dashboard widgets.
- Refresh button on dashboard widgets (Closes #810).
- Maintain sync between query/dashboard URL and current parameters, and preserve them when navigating.
- Removed Pivot Table tab.
2016-06-14 10:09:16 +03:00
Arik Fraimovich
52558043ee Merge pull request #1091 from whummer/feature/cache_embeds
Add caching for queries used in embeds
2016-06-14 08:34:13 +03:00
Waldemar Hummer
a045d7ddf7 simplify code to get parameters 2016-06-14 09:36:32 +10:00
Arik Fraimovich
c107c94a27 Merge pull request #1077 from nabilblk/master
Fix: install needed dependencies to use Hive in Docker image
2016-06-13 15:22:53 +03:00
Arik Fraimovich
790128ce77 Merge pull request #1067 from anthony-coble/add_word_cloud
Feature: word cloud visualization
2016-06-13 13:40:02 +03:00
Arik Fraimovich
abc790ce41 Merge pull request #1115 from getredash/fix-1097
Fix: allow non integers in alert reference value
2016-06-13 13:13:27 +03:00
Arik Fraimovich
f2643521f7 Fix: allow non integers in alert reference value 2016-06-13 13:03:08 +03:00
nabil
0d897e6878 move hive depencencies into req_all_ds.txt 2016-06-09 22:29:46 +00:00
Arik Fraimovich
4ec473cf5e Merge pull request #1110 from getredash/fix-1097
Fix #1109: mixed group permissions resulting in wrong permission
2016-06-09 20:02:06 +03:00
Arik Fraimovich
0c7f0c25a8 Fix #1109: mixed group permissions resulting in wrong permission 2016-06-09 19:59:26 +03:00
Arik Fraimovich
8c21e9149d Merge pull request #1108 from getredash/fix-1097
Remove potnetially concurrency not safe code form enqueue_query
2016-06-09 17:08:53 +03:00
Arik Fraimovich
7159f0beb0 Remove potnetially concurrency not safe code form enqueue_query.
This might have been causing the behavior described in #1097.
2016-06-09 16:53:29 +03:00
Arik Fraimovich
095e7596b5 Merge pull request #1103 from AntoineAugusti/patch-1
Docs: add section about monitoring
2016-06-09 16:32:24 +03:00
Arik Fraimovich
31013836ea Fix path reference in embed.html. 2016-06-09 11:06:52 +03:00
Arik Fraimovich
b67f412f58 Add test for enqueue_query 2016-06-08 20:00:59 +03:00
Antoine Augusti
c1bf9dc67d Add section about monitoring 2016-06-08 15:47:00 +02:00
Arik Fraimovich
65635ec703 Merge pull request #1102 from getredash/feature/hipchat_v2
Switch to HipChat V2 API
2016-06-08 16:17:10 +03:00
Arik Fraimovich
ceaa00e448 Fix HipChat base URL 2016-06-08 16:09:06 +03:00
Arik Fraimovich
679b0a3125 Switch to HipChat V2. 2016-06-08 16:04:28 +03:00
Arik Fraimovich
fe81dbd3a2 Fix paths in Gulp build pipeline 2016-06-08 10:00:05 +03:00
Arik Fraimovich
1409907ef1 WIP: gulp fix 2016-06-08 09:32:14 +03:00
Arik Fraimovich
cbbfc4e931 Update bower.json 2016-06-08 08:57:51 +03:00
Arik Fraimovich
1ca5262fa8 Merge pull request #1099 from someones/heroku-clean
Fix RST syntax for links
2016-06-08 08:14:29 +03:00
Adam Griffiths
429b76f5a7 Fix RST syntax for links 2016-06-08 15:01:03 +10:00
Arik Fraimovich
8b73a2b135 Merge pull request #1098 from getredash/flexible_notifications
Feature: UI for alert destinations & new destination types
2016-06-07 15:18:11 +03:00
Arik Fraimovich
eed5485080 Update Alerts/subscriptions UI for new look and feel. 2016-06-07 15:12:47 +03:00
Arik Fraimovich
daa6c1cd6f Merge pull request #1096 from AntoineAugusti/patch-2
Fix typo in env variable VERSION_CHECK
2016-06-07 11:01:00 +03:00
Antoine Augusti
68dc3b033c Fix typo in env variable VERSION_CHECK 2016-06-07 09:58:49 +02:00
Anthony Coble
2e88e7f396 fixup! Add a word cloud vis 2016-06-06 17:49:31 -04:00
Arik Fraimovich
cd06d276e4 Merge pull request #1095 from ordd/fix_new_query_permission
Fix: use create_query permission for new query button.
2016-06-06 17:25:28 +03:00
Or
437f589fde Fix: use create_query permission for new query button. 2016-06-06 12:27:08 +03:00
Arik Fraimovich
1fbeb5d2a5 Merge pull request #1092 from someones/heroku-clean
Add Heroku support
2016-06-06 09:24:27 +03:00
Arik Fraimovich
df1e72ca01 Take into account that node_modules moves to root 2016-06-06 09:24:13 +03:00
Arik Fraimovich
fcc656e04e Add support for REDIS_URL and DATABASE_URL in settings.
(preparation for Heroku support - #1092)
2016-06-06 09:22:33 +03:00
Adam Griffiths
a0b97c1fc9 Update Heroku support as per comments
Fix comment in bin/pre_compile.
Remove .gitattributes and .travis.yml in rd_ui/.
Remove bin/run from Procfile.heroku.
Update documentation:
-Add a note about upgrading from version to version.
-Remove commands for DATABASE_URL and REDIS_URL.
-Add importance to the cookie secret variable.
-Merge adding redis and postgres addons into 1 step.
2016-06-06 11:39:10 +10:00
Arik Fraimovich
4d6599e0ea WIP 2016-06-05 15:51:49 +03:00
Adam Griffiths
c75054b320 Add Heroku support
Move .bowerrc, bower.json, gulpfile.js, package.json
down to root level.
Update paths in .bowerrc, gulpfile.js, Makefile
Add a heroku-postbuild step to package.json which
installs devDependencies and runs the build.
Add step in bin/pre_compile which adds the
requirements_all_ds.txt to requirements.txt to ensure that
cffi is installed. Also removes pymssql as this is
not supported on Heroku.
Add content from rd_ui/.gitignore to .gitignore and
remove rd_ui/.gitignore.
Add section in setup.rst about Heroku deployments.
2016-06-03 12:21:09 +10:00
Waldemar Hummer
011ca74338 add caching for queries used in embeds 2016-06-03 09:14:59 +10:00
Arik Fraimovich
434615a1be Merge remote-tracking branch 'origin/master' into flexible_notifications 2016-06-02 10:21:52 +03:00
Arik Fraimovich
2bc0b276b5 Merge pull request #1061 from thoughtworks/fix/area_plots
Fix: area chart stacking doesn't work
2016-06-01 21:21:18 +03:00
Arik Fraimovich
e942486ed7 Merge pull request #1090 from getredash/contributing_guide
Contributing Guide
2016-06-01 15:56:36 +03:00
Arik Fraimovich
9eff7ef8c9 Update CONTRIBUTING.md 2016-06-01 15:55:50 +03:00
Arik Fraimovich
34b305d232 Update CONTRIBUTING.md 2016-06-01 15:52:52 +03:00
Arik Fraimovich
f0d97bc5d1 Update CONTRIBUTING.md 2016-06-01 15:30:04 +03:00
Arik Fraimovich
f64622db77 Merge pull request #1089 from James226/master
Add support for serialising UUID type within MSSQL #961
2016-06-01 15:14:59 +03:00
Arik Fraimovich
8030baa6a5 Create "Contributing Guide" 2016-06-01 14:55:11 +03:00
Raymond Machira
3d82b702b3 Merge branch 'upstream/master' into fix/area_plots
# Conflicts:
#	rd_ui/app/scripts/directives/plotly.js
2016-05-31 10:50:05 -05:00
Arik Fraimovich
ad8676df2e Merge pull request #1087 from getredash/fix/chart_editor
Maintain fixed size of chart area
2016-05-31 11:29:37 +03:00
Arik Fraimovich
ea031e9a98 Maintain fixed size of chart area 2016-05-31 11:27:01 +03:00
Arik Fraimovich
9cfebedec9 Merge pull request #1086 from getredash/fix/chart_editor
Give bottom margin setting more meaningful name & change to input type to number.
2016-05-31 10:36:24 +03:00
Arik Fraimovich
772d263827 Give bottom margin setting more meaningful name & change to input to number. 2016-05-31 10:27:28 +03:00
Arik Fraimovich
8c455c8a1c Update login page title 2016-05-31 09:46:29 +03:00
Arik Fraimovich
857caab20e Merge pull request #1085 from getredash/feature/pause-api
Feature: API to pause a data source
2016-05-31 09:08:03 +03:00
Arik Fraimovich
59f8af2c44 Switch to Redis for pause state storage 2016-05-30 22:44:09 +03:00
Arik Fraimovich
9538ee7c31 Feature: API to pause a data source 2016-05-30 18:30:05 +03:00
Arik Fraimovich
e8312185dc Merge pull request #1084 from getredash/fix_dup_alerts
Fix #1049: duplicate alerts when data source belongs to multiple groups
2016-05-30 14:42:05 +03:00
Arik Fraimovich
07d2b5ba42 Fix #1049: duplicate alerts 2016-05-30 14:39:58 +03:00
Arik Fraimovich
f8120284d5 WIP: updated look and feel 2016-05-30 14:39:01 +03:00
James Parker
5b654fd1c8 Add support for serialising UUID type within MSSQL #961 2016-05-27 09:22:38 +01:00
Arik Fraimovich
6edb0ca8ec Merge pull request #1080 from jeffwidman/patch-1
Fix typo
2016-05-27 08:42:21 +03:00
Jeff Widman
ef0de1414d Fix typo 2016-05-26 14:29:40 -07:00
Arik Fraimovich
214aa3b799 Merge pull request #1060 from thoughtworks/saml-authorization
Feature: support configuring user's groups with SAML
2016-05-26 23:07:30 +03:00
Arik Fraimovich
64d7538040 Merge pull request #1074 from toyama0919/master
Fix: ElasticSearch wasn't using correct type names
2016-05-26 22:57:32 +03:00
Kumar Vora
69177752bc addresses PR feedback! 2016-05-26 14:46:25 -05:00
Kumar Vora
d83c6c42dd Raymond/Kumar : Fixes issue where going into edit more would break stacking. - selects legend divs from current graph only (instead of selecting from the entire dom) 2016-05-26 14:17:19 -05:00
nabil
2043834ae9 Issue #1076 : addtional dependencies for Hive Datasource 2016-05-26 10:38:07 +01:00
toyama0919
d6f4af448c fix bug. There is a case set unsupport type for elasticsearch. 2016-05-25 17:50:13 +09:00
Raymond Machira
43b425f91c Merge branch 'get-redash-master' of github.com:thoughtworks/redash into fix/area_plots 2016-05-24 18:11:13 -05:00
Raymond Machira
17427cf47b Names the legend item click listener to avoid overriding other places the event may be used. Also removes the event listener when type of chart changes. 2016-05-24 17:54:50 -05:00
Kumar Vora
b5be5a8fa4 no need to check count of results 2016-05-24 16:38:41 -05:00
Arik Fraimovich
14fcf01751 Merge pull request #1072 from getredash/arikfr-patch-2
Remove counter from the tasks Done tab (as it always shows 50). #1047
2016-05-23 21:24:01 +03:00
Arik Fraimovich
09848d65a1 Remove counter from the tasks Done tab (as it always shows 50). #1047 2016-05-23 18:32:59 +03:00
Arik Fraimovich
0d897ea959 Update AMIs to 0.10.1 2016-05-22 13:35:07 +03:00
Arik Fraimovich
e88d4c3d27 Remove reference to Google Groups and add Discourse instead 2016-05-22 10:33:46 +03:00
Arik Fraimovich
82f0b4c386 Update version reference in bootstrap scripts. 2016-05-22 10:33:46 +03:00
Arik Fraimovich
3037c4f90d Merge pull request #1066 from edwardsharp/edwardsharp-fix-1057
Docs: command type-o fix.
2016-05-22 08:59:12 +03:00
Kumar Vora
8900d02c95 fixing test 2016-05-20 14:35:49 -05:00
Kumar Vora
c1c2db4a73 use user.org instead of passing org as a separate argument 2016-05-20 14:28:08 -05:00
Anthony Coble
574d8a18ae Add a word cloud vis 2016-05-19 16:19:08 -04:00
edward sharp
82872db111 command type-o in setup docs. fix #1057 2016-05-19 12:57:18 -07:00
Arik Fraimovich
3f90dd9247 Merge pull request #1064 from getredash/fix/keys_cleanup
Fix: old task trackers were not really removed
2016-05-19 10:08:58 +03:00
Arik Fraimovich
b2e2277d0b Fix: old task trackers were not really removed 2016-05-19 09:58:30 +03:00
Arik Fraimovich
e20a00566a Merge pull request #1007 from vorakumar/issue-1006
Issue#1006:  Make bottom margin editable for Chart visualization
2016-05-18 23:18:12 +03:00
Arik Fraimovich
e10ecd2dad Merge pull request #1058 from AntoineAugusti/patch-1
Bring back filters if dashboard filters are enabled
2016-05-18 22:50:31 +03:00
Arik Fraimovich
6e0dd2b9a3 Merge pull request #1063 from windward-ltd/master
Add support for date/time Y axis
2016-05-18 22:50:13 +03:00
tomerb
0bb3fb9c40 added datetime to the yaxis scale options 2016-05-18 18:41:08 +03:00
Arik Fraimovich
1a1160eb76 Merge pull request #1062 from getredash/fix_dql
DynamoDB: Better exception handling
2016-05-18 14:14:00 +03:00
Arik Fraimovich
d4ae97aab2 Move pyparsing import to the try/except block 2016-05-18 14:08:42 +03:00
Arik Fraimovich
8bc42c8ad9 Remove duplicate reference to ParseException 2016-05-18 13:45:26 +03:00
Arik Fraimovich
6c5865bd3b Better exception handling 2016-05-18 13:44:38 +03:00
Arik Fraimovich
701035fabd Merge pull request #1059 from getredash/fix_dql
Fix: DynamoDB having issues when setting host
2016-05-18 13:42:48 +03:00
Arik Fraimovich
31aee1b6b9 Better exception handling 2016-05-18 13:30:20 +03:00
Arik Fraimovich
367ea859e4 If host param is empty, change it to None 2016-05-18 13:30:10 +03:00
Raymond Machira
d79d3da955 Merge branch 'get-redash-master' into fix/area_plots 2016-05-17 15:55:21 -05:00
Raymond Machira
6c822d1e4b Force area charts to recalculate on click of legend items.
This fixes issue #948 where stacked area plots do not update once series are enabled/disabled using the legend.
It also fixes the behaviour of percentage stack area plots, so as to have similar behaviour to percentage stacked bar charts.
2016-05-17 15:49:25 -05:00
Ama Asare
ad85b9a62c Ama/Kumar: Configure authorization for SAML 2016-05-17 14:01:18 -05:00
Arik Fraimovich
b5a4a6b880 Merge pull request #979 from ninneko/860-managepy_support_gropu_operation
Feature: Add CLI to edit group permissions
2016-05-17 15:05:16 +03:00
Arik Fraimovich
1828de20b0 Fix: DynamoDB having issues when setting host 2016-05-17 14:36:15 +03:00
Antoine Augusti
48c85645c6 Bring back filters if dashboard filters are enabled 2016-05-17 09:57:08 +02:00
Arik Fraimovich
ed45dcb01d Merge pull request #1044 from thoughtworks/improve-vagrant-flow
Improve vagrant flow
2016-05-13 15:47:10 +03:00
Ama Asare
d4ff7482ad Ama: Install just pymongo and not everything in requirements_all_ds.txt
Because some dependencies were missing and we dont really need everything in the file...just pymongo
Also removed unneeded files, reverted circle.yml to as it is on redash, so it doesnt show in the PR diff
2016-05-09 17:15:07 -05:00
Ama Asare
90f0b3b49a Ama: Consolidate vagrant_provision script, move files to more intuitive locations, include command from #1021.
Also reset circle.yml to be as in the main redash repo so our changes are not included in the changeset
Check earlier PR https://github.com/getredash/redash/pull/1027 to follow conversation.
2016-05-09 17:13:33 -05:00
Ama Asare
f8efb2d7ea Ama/Chris #7121 Added script to run or test redash vagrant server in one command. 2016-05-09 17:13:18 -05:00
Kumar Vora
d2ba0cb6cf Let plotly calculate the height based on provided margin values 2016-05-09 16:11:51 -05:00
Arik Fraimovich
cfb852e9c5 Add missing import. 2016-05-08 22:29:36 +03:00
Arik Fraimovich
d5c6e57c62 Optionally get org_slug from g. 2016-05-08 10:41:56 +03:00
Arik Fraimovich
2924d4fce6 Merge pull request #1040 from getredash/fix/visaulizations
Small fixes to visualizations view
2016-05-08 10:39:37 +03:00
Arik Fraimovich
e602b8cf2b Don't render visualizations in the background 2016-05-08 10:34:30 +03:00
Arik Fraimovich
0b806e2e7d Fix: link in pivot tab was broken 2016-05-08 10:34:14 +03:00
yohei.naruse
c3c302e11e modify indent size 2016-05-08 12:43:42 +09:00
Arik Fraimovich
aa837ed09b Update for new design 2016-05-07 22:20:06 +03:00
Arik Fraimovich
f07e7273c1 Fix: add destination to list of static routes 2016-05-07 22:19:53 +03:00
Arik Fraimovich
9b6f555d76 Update alert task to use destinations 2016-05-07 17:58:15 +03:00
Arik Fraimovich
e069374232 Merge w/ latest master 2016-05-07 17:49:49 +03:00
Arik Fraimovich
c496df3b87 Update ISSUE_TEMPLATE.md 2016-05-05 22:30:10 +03:00
Arik Fraimovich
2ee0065102 Merge pull request #1038 from getredash/docs-github
Add an ISSUE_TEMPLATE.md to direct people at the forum
2016-05-05 22:29:23 +03:00
Arik Fraimovich
c0ffea7083 Add an ISSUE_TEMPLATE.md to direct people at the forum 2016-05-05 22:28:04 +03:00
Arik Fraimovich
fec0d5fecc Merge pull request #1037 from dheerajrav/master
multifilter bug fix
2016-05-05 16:28:03 +03:00
dheerajrav
83a03a22b1 multifilter bug fix 2016-05-05 18:51:41 +05:30
Arik Fraimovich
8b5dc8ef68 Merge pull request #1036 from getredash/fixes_160504
Add optional block for more scripts in template
2016-05-05 11:26:22 +03:00
Arik Fraimovich
f3a274a5c0 Add optional block for more scripts in template 2016-05-05 11:26:02 +03:00
Arik Fraimovich
386d6efdaa Merge pull request #1035 from whummer/feat/test_params_on_embeds
Add test case for embeds with parameters
2016-05-05 09:56:56 +03:00
Waldemar Hummer
e415189017 add test case for embeds with parameters; minor fix in embeds.py 2016-05-05 11:15:49 +10:00
Arik Fraimovich
b066ce4b74 Merge pull request #1033 from getredash/fixes_160504
Fix: only ask for notification permissions if wasn't denied
2016-05-04 17:03:26 +03:00
Arik Fraimovich
056ae4f63e Fix: only ask for notification permissions if wasn't denied 2016-05-04 16:57:58 +03:00
Arik Fraimovich
6d495d2f2c Merge pull request #1014 from whummer/feat/params_on_embeds
Add server-side parameter handling for embeds
2016-05-04 16:56:35 +03:00
Arik Fraimovich
960c416fcb Merge pull request #1032 from getredash/fixes_160504
Fix: make sure we return dashboards only for current org only
2016-05-04 16:54:25 +03:00
Arik Fraimovich
f7322a413f Merge pull request #1021 from ariarijp/documentation-improvement
Vagrant docs: add purging the cache step
2016-05-04 16:49:58 +03:00
Arik Fraimovich
d9cc063be2 Fix: make sure we return dashboards only for current org 2016-05-04 16:32:49 +03:00
Arik Fraimovich
8fa6fdb0d5 Merge pull request #1030 from getredash/fixes_160504
Make sure data sources list ordered by id
2016-05-04 12:12:17 +03:00
Arik Fraimovich
7016477700 Restore support for forwarding events 2016-05-04 12:07:59 +03:00
Arik Fraimovich
0bb722df5d Make sure data sources ordered by id 2016-05-04 12:03:47 +03:00
Arik Fraimovich
b3844d3643 Merge pull request #1029 from getredash/fixes_160504
Hive: close connection only if it exists
2016-05-04 10:42:59 +03:00
Arik Fraimovich
e32bfe3db7 Hive: close connection only if it exists 2016-05-04 10:40:53 +03:00
Waldemar Hummer
4591eff557 add server-side parameter handling for embeds 2016-05-03 10:49:01 +10:00
Arik Fraimovich
7062873cd1 Add note re. GCE image version 2016-05-02 12:48:43 +03:00
Arik Fraimovich
9e23cc2bf2 update version references 2016-05-01 15:49:39 +03:00
ariarijp
c5d92b4e7e Add purging the cache step 2016-05-01 21:37:51 +09:00
Arik Fraimovich
41dfcd8cbf Bump version. 2016-05-01 11:18:32 +03:00
yohei.naruse
1fa701c136 apply reviews. 2016-04-28 11:43:01 +09:00
Kumar Vora
303e158eb1 Issue#1006: allowing user to change bottom margin for Chart visualizations 2016-04-22 13:16:00 -05:00
yohei.naruse
19aaa938d8 manage.py support group operations. 2016-04-10 22:08:36 +09:00
Alex DeBrie
4bcb705a2a Hide user subscriptions if email is not enabled 2016-03-24 14:28:47 +00:00
Alex DeBrie
1c04f3cc29 Fix broken tests 2016-03-24 13:55:46 +00:00
Alex DeBrie
ee29f07802 Clean up after rebase 2016-03-24 02:46:59 +00:00
Alex DeBrie
df2067eec1 Make subscription display more clear 2016-03-23 20:57:19 +00:00
Alex DeBrie
601010e44e Remove email destination from AlertSubscription migration 2016-03-23 20:57:19 +00:00
Alex DeBrie
6c3b713b3d Add destination subscriptions to Alert page 2016-03-23 20:57:19 +00:00
Alex DeBrie
faf2f7dede Add user subscriptions back to Alert page 2016-03-23 20:57:19 +00:00
Alex DeBrie
bf880a834b Move notify to AlertSubscription; adjust email destination to take multiple addresses 2016-03-23 20:57:19 +00:00
Alex DeBrie
ce6ceac5c4 Add hipchat to default destinations 2016-03-23 20:57:19 +00:00
Alex DeBrie
70b4f9d447 Create dynamicForm directive 2016-03-23 20:57:18 +00:00
Alex DeBrie
3838b03417 Clean up tasks.py to remove unneeded code 2016-03-23 20:52:50 +00:00
Alex DeBrie
a11fa2717d Add migrations of existing alerts 2016-03-23 20:51:10 +00:00
Alex DeBrie
becf315e66 Add hipchat destination 2016-03-23 20:51:10 +00:00
Alex DeBrie
04eb37a7f2 Pass objects instead of IDs to notify method 2016-03-23 20:51:10 +00:00
Alex DeBrie
e91610f4b4 Remove hacky backwards compatibility for notifications 2016-03-23 20:51:10 +00:00
Alex DeBrie
63786c98df Remove unnecessary code 2016-03-23 20:51:10 +00:00
Alex DeBrie
54f3df6988 Move destination icons to BaseDestination 2016-03-23 20:49:20 +00:00
Alex DeBrie
bb3874e631 Pass subscription user to notify method 2016-03-23 20:49:20 +00:00
Alex DeBrie
eef18510d5 Fix email destination bugs; make email alerts backward compatible 2016-03-23 20:49:20 +00:00
Alex DeBrie
a3c0917d85 Fix AlertSubscription unsubscribe permissions and broken tests 2016-03-23 20:49:20 +00:00
Alex DeBrie
ed7f9ea5f0 Create UI for adding/removing alert subscriptions 2016-03-23 20:49:20 +00:00
Alex DeBrie
82b7146216 Enforce AlertSubscription uniqueness; update AlertSubscription delete handler 2016-03-23 20:49:20 +00:00
Alex DeBrie
3cfbb9855b Update notify logic in tasks and add destinations for Slack, email, and webhooks 2016-03-23 20:49:20 +00:00
Alex DeBrie
4938f8e013 Add ability to choose destination when creating alert 2016-03-23 20:47:50 +00:00
Alex DeBrie
a43761da39 Require destination_id in POSTs that create a subscription 2016-03-23 20:47:50 +00:00
Alex DeBrie
a3703b2058 Fix broken tests 2016-03-23 20:47:08 +00:00
Alex DeBrie
f2d5d52310 Remove destination groups; use ConfigurationContainer for options; Add user field on NotificationDestination 2016-03-23 20:44:14 +00:00
Alex DeBrie
eed2a41816 Add destination field to AlertSubscription 2016-03-23 20:39:22 +00:00
Alex DeBrie
16c0df4117 Group handlers for Destinations 2016-03-23 20:38:33 +00:00
Alex DeBrie
3844483776 Add destination elements to rd_ui 2016-03-23 20:35:50 +00:00
Alex DeBrie
53f8f1de3b Fix typo 2016-03-23 20:24:26 +00:00
Alex DeBrie
3ac7f02aea Add NotificationDestination model and handlers; Add BaseNotification class 2016-03-23 20:24:26 +00:00
137 changed files with 3274 additions and 776 deletions

3
.bowerrc Normal file
View File

@@ -0,0 +1,3 @@
{
"directory": "rd_ui/app/bower_components"
}

24
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,24 @@
Welcome to Redash's GitHub repo! 👋🎉
Do you need help or have a question? Checkout the Support category in our discussion forum: https://discuss.redash.io/c/support.
Got an idea for a new feature? Check if it isn't on the roadmap already: http://bit.ly/redash-roadmap and start a new discussion in the features category: https://discuss.redash.io/c/feature-requests 🌟.
Found a bug? Please fill out the sections below... thank you 👍
### Issue Summary
A summary of the issue and the browser/OS environment in which it occurs.
### Steps to Reproduce
1. This is the first step
2. This is the second step, etc.
Any other info e.g. Why do you consider this to be a bug? What did you expect to happen instead?
### Technical details:
* Redash Version:
* Browser/OS:
* How did you install Redash:

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@ dump.rdb
# Docker related
docker-compose.yml
node_modules
.tmp
.sass-cache
rd_ui/app/bower_components

79
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,79 @@
# Contributing Guide
Thank you for taking the time to contribute! :tada::+1:
The following is a set of guidelines for contributing to Redash. These are guidelines, not rules, please use your best judgement and feel free to propose changes to this document in a pull request.
## Quick Links:
- [Feature Roadmap](https://trello.com/b/b2LUHU7A/re-dash-roadmap)
- [Feature Requests](https://discuss.redash.io/c/feature-requests)
- [Gitter Chat](https://gitter.im/getredash/redash) or [Slack](https://slack.redash.io)
- [Documentation](http://docs.redash.io)
- [Blog](http://blog.redash.io/)
- [Twitter](https://twitter.com/getredash)
---
:star: If you already here and love the project, please make sure to press the Star button. :star:
---
## Table of Contents
[How can I contribute?](#how-can-i-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements / Feature Requests](#suggesting-enhancements--feature-requests)
- [Pull Requests](#pull-requests)
- [Documentation](#documentation)
- Design?
[Addtional Notes](#additional-notes)
- [Release Method](#release-method)
- [Code of Conduct](#code-of-conduct)
## How can I contribute?
### Reporting Bugs
When creating a new bug report, please make sure to:
- Search for existing issues first. If you find a previous report of your issue, please update the existing issue with additional information instead of creating a new one.
- If you are not sure if your issue is really a bug or just some configuration/setup problem, please start a discussion in [the support forum](https://discuss.redash.io/c/support) first. Unless you can provide clear steps to reproduce, it's probably better to start with a thread in the forum and later to open an issue.
- If you still decide to open an issue, please review the template and guidelines and include as much details as possible.
### Suggesting Enhancements / Feature Requests
If you would like to suggest an enchancement or ask for a new feature:
- Please check [the roadmap](https://trello.com/b/b2LUHU7A/re-dash-roadmap) for existing Trello card for what you want to suggest/ask. If there is, feel free to upvote it to signal interest or add your comments.
- If there is no existing card, open a thread in [the forum](https://discuss.redash.io/c/feature-requests) to start a discussion about what you want to suggest. Try to provide as much details and context as possible and include information about *the problem you want to solve* rather only *your proposed solution*.
### Pull Requests
- **Code contributions are welcomed**. For big changes or significant features, it's usually better to reach out first and discuss what you want to implement and how (we recommend reading: [Pull Request First](https://medium.com/practical-blend/pull-request-first-f6bb667a9b6#.ozlqxvj36)). This to make sure that what you want to implement is aligned with our goals for the project and that no one else is already working on it.
- Include screenshots and animated GIFs in your pull request whenever possible.
- Please add [documentation](#documentation) for new features or changes in functionality along with the code.
- Please follow existing code style. We use PEP8 for Python and sensible style for Javascript.
### Documentation
The project's documentation can be found at [docs.redash.io](http://docs.redash.io/). The [documentation sources](https://github.com/getredash/redash/tree/master/docs) are managed along with the code and to contribute edits / new pages, you can use GitHub's interface. Click the "Edit on GitHub" link on the documentation page to quickly open the edit interface.
The pages are written in *reStructuredText* format, which is very similar to Markdown.
## Additional Notes
### Release Method
We publish a stable release every ~2 months, although the goal is to get to a stable release every month. You can see the change log on [GitHub releases page](http://github.com/getredash/redash/releases).
Every build of the master branch updates the latest *RC release*. These releases are usually stable, but might contain regressions and therefore recommended for "advanced users" only.
When we release a new stable release, we also update the *latest* Docker image tag, the EC2 AMIs and GCE images.
## Code of Conduct
This project adheres to the Contributor Covenant [code of conduct](http://redash.io/community/code_of_conduct). By participating, you are expected to uphold this code. Please report unacceptable behavior to team@redash.io.

View File

@@ -6,7 +6,7 @@ RUN apt-get update && \
# Postgres client
libpq-dev \
# Additional packages required for data sources:
libssl-dev libmysqlclient-dev freetds-dev && \
libssl-dev libmysqlclient-dev freetds-dev libsasl2-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
@@ -14,7 +14,7 @@ RUN apt-get update && \
RUN useradd --system --comment " " --create-home redash
# Pip requirements for all data source types
RUN pip install -U setuptools && \
RUN pip install -U setuptools==23.1.0 && \
pip install supervisor==3.1.2
COPY . /opt/redash/current
@@ -32,7 +32,7 @@ RUN pip install -r requirements_all_ds.txt && \
RUN curl https://deb.nodesource.com/setup_4.x | bash - && \
apt-get install -y nodejs && \
sudo -u redash -H make deps && \
rm -rf rd_ui/node_modules /home/redash/.npm /home/redash/.cache && \
rm -rf node_modules rd_ui/node_modules /home/redash/.npm /home/redash/.cache && \
apt-get purge -y nodejs && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

View File

@@ -6,17 +6,17 @@ BASE_VERSION=$(shell python ./manage.py version | cut -d + -f 1)
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(VERSION).tar.gz
deps:
if [ -d "./rd_ui/app" ]; then cd rd_ui && npm install; fi
if [ -d "./rd_ui/app" ]; then cd rd_ui && npm run bower install; fi
if [ -d "./rd_ui/app" ]; then cd rd_ui && npm run build; fi
if [ -d "./rd_ui/app" ]; then npm install; fi
if [ -d "./rd_ui/app" ]; then npm run bower install; fi
if [ -d "./rd_ui/app" ]; then npm run build; fi
pack:
sed -ri "s/^__version__ = '([0-9.]*)'/__version__ = '$(FULL_VERSION)'/" redash/__init__.py
tar -zcv -f $(FILENAME) --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="rd_ui/node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
tar -zcv -f $(FILENAME) --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
upload:
python bin/release_manager.py $(CIRCLE_SHA1) $(BASE_VERSION) $(FILENAME)
test:
nosetests --with-coverage --cover-package=redash tests/
#cd rd_ui && grunt test
#grunt test

2
Procfile.heroku Normal file
View File

@@ -0,0 +1,2 @@
web: ./manage.py runserver -d -r -p $PORT --host 0.0.0.0
worker: celery worker --app=redash.worker -c2 --beat -Q queries,celery,scheduled_queries

View File

@@ -42,7 +42,7 @@ You can try out the demo instance: http://demo.redash.io/ (login with any Google
## Getting Help
* Issues: https://github.com/getredash/redash/issues
* Mailing List: https://groups.google.com/forum/#!forum/redash-users
* Discussion Forum: https://discuss.redash.io/
* Slack: http://slack.redash.io/
* Gitter (chat): https://gitter.im/getredash/redash

4
Vagrantfile vendored
View File

@@ -8,4 +8,8 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.box = "redash/dev"
config.vm.synced_folder "./", "/opt/redash/current"
config.vm.network "forwarded_port", guest: 5000, host: 9001
config.vm.provision "shell" do |s|
s.inline = "/opt/redash/current/setup/vagrant/provision.sh"
s.privileged = false
end
end

18
bin/pre_compile Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Heroku pre_compile script
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
pushd $DIR/..
# heroku requires cffi to be in requirements.txt in order for libffi to be installed.
# https://github.com/heroku/heroku-buildpack-python/blob/master/bin/steps/cryptography
# to avoid making it a requirement for other build systems, we'll inject it now
# into the requirements.txt file
# Remove Heroku unsupported Python packages:
grep -v -E "^(pymssql|thrift|sasl|pyhive)" requirements_all_ds.txt >> requirements.txt
# make the heroku Procfile the active one
cp Procfile.heroku Procfile
popd

21
bin/vagrant_ctl.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
set -e
help() {
echo "Usage: "
echo "`basename "$0"` {start, test}"
}
case "$1" in
start)
vagrant up
vagrant ssh -c "cd /opt/redash/current; bin/run honcho start -f Procfile.dev;"
;;
test)
vagrant up
vagrant ssh -c "cd /opt/redash/current; make test"
;;
*)
help
;;
esac

View File

@@ -1,6 +1,6 @@
{
"name": "rdUi",
"version": "0.1.0",
"name": "redash",
"version": "0.11.1",
"dependencies": {
"angular": "1.2.18",
"angular-resource": "1.2.18",

View File

@@ -12,16 +12,16 @@ dependencies:
- pip install -r requirements_dev.txt
- pip install -r requirements.txt
- pip install pymongo==3.2.1
- if [ "$CIRCLE_BRANCH" = "master" ]; then make deps; fi
- make deps
cache_directories:
- rd_ui/node_modules/
- node_modules/
- rd_ui/app/bower_components/
test:
override:
- nosetests --with-xunit --xunit-file=$CIRCLE_TEST_REPORTS/junit.xml --with-coverage --cover-package=redash tests/
deployment:
github_and_docker:
branch: master
branch: [master, /release_.*/]
commands:
- make pack
- make upload

View File

@@ -16,7 +16,7 @@ redis:
postgres:
image: postgres:9.3
volumes:
- /opt/postgres-data:/var/lib/postgresql/data
- /opt/postgres-data:/var/lib/postgresql/data
redash-nginx:
image: redash/nginx:latest
ports:

34
docs/dev/saml.rst Normal file
View File

@@ -0,0 +1,34 @@
SAML Authentication and Authorization
#####################################
Authentication
==============
Add to your .env file REDASH_SAML_METADATA_URL config value which
needs to point to the SAML provider metadata url, eg https://app.onelogin.com/saml/metadata/
And an optional REDASH_SAML_CALLBACK_SERVER_NAME which contains the
server name of the redash server for the callbacks from the SAML provider (eg demo.redash.io)
On the SAML provider side, example configuration for OneLogin is:
SAML Consumer URL: http://demo.redash.io/saml/login
SAML Audience: http://demo.redash.io/saml/callback
SAML Recipient: http://demo.redash.io/saml/callback
Example configuration for Okta is:
Single Sign On URL: http://demo.redash.io/saml/callback
Recipient URL: http://demo.redash.io/saml/callback
Destination URL: http://demo.redash.io/saml/callback
with parameters 'FirstName' and 'LastName', both configured to be included in the SAML assertion.
Authorization
=============
To manage group assignments in Redash using your SAML provider, configure SAML response to include
attribute with key 'RedashGroups', and value as names of groups in Redash.
Example configuration for Okta is:
In the Group Attribute Statements -
Name: RedashGroups
Filter: Starts with: this-is-a-group-in-redash

View File

@@ -14,27 +14,10 @@ To get started with this box:
`Vagrant <https://www.vagrantup.com/>`__ installed.
2. Clone the Re:dash repository:
``git clone https://github.com/getredash/redash.git``.
3. Change dir into the repository (``cd redash``) and run run
``vagrant up``. This might take some time the first time you run it,
3. Change dir into the repository (``cd redash``)
4a. To execute tests, run ``./bin/vagrant_ctl.sh test``
4b. To run the app, run ``./bin/vagrant_ctl.sh start``.
This might take some time the first time you run it,
as it downloads the Vagrant virtual box.
4. Once Vagrant is ready, ssh into the instance (``vagrant ssh``), and
change dir to ``/opt/redash/current`` -- this is where your local
repository copy synced to.
5. Copy ``.env`` file into this directory (``cp ../.env ./``).
6. From ``/opt/redash/current/rd_ui`` run ``bower install`` to install
frontend packages. This can be done from your host machine as well,
if you have bower installed.
7. Go back to ``/opt/redash/current`` and install python dependencies
``sudo pip install -r requirements.txt``
8. Update database schema to the latest version:
::
bin/run ./manage.py database drop_tables
bin/run ./manage.py database create_tables
bin/run ./manage.py users create --admin --password admin "Admin" "admin"
9. Start the server and background workers with
``bin/run honcho start -f Procfile.dev``.
10. Now the server should be available on your host on port 9001 and you
Now the server should be available on your host on port 9001 and you
can login with username admin and password admin.

View File

@@ -39,7 +39,7 @@ Getting Help
* Source: https://github.com/getredash/redash
* Issues: https://github.com/getredash/redash/issues
* Mailing List: https://groups.google.com/forum/#!forum/redash-users
* Discussion Forum: https://discuss.redash.io/
* Slack: http://slack.redash.io/
* Gitter (chat): https://gitter.im/getredash/redash

View File

@@ -42,6 +42,13 @@ SSL with your instance you need to:
ssl_certificate /path-to/cert.pem; # or crt
ssl_certificate_key /path-to/cert.key;
# Specifies that we don't want to use SSLv2 (insecure) or SSLv3 (exploitable)
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
# Uses the server's ciphers rather than the client's
ssl_prefer_server_ciphers on;
# Specifies which ciphers are okay and which are not okay. List taken from https://raymii.org/s/tutorials/Strong_SSL_Security_On_nginx.html
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:ECDHE-RSA-AES128-GCM-SHA256:AES256+EECDH:DHE-RSA-AES128-GCM-SHA256:AES256+EDH:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
access_log /var/log/nginx/redash.access.log;
gzip on;

View File

@@ -18,17 +18,17 @@ AWS
Launch the instance with from the pre-baked AMI (for small deployments
t2.micro should be enough):
- us-east-1: `ami-a7ddfbcd <https://console.aws.amazon.com/ec2/home?region=us-east-1#LaunchInstanceWizard:ami=ami-a7ddfbcd>`__
- us-west-1: `ami-269feb46 <https://console.aws.amazon.com/ec2/home?region=us-west-1#LaunchInstanceWizard:ami=ami-269feb46>`__
- us-west-2: `ami-435fba23 <https://console.aws.amazon.com/ec2/home?region=us-west-2#LaunchInstanceWizard:ami=ami-435fba23>`__
- eu-west-1: `ami-b4c277c7 <https://console.aws.amazon.com/ec2/home?region=eu-west-1#LaunchInstanceWizard:ami=ami-b4c277c7>`__
- eu-central-1: `ami-07ced76b <https://console.aws.amazon.com/ec2/home?region=eu-central-1#LaunchInstanceWizard:ami=ami-07ced76b>`__
- sa-east-1: `ami-6e2eaf02 <https://console.aws.amazon.com/ec2/home?region=sa-east-1#LaunchInstanceWizard:ami=ami-6e2eaf02>`__
- ap-northeast-1: `ami-aa5a64c4 <https://console.aws.amazon.com/ec2/home?region=ap-northeast-1#LaunchInstanceWizard:ami=ami-aa5a64c4>`__
- ap-southeast-1: `ami-1c45897f <https://console.aws.amazon.com/ec2/home?region=ap-southeast-1#LaunchInstanceWizard:ami=ami-1c45897f>`__
- ap-southeast-2: `ami-42b79221 <https://console.aws.amazon.com/ec2/home?region=ap-southeast-2#LaunchInstanceWizard:ami=ami-42b79221>`__
- us-east-1: `ami-52c3373f <https://console.aws.amazon.com/ec2/home?region=us-east-1#LaunchInstanceWizard:ami=ami-52c3373f>`__
- us-west-1: `ami-c6c5bda6 <https://console.aws.amazon.com/ec2/home?region=us-west-1#LaunchInstanceWizard:ami=ami-c6c5bda6>`__
- us-west-2: `ami-f0b04e90 <https://console.aws.amazon.com/ec2/home?region=us-west-2#LaunchInstanceWizard:ami=ami-f0b04e90>`__
- eu-west-1: `ami-f3910780 <https://console.aws.amazon.com/ec2/home?region=eu-west-1#LaunchInstanceWizard:ami=ami-f3910780>`__
- eu-central-1: `ami-00719d6f <https://console.aws.amazon.com/ec2/home?region=eu-central-1#LaunchInstanceWizard:ami=ami-00719d6f>`__
- sa-east-1: `ami-af2fa7c3 <https://console.aws.amazon.com/ec2/home?region=sa-east-1#LaunchInstanceWizard:ami=ami-af2fa7c3>`__
- ap-northeast-1: `ami-78967519 <https://console.aws.amazon.com/ec2/home?region=ap-northeast-1#LaunchInstanceWizard:ami=ami-78967519>`__
- ap-southeast-1: `ami-bdbb6ade <https://console.aws.amazon.com/ec2/home?region=ap-southeast-1#LaunchInstanceWizard:ami=ami-bdbb6ade>`__
- ap-southeast-2: `ami-8edbf4ed <https://console.aws.amazon.com/ec2/home?region=ap-southeast-2#LaunchInstanceWizard:ami=ami-8edbf4ed>`__
(the above AMIs are of version: 0.9.1)
(the above AMIs are of version: 0.10.1)
When launching the instance make sure to use a security group, that **only** allows incoming traffic on: port 22 (SSH), 80 (HTTP) and 443 (HTTPS). These AMIs are based on Ubuntu so you will need to use the user ``ubuntu`` when connecting to the instance via SSH.
@@ -44,8 +44,9 @@ First, you need to add the images to your account:
$ gcloud compute images create "redash-091-b1377" --source-uri gs://redash-images/redash.0.9.1.b1377.tar.gz
Next you need to launch an instance using this image (n1-standard-1
instance type is recommended). If you plan using Re:dash with BigQuery,
you can use a dedicated image which comes with BigQuery preconfigured
instance type is recommended).
If you plan using Re:dash with BigQuery, you can use a dedicated image which comes with BigQuery preconfigured
(using instance permissions):
.. code:: bash
@@ -61,7 +62,8 @@ Note that you need to launch this instance with BigQuery access:
(the same can be done from the web interface, just make sure to enable
BigQuery access)
Now proceed to `"Setup" <#setup>`__.
Please note that currently the Google Compute Engine images are for version 0.9.1. After creating the instance, please
run the :doc:`upgrade process <upgrade>` and then proceed to `"Setup" <#setup>`__.
Docker Compose
------
@@ -70,13 +72,56 @@ Docker Compose
2. Make sure your current working directory is the root of this GitHub repository.
3. Run ``docker-compose up postgres``.
4. Run ``./setup/docker/create_database.sh``. This will access the postgres container and set up the database.
5. Run ``docker compose up``
5. Run ``docker-compose up``
6. Run ``docker-machine ls``, take note of the ip for the Docker machine you are using, and open the web browser.
7. Visit that Docker machine IP at port 80, and you should see a Re:dash login screen.
Now proceed to `"Setup" <#setup>`__.
Heroku
------
Due to the nature of Heroku deployments, upgrading to a newer version of Redash
requires performing the steps outlined on the `"How to Upgrade" <http://docs.redash.io/en/latest/upgrade.html>`__ page.
1. Install `Heroku CLI <https://toolbelt.heroku.com/>`__.
2. Create Heroku App::
$ heroku apps:create <app name>
2. Set application buildpacks::
$ heroku buildpacks:set heroku/python
$ heroku buildpacks:add --index 1 heroku/nodejs
3. Add Postgres and Redis addons::
$ heroku addons:create heroku-postgresql:hobby-dev
$ heroku addons:create heroku-redis:hobby-dev
4. Update the cookie secret (**Important** otherwise anyone can sign new cookies and impersonate users. You may be able to run the command ``pwgen 32 -1`` to generate a random string)::
$ heroku config:set REDASH_COOKIE_SECRET='<create a secret token and put here>'
5. Push the repository to Heroku::
$ git push heroku master
6. Create database tables::
$ heroku run ./manage.py database create_tables
7. Create admin user::
$ heroku run ./manage.py users create --admin "Admin" admin
7. Start worker process::
$ heroku ps:scale worker=1
Other
-----
@@ -187,6 +232,11 @@ It's recommended to upgrade once in a while your Re:dash instance to
benefit from bug fixes and new features. See :doc:`here </upgrade>` for full upgrade
instructions (including Fabric script).
Configuration
-------------
For a full list of environment variables, see :doc:`the settings page </settings>`.
Notes
=====

View File

@@ -70,3 +70,43 @@ Version
See current version:
``bin/run ./manage.py version``
Monitoring
==========
Re:dash ships by default with a HTTP handler that gives you useful information about the
health of your application. The endpoint is ``/status.json`` and requires a super admin
API key to be given if you're not already logged in. This API key can be obtained from
the dedicated tab in your profile.
You'll find below an example output of this endpoint:
.. code-block:: json
{
"dashboards_count": 30,
"manager": {
"last_refresh_at": "1465392784.433638",
"outdated_queries_count": 1,
"query_ids": "[34]",
"queues": {
"queries": {
"data_sources": "Redshift data, re:dash metadata, MySQL data, MySQL read-only, Redshift read-only",
"size": 1
},
"scheduled_queries": {
"data_sources": "Redshift data, re:dash metadata, MySQL data, MySQL read-only, Redshift read-only",
"size": 0
}
}
},
"queries_count": 204,
"query_results_count": 11161,
"redis_used_memory": "6.09M",
"unused_query_results_count": 32,
"version": "0.10.0+b1774",
"widgets_count": 176,
"workers": []
}
If you plan to hit this endpoint without being logged in, you'll need to provide your API key as a query parameter. Example endpoint with an API key: ``/status.json?api_key=fooBarqsLlGJQIs3maPErUxKuxwWGIpDXoSzQsx7xdv``

View File

@@ -23,8 +23,7 @@ How does it work?
Dashboard widget with a visualization the user doesn't have access to.
In current implementation all the users see a list of all the dashboards. Once `pull request #957 <https://github.com/getredash/redash/pull/957>`__
gets merged, we will filter out dashboards from the list that the user has no access to any of their widgets.
If a user has access to at least one widget on a dashboard, they can see this dashboard in the list of all dashboards.
What if I want to limit the user to only some tables?

View File

@@ -7,17 +7,28 @@ var lazypipe = require('lazypipe');
var rimraf = require('rimraf');
var wiredep = require('wiredep').stream;
var runSequence = require('run-sequence');
var map = require('lodash.map');
var yeoman = {
app: 'app',
dist: 'dist'
app: 'rd_ui/app',
dist: 'rd_ui/dist'
};
function applyAppPath(p) {
if (typeof p === 'string') {
return yeoman.app + p;
} else {
return map(p, function (path) {
return applyAppPath(path);
});
}
}
var paths = {
scripts: [yeoman.app + '/scripts/**/*.js'],
styles: [yeoman.app + '/styles/**/*.css'],
views: {
main: [yeoman.app + '/index.html', 'app/vendor_scripts.html', 'app/login.html', 'app/embed.html', 'app/public.html', 'app/app_layout.html', 'app/signed_out_layout.html'],
main: applyAppPath(['/index.html', '/vendor_scripts.html', '/login.html', '/embed.html', '/public.html', '/app_layout.html', '/signed_out_layout.html']),
files: [yeoman.app + '/views/**/*.html']
}
};
@@ -104,12 +115,12 @@ gulp.task('images', function () {
});
gulp.task('copy:extras', function () {
return gulp.src([yeoman.app + '/*/.*', 'app/google_login.png', 'favicon.ico', 'robots.txt'], { dot: true })
return gulp.src(applyAppPath(['/*/.*', '/google_login.png', '/favicon.ico', '/robots.txt']), { dot: true })
.pipe(gulp.dest(yeoman.dist));
});
gulp.task('copy:fonts', function () {
return gulp.src([yeoman.app + '/fonts/**/*', 'app/bower_components/font-awesome/fonts/*', 'app/bower_components/material-design-iconic-font/dist/fonts/*'])
return gulp.src(applyAppPath(['/fonts/**/*', '/bower_components/font-awesome/fonts/*', '/bower_components/material-design-iconic-font/dist/fonts/*']))
.pipe(gulp.dest(yeoman.dist + '/fonts'));
});

View File

@@ -8,12 +8,13 @@ from flask_script import Manager
from redash import settings, models, __version__
from redash.wsgi import app
from redash.cli import users, database, data_sources, organization
from redash.cli import users, groups, database, data_sources, organization
from redash.monitor import get_status
manager = Manager(app)
manager.add_command("database", database.manager)
manager.add_command("users", users.manager)
manager.add_command("groups", groups.manager)
manager.add_command("ds", data_sources.manager)
manager.add_command("org", organization.manager)

View File

@@ -0,0 +1,81 @@
import peewee
from redash import settings
from redash.models import db, NotificationDestination, AlertSubscription, Alert, Organization, User
from redash.destinations import get_configuration_schema_for_destination_type
from redash.utils.configuration import ConfigurationContainer
from playhouse.migrate import PostgresqlMigrator, migrate
if __name__ == '__main__':
migrator = PostgresqlMigrator(db.database)
with db.database.transaction():
if not NotificationDestination.table_exists():
NotificationDestination.create_table()
# Update alert subscription fields
migrate(
migrator.add_column('alert_subscriptions', 'destination_id', AlertSubscription.destination)
)
try:
org = Organization.get_by_slug('default')
user = User.select().where(User.org==org, peewee.SQL("%s = ANY(groups)", org.admin_group.id)).get()
except Exception:
print "!!! Warning: failed finding default organization or admin user, won't migrate Webhook/HipChat alert subscriptions."
exit()
if settings.WEBHOOK_ENDPOINT:
# Have all existing alerts send to webhook if already configured
schema = get_configuration_schema_for_destination_type('webhook')
conf = {'url': settings.WEBHOOK_ENDPOINT}
if settings.WEBHOOK_USERNAME:
conf['username'] = settings.WEBHOOK_USERNAME
conf['password'] = settings.WEBHOOK_PASSWORD
options = ConfigurationContainer(conf, schema)
webhook = NotificationDestination.create(
org=org,
user=user,
name="Webhook",
type="webhook",
options=options
)
for alert in Alert.select():
AlertSubscription.create(
user=user,
destination=webhook,
alert=alert
)
if settings.HIPCHAT_API_TOKEN:
# Have all existing alerts send to HipChat if already configured
schema = get_configuration_schema_for_destination_type('hipchat')
conf = {}
if settings.HIPCHAT_API_URL:
conf['url'] = '{url}/room/{room_id}/notification?auth_token={token}'.format(
url=settings.HIPCHAT_API_URL, room_id=settings.HIPCHAT_ROOM_ID, token=settings.HIPCHAT_API_TOKEN)
else:
conf['url'] = 'https://hipchat.com/v2/room/{room_id}/notification?auth_token={token}'.format(
room_id=settings.HIPCHAT_ROOM_ID, token=settings.HIPCHAT_API_TOKEN)
options = ConfigurationContainer(conf, schema)
hipchat = NotificationDestination.create(
org=org,
user=user,
name="HipChat",
type="hipchat",
options=options
)
for alert in Alert.select():
AlertSubscription.create(
user=user,
destination=hipchat,
alert=alert
)
db.close_db(None)

View File

@@ -0,0 +1,10 @@
from redash.models import db, Query
from playhouse.migrate import PostgresqlMigrator, migrate
if __name__ == '__main__':
migrator = PostgresqlMigrator(db.database)
with db.database.transaction():
migrate(
migrator.add_column('queries', 'options', Query.options),
)

View File

@@ -26,7 +26,8 @@
"gulp-print": "^2.0.1",
"gulp-rev-all": "^0.8.22",
"bower": "~1.7.1",
"gulp-cli": "~1.2.0"
"gulp-cli": "~1.2.0",
"lodash.map": "^4.4.0"
},
"engines": {
"node": ">=0.10.0"
@@ -34,7 +35,8 @@
"scripts": {
"test": "echo 'No tests.'",
"build": "gulp build",
"bower": "bower"
"bower": "bower",
"heroku-postbuild": "npm install --dev && npm run bower install && npm run build && npm prune --production"
},
"dependencies": {
}

View File

@@ -1,3 +0,0 @@
{
"directory": "app/bower_components"
}

View File

@@ -1 +0,0 @@
* text=auto

4
rd_ui/.gitignore vendored
View File

@@ -1,4 +0,0 @@
node_modules
.tmp
.sass-cache
app/bower_components

View File

@@ -1,6 +0,0 @@
language: node_js
node_js:
- '0.10'
before_script:
- 'npm install -g bower grunt-cli'
- 'bower install'

View File

@@ -64,7 +64,7 @@
{% include 'vendor_scripts.html' %}
<!-- build:js({.tmp,app}) /scripts/scripts.js -->
<!-- build:js({.tmp,rd_ui/app}) /scripts/scripts.js -->
<script src="/scripts/app.js"></script>
<script src="/scripts/services/services.js"></script>
<script src="/scripts/services/resources.js"></script>
@@ -74,6 +74,7 @@
<script src="/scripts/controllers/dashboard.js"></script>
<script src="/scripts/controllers/admin_controllers.js"></script>
<script src="/scripts/controllers/data_sources.js"></script>
<script src="/scripts/controllers/destinations.js"></script>
<script src="/scripts/controllers/query_view.js"></script>
<script src="/scripts/controllers/query_source.js"></script>
<script src="/scripts/controllers/users.js"></script>
@@ -86,10 +87,10 @@
<script src="/scripts/visualizations/box.js"></script>
<script src="/scripts/visualizations/table.js"></script>
<script src="/scripts/visualizations/pivot.js"></script>
<script src="/scripts/visualizations/date_range_selector.js"></script>
<script src="/scripts/visualizations/wordcloud.js"></script>
<script src="/scripts/vendor/cloud.js"></script>
<script src="/scripts/directives/directives.js"></script>
<script src="/scripts/directives/query_directives.js"></script>
<script src="/scripts/directives/data_source_directives.js"></script>
<script src="/scripts/directives/dashboard_directives.js"></script>
<script src="/scripts/filters.js"></script>
<script src="/scripts/controllers/alerts.js"></script>

View File

@@ -37,7 +37,7 @@
{% include 'vendor_scripts.html' %}
<!-- build:js({.tmp,app}) /scripts/embed-scripts.js -->
<!-- build:js({.tmp,rd_ui/app}) /scripts/embed-scripts.js -->
<script src="/scripts/embed.js"></script>
<script src="/scripts/services/services.js"></script>
<script src="/scripts/services/resources.js"></script>
@@ -59,10 +59,10 @@
<script src="/scripts/visualizations/box.js"></script>
<script src="/scripts/visualizations/table.js"></script>
<script src="/scripts/visualizations/pivot.js"></script>
<script src="/scripts/visualizations/date_range_selector.js"></script>
<script src="/scripts/visualizations/wordcloud.js"></script>
<script src="/scripts/vendor/cloud.js"></script>
<script src="/scripts/directives/directives.js"></script>
<script src="/scripts/directives/query_directives.js"></script>
<script src="/scripts/directives/data_source_directives.js"></script>
<script src="/scripts/directives/dashboard_directives.js"></script>
<script src="/scripts/filters.js"></script>
<script src="/scripts/controllers/alerts.js"></script>

BIN
rd_ui/app/favicon.ico Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,7 +1,5 @@
{% extends 'app_layout.html' %}
{% block content %}
<app-header></app-header>
<edit-dashboard-form dashboard="newDashboard" id="new_dashboard_dialog"></edit-dashboard-form>
{% endblock %}

View File

@@ -1,5 +1,5 @@
{% extends "signed_out_layout.html" %}
{% block title %}Login{% endblock %}
{% block title %}Login | Redash{% endblock %}
{% block content %}
{% with messages = get_flashed_messages() %}
{% if messages %}

View File

@@ -115,6 +115,15 @@ angular.module('redash', [
controller: 'DataSourcesCtrl'
});
$routeProvider.when('/destinations/:destinationId', {
templateUrl: '/views/destinations/edit.html',
controller: 'DestinationCtrl'
});
$routeProvider.when('/destinations', {
templateUrl: '/views/destinations/list.html',
controller: 'DestinationsCtrl'
});
$routeProvider.when('/users/new', {
templateUrl: '/views/users/new.html',
controller: 'NewUserCtrl'

View File

@@ -46,7 +46,8 @@
];
};
var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert) {
var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert, Destination) {
$scope.selectedTab = 'users';
$scope.$parent.pageTitle = "Alerts";
$scope.alertId = $routeParams.alertId;
@@ -66,10 +67,12 @@
if ($scope.alertId === "new") {
$scope.alert = new Alert({options: {}});
$scope.canEdit = true;
} else {
$scope.alert = Alert.get({id: $scope.alertId}, function(alert) {
$scope.onQuerySelected(new Query($scope.alert.query));
});
$scope.canEdit = currentUser.canEdit($scope.alert);
}
$scope.ops = ['greater than', 'less than', 'equals'];
@@ -108,69 +111,118 @@
growl.addErrorMessage("Failed saving alert.");
});
};
$scope.delete = function() {
$scope.alert.$delete(function() {
$location.path('/alerts');
growl.addSuccessMessage("Alert deleted.");
}, function() {
growl.addErrorMessage("Failed deleting alert.");
});
}
};
angular.module('redash.directives').directive('alertSubscribers', ['AlertSubscription', function (AlertSubscription) {
angular.module('redash.directives').directive('alertSubscriptions', ['$q', '$sce', 'AlertSubscription', 'Destination', 'growl', function ($q, $sce, AlertSubscription, Destination, growl) {
return {
restrict: 'E',
replace: true,
templateUrl: '/views/alerts/subscribers.html',
templateUrl: '/views/alerts/alert_subscriptions.html',
scope: {
'alertId': '='
},
controller: function ($scope) {
$scope.subscribers = AlertSubscription.query({alertId: $scope.alertId});
}
}
}]);
$scope.newSubscription = {};
$scope.subscribers = [];
$scope.destinations = [];
$scope.currentUser = currentUser;
angular.module('redash.directives').directive('subscribeButton', ['AlertSubscription', 'growl', function (AlertSubscription, growl) {
return {
restrict: 'E',
replace: true,
template: '<button class="btn btn-default btn-xs" ng-click="toggleSubscription()"><i ng-class="class"></i></button>',
controller: function ($scope) {
var updateClass = function() {
if ($scope.subscription) {
$scope.class = "fa fa-eye-slash";
} else {
$scope.class = "fa fa-eye";
var destinations = Destination.query().$promise;
var subscribers = AlertSubscription.query({alertId: $scope.alertId}).$promise;
$q.all([destinations, subscribers]).then(function(responses) {
var destinations = responses[0];
var subscribers = responses[1];
var subscribedDestinations = _.compact(_.map(subscribers, function(s) { return s.destination && s.destination.id }));
var subscribedUsers = _.compact(_.map(subscribers, function(s) { if (!s.destination) { return s.user.id } }));
$scope.destinations = _.filter(destinations, function(d) { return !_.contains(subscribedDestinations, d.id); });
if (!_.contains(subscribedUsers, currentUser.id)) {
$scope.destinations.unshift({user: {name: currentUser.name}});
}
}
$scope.subscribers.$promise.then(function() {
$scope.subscription = _.find($scope.subscribers, function(subscription) {
return (subscription.user.email == currentUser.email);
});
updateClass();
$scope.newSubscription.destination = $scope.destinations[0];
$scope.subscribers = subscribers;
});
$scope.toggleSubscription = function() {
if ($scope.subscription) {
$scope.subscription.$delete(function() {
$scope.subscribers = _.without($scope.subscribers, $scope.subscription);
$scope.subscription = undefined;
updateClass();
}, function() {
growl.addErrorMessage("Failed saving subscription.");
});
} else {
$scope.subscription = new AlertSubscription({alert_id: $scope.alertId});
$scope.subscription.$save(function() {
$scope.subscribers.push($scope.subscription);
updateClass();
}, function() {
growl.addErrorMessage("Unsubscription failed.");
});
$scope.destinationsDisplay = function(destination) {
if (!destination) {
return '';
}
}
if (destination.destination) {
destination = destination.destination;
} else if (destination.user) {
destination = {
name: destination.user.name + ' (Email)',
icon: 'fa-envelope',
type: 'user'
};
}
return $sce.trustAsHtml('<i class="fa ' + destination.icon + '"></i>&nbsp;' + destination.name);
};
$scope.saveSubscriber = function() {
var sub = new AlertSubscription({alert_id: $scope.alertId});
if ($scope.newSubscription.destination.id) {
sub.destination_id = $scope.newSubscription.destination.id;
}
sub.$save(function () {
growl.addSuccessMessage("Subscribed.");
$scope.subscribers.push(sub);
$scope.destinations = _.without($scope.destinations, $scope.newSubscription.destination);
if ($scope.destinations.length > 0) {
$scope.newSubscription.destination = $scope.destinations[0];
} else {
$scope.newSubscription.destination = undefined;
}
console.log("dests: ", $scope.destinations);
}, function (response) {
growl.addErrorMessage("Failed saving subscription.");
});
};
$scope.unsubscribe = function(subscriber) {
var destination = subscriber.destination;
var user = subscriber.user;
subscriber.$delete(function () {
growl.addSuccessMessage("Unsubscribed");
$scope.subscribers = _.without($scope.subscribers, subscriber);
if (destination) {
$scope.destinations.push(destination);
} else if (user.id == currentUser.id) {
$scope.destinations.push({user: {name: currentUser.name}});
}
if ($scope.destinations.length == 1) {
$scope.newSubscription.destination = $scope.destinations[0];
}
}, function () {
growl.addErrorMessage("Failed unsubscribing.");
});
};
}
}
}]);
angular.module('redash.controllers')
.controller('AlertsCtrl', ['$scope', 'Events', 'Alert', AlertsCtrl])
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', AlertCtrl])
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', 'Destination', AlertCtrl])
})();

View File

@@ -116,7 +116,7 @@
},
{
'label': 'Runtime',
'map': 'run_time',
'map': 'runtime',
'formatFunction': function (value) {
return $filter('durationHumanize')(value);
}

View File

@@ -211,14 +211,20 @@
Events.record(currentUser, "view", "widget", $scope.widget.id);
$scope.reload = function(force) {
var maxAge = $location.search()['maxAge'];
if (force) {
maxAge = 0;
}
$scope.queryResult = $scope.query.getQueryResult(maxAge);
};
if ($scope.widget.visualization) {
Events.record(currentUser, "view", "query", $scope.widget.visualization.query.id);
Events.record(currentUser, "view", "visualization", $scope.widget.visualization.id);
$scope.query = $scope.widget.getQuery();
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
var maxAge = $location.search()['maxAge'];
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
$scope.reload(false);
$scope.type = 'visualization';
} else if ($scope.widget.restricted) {

View File

@@ -7,7 +7,7 @@
};
var DataSourceCtrl = function ($scope, $routeParams, $http, $location, Events, DataSource) {
var DataSourceCtrl = function ($scope, $routeParams, $http, $location, growl, Events, DataSource) {
Events.record(currentUser, "view", "page", "admin/data_source");
$scope.$parent.pageTitle = "Data Sources";
@@ -24,9 +24,21 @@
$location.path('/data_sources/' + id).replace();
}
});
$scope.delete = function () {
Events.record(currentUser, "delete", "datasource", $scope.dataSource.id);
$scope.dataSource.$delete(function (resource) {
growl.addSuccessMessage("Data source deleted successfully.");
$location.path('/data_sources/');
}.bind(this), function (httpResponse) {
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
growl.addErrorMessage("Failed to delete data source.");
});
}
};
angular.module('redash.controllers')
.controller('DataSourcesCtrl', ['$scope', '$location', 'growl', 'Events', 'DataSource', DataSourcesCtrl])
.controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'Events', 'DataSource', DataSourceCtrl])
.controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'DataSource', DataSourceCtrl])
})();

View File

@@ -0,0 +1,44 @@
(function () {
var DestinationsCtrl = function ($scope, $location, growl, Events, Destination) {
Events.record(currentUser, "view", "page", "admin/destinations");
$scope.$parent.pageTitle = "Destinations";
$scope.destinations = Destination.query();
};
var DestinationCtrl = function ($scope, $routeParams, $http, $location, growl, Events, Destination) {
Events.record(currentUser, "view", "page", "admin/destination");
$scope.$parent.pageTitle = "Destinations";
$scope.destinationId = $routeParams.destinationId;
if ($scope.destinationId == "new") {
$scope.destination = new Destination({options: {}});
} else {
$scope.destination = Destination.get({id: $routeParams.destinationId});
}
$scope.$watch('destination.id', function(id) {
if (id != $scope.destinationId && id !== undefined) {
$location.path('/destinations/' + id).replace();
}
});
$scope.delete = function() {
Events.record(currentUser, "delete", "destination", $scope.destination.id);
$scope.destination.$delete(function(resource) {
growl.addSuccessMessage("Destination deleted successfully.");
$location.path('/destinations/');
}.bind(this), function(httpResponse) {
console.log("Failed to delete destination: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
growl.addErrorMessage("Failed to delete destination.");
});
}
};
angular.module('redash.controllers')
.controller('DestinationsCtrl', ['$scope', '$location', 'growl', 'Events', 'Destination', DestinationsCtrl])
.controller('DestinationCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Destination', DestinationCtrl])
})();

View File

@@ -53,6 +53,10 @@
$scope.saveQuery = function(options, data) {
var savePromise = saveQuery(options, data);
if (!savePromise) {
return;
}
savePromise.then(function(savedQuery) {
queryText = savedQuery.query;
$scope.isDirty = $scope.query.query !== queryText;

View File

@@ -5,8 +5,6 @@
var DEFAULT_TAB = 'table';
var getQueryResult = function(maxAge) {
// Collect params, and getQueryResult with params; getQueryResult merges it into the query
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
if (maxAge === undefined) {
maxAge = $location.search()['maxAge'];
}
@@ -16,7 +14,7 @@
}
$scope.showLog = false;
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
$scope.queryResult = $scope.query.getQueryResult(maxAge);
};
var getDataSourceId = function() {
@@ -125,9 +123,16 @@
$scope.saveQuery = function(options, data) {
if (data) {
// Don't save new query with partial data
if ($scope.query.isNew()) {
return;
}
data.id = $scope.query.id;
} else {
data = _.clone($scope.query);
data = _.pick($scope.query, ["schedule", "query", "id", "description", "name", "data_source_id", "options"]);
if ($scope.query.isNew()) {
data['latest_query_data_id'] = $scope.query.latest_query_data_id;
}
}
options = _.extend({}, {
@@ -135,9 +140,6 @@
errorMessage: 'Query could not be saved'
}, options);
delete data.latest_query_data;
delete data.queryResult;
return Query.save(data, function() {
growl.addSuccessMessage(options.successMessage);
}, function(httpResponse) {

View File

@@ -68,7 +68,7 @@
},
transclude: true,
template:
'<h2>'+
'<h2 class="p-l-5">'+
'<edit-in-place editable="canEdit()" done="saveName" ignore-blanks=\'true\' value="group.name"></edit-in-place>&nbsp;' +
'<button class="btn btn-xs btn-danger" ng-if="canEdit()" ng-click="deleteGroup()">Delete this group</button>' +
'</h2>',

View File

@@ -1,95 +0,0 @@
(function () {
'use strict';
var directives = angular.module('redash.directives');
// Angular strips data- from the directive, so data-source-form becomes sourceForm...
directives.directive('sourceForm', ['$http', 'growl', '$q', '$location', 'Events', function ($http, growl, $q, $location, Events) {
return {
restrict: 'E',
replace: true,
templateUrl: '/views/data_sources/form.html',
scope: {
'dataSource': '='
},
link: function ($scope) {
var setType = function(types) {
if ($scope.dataSource.type === undefined) {
$scope.dataSource.type = types[0].type;
return types[0];
}
$scope.type = _.find(types, function (t) {
return t.type == $scope.dataSource.type;
});
};
$scope.files = {};
$scope.$watchCollection('files', function() {
_.each($scope.files, function(v, k) {
if (v) {
$scope.dataSource.options[k] = v.base64;
}
});
});
var typesPromise = $http.get('api/data_sources/types');
$q.all([typesPromise, $scope.dataSource.$promise]).then(function(responses) {
var types = responses[0].data;
setType(types);
$scope.dataSourceTypes = types;
_.each(types, function (type) {
_.each(type.configuration_schema.properties, function (prop, name) {
if (name == 'password' || name == 'passwd') {
prop.type = 'password';
}
if (_.string.endsWith(name, "File")) {
prop.type = 'file';
}
if (prop.type == 'boolean') {
prop.type = 'checkbox';
}
prop.required = _.contains(type.configuration_schema.required, name);
});
});
});
$scope.$watch('dataSource.type', function(current, prev) {
if (prev !== current) {
if (prev !== undefined) {
$scope.dataSource.options = {};
}
setType($scope.dataSourceTypes);
}
});
$scope.saveChanges = function() {
$scope.dataSource.$save(function() {
growl.addSuccessMessage("Saved.");
}, function() {
growl.addErrorMessage("Failed saving.");
});
}
$scope.deleteDataSource = function() {
Events.record(currentUser, "delete", "datasource", $scope.dataSource.id);
$scope.dataSource.$delete(function(resource) {
growl.addSuccessMessage("Data source deleted successfully.");
$location.path('/data_sources/');
}.bind(this), function(httpResponse) {
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
growl.addErrorMessage("Failed to delete data source.");
});
}
}
}
}]);
})();

View File

@@ -92,13 +92,14 @@
restrict: 'E',
scope: {
'tabId': '@',
'name': '@'
'name': '@',
'basePath': '=?'
},
transclude: true,
template: '<li class="rd-tab" ng-class="{active: tabId==selectedTab}"><a href="{{basePath}}#{{tabId}}">{{name}}<span ng-transclude></span></a></li>',
replace: true,
link: function (scope) {
scope.basePath = $location.path().substring(1);
scope.basePath = scope.basePath || $location.path().substring(1);
scope.$watch(function () {
return scope.$parent.selectedTab
}, function (tab) {
@@ -383,7 +384,86 @@
'</div>' +
'</div>'
}
})
});
directives.directive('dynamicForm', ['$http', 'growl', '$q', function ($http, growl, $q) {
return {
restrict: 'E',
replace: 'true',
transclude: true,
templateUrl: '/views/directives/dynamic_form.html',
scope: {
'target': '=',
'type': '@type'
},
link: function ($scope) {
var setType = function(types) {
if ($scope.target.type === undefined) {
$scope.target.type = types[0].type;
return types[0];
}
$scope.type = _.find(types, function (t) {
return t.type == $scope.target.type;
});
};
$scope.files = {};
$scope.$watchCollection('files', function() {
_.each($scope.files, function(v, k) {
if (v) {
$scope.target.options[k] = v.base64;
}
});
});
var typesPromise = $http.get('api/' + $scope.type + '/types');
$q.all([typesPromise, $scope.target.$promise]).then(function(responses) {
var types = responses[0].data;
setType(types);
$scope.types = types;
_.each(types, function (type) {
_.each(type.configuration_schema.properties, function (prop, name) {
if (name == 'password' || name == 'passwd') {
prop.type = 'password';
}
if (_.string.endsWith(name, "File")) {
prop.type = 'file';
}
if (prop.type == 'boolean') {
prop.type = 'checkbox';
}
prop.required = _.contains(type.configuration_schema.required, name);
});
});
});
$scope.$watch('target.type', function(current, prev) {
if (prev !== current) {
if (prev !== undefined) {
$scope.target.options = {};
}
setType($scope.types);
}
});
$scope.saveChanges = function() {
$scope.target.$save(function() {
growl.addSuccessMessage("Saved.");
}, function() {
growl.addErrorMessage("Failed saving.");
});
}
}
}
}]);
directives.directive('pageHeader', function() {
return {
@@ -407,10 +487,49 @@
scope.usersPage = _.string.startsWith($location.path(), '/users');
scope.groupsPage = _.string.startsWith($location.path(), '/groups');
scope.dsPage = _.string.startsWith($location.path(), '/data_sources');
scope.destinationsPage = _.string.startsWith($location.path(), '/destinations');
scope.showGroupsLink = currentUser.hasPermission('list_users');
scope.showUsersLink = currentUser.hasPermission('list_users');
scope.showDsLink = currentUser.hasPermission('admin');
scope.showDestinationsLink = currentUser.hasPermission('admin');
}
}
}]);
directives.directive('parameters', ['$location', '$modal', function($location, $modal) {
return {
restrict: 'E',
transclude: true,
scope: {
'parameters': '=',
'syncValues': '=?',
'editable': '=?'
},
templateUrl: '/views/directives/parameters.html',
link: function(scope, elem, attrs) {
// is this the correct location for this logic?
if (scope.syncValues !== false) {
scope.$watch('parameters', function() {
_.each(scope.parameters, function(param) {
if (param.value !== null || param.value !== '') {
$location.search('p_' + param.name, param.value);
}
})
}, true);
}
scope.showParameterSettings = function(param) {
$modal.open({
templateUrl: '/views/dialogs/parameter_settings.html',
controller: ['$scope', '$modalInstance', function($scope, $modalInstance) {
$scope.close = function() {
$modalInstance.close();
};
$scope.parameter = param;
}]
})
}
}
}
}]);

View File

@@ -45,59 +45,82 @@
});
};
var normalAreaStacking = function(seriesList) {
fillXValues(seriesList);
var storeOriginalHeightForEachSeries = function(seriesList) {
_.each(seriesList, function(series) {
if(!_.has(series,'visible')){
series.visible = true;
series.original_y = series.y.slice();
}
});
};
var getEnabledSeries = function(seriesList){
return _.filter(seriesList, function(series) {
return series.visible === true;
});
};
var initializeTextAndHover = function(seriesList){
_.each(seriesList, function(series) {
series.text = [];
series.hoverinfo = 'text+name';
});
for (var i = 0; i < seriesList.length; i++) {
for (var j = 0; j < seriesList[i].y.length; j++) {
var sum = i > 0 ? seriesList[i-1].y[j] : 0;
seriesList[i].text.push('Value: ' + seriesList[i].y[j] + '<br>Sum: ' + (sum + seriesList[i].y[j]));
seriesList[i].y[j] += sum;
};
var normalAreaStacking = function(seriesList) {
fillXValues(seriesList);
storeOriginalHeightForEachSeries(seriesList);
initializeTextAndHover(seriesList);
seriesList = getEnabledSeries(seriesList);
_.each(seriesList, function(series, seriesIndex, list){
_.each(series.y, function(undefined, yIndex, undefined2){
var cumulativeHeightOfPreviousSeries = seriesIndex > 0 ? list[seriesIndex-1].y[yIndex] : 0;
var cumulativeHeightWithThisSeries = cumulativeHeightOfPreviousSeries + series.original_y[yIndex];
series.y[yIndex] = cumulativeHeightWithThisSeries;
series.text.push('Value: ' + series.original_y[yIndex] + '<br>Sum: ' + cumulativeHeightWithThisSeries);
});
});
};
var lastVisibleY = function(seriesList, lastSeriesIndex, yIndex){
for(; lastSeriesIndex >= 0; lastSeriesIndex--){
if(seriesList[lastSeriesIndex].visible === true){
return seriesList[lastSeriesIndex].y[yIndex];
}
}
};
return 0;
}
var percentAreaStacking = function(seriesList) {
if (seriesList.length === 0) {
return;
}
fillXValues(seriesList);
_.each(seriesList, function(series) {
series.text = [];
series.hoverinfo = 'text+name';
storeOriginalHeightForEachSeries(seriesList);
initializeTextAndHover(seriesList);
_.each(seriesList[0].y, function(seriesY, yIndex, undefined){
var sumOfCorrespondingDataPoints = _.reduce(seriesList, function(total, series){
return total + series.original_y[yIndex];
}, 0);
_.each(seriesList, function(series, seriesIndex, list){
var percentage = (series.original_y[yIndex] / sumOfCorrespondingDataPoints ) * 100;
var previousVisiblePercentage = lastVisibleY(seriesList, seriesIndex-1, yIndex);
series.y[yIndex] = percentage + previousVisiblePercentage;
series.text.push('Value: ' + series.original_y[yIndex] + '<br>Relative: ' + percentage.toFixed(2) + '%');
});
});
for (var i = 0; i < seriesList[0].y.length; i++) {
var sum = 0;
for(var j = 0; j < seriesList.length; j++) {
sum += seriesList[j].y[i];
}
for(var j = 0; j < seriesList.length; j++) {
var value = seriesList[j].y[i] / sum * 100;
seriesList[j].text.push('Value: ' + seriesList[j].y[i] + '<br>Relative: ' + value.toFixed(2) + '%');
seriesList[j].y[i] = value;
if (j > 0) {
seriesList[j].y[i] += seriesList[j-1].y[i];
}
}
}
};
var percentBarStacking = function(seriesList) {
if (seriesList.length === 0) {
return;
}
fillXValues(seriesList);
_.each(seriesList, function(series) {
series.text = [];
series.hoverinfo = 'text+name';
});
initializeTextAndHover(seriesList);
for (var i = 0; i < seriesList[0].y.length; i++) {
var sum = 0;
for(var j = 0; j < seriesList.length; j++) {
@@ -121,6 +144,7 @@
angular.module('plotly', [])
.constant('ColorPalette', ColorPalette)
.directive('plotlyChart', function () {
var bottomMargin = 50;
return {
restrict: 'E',
template: '<div></div>',
@@ -158,9 +182,18 @@
return ColorPaletteArray[index % ColorPaletteArray.length];
};
var calculateHeight = function() {
var height = Math.max(scope.height, (scope.height - 50) + bottomMargin);
return height;
}
var recalculateOptions = function() {
scope.data.length = 0;
scope.layout.showlegend = _.has(scope.options, 'legend') ? scope.options.legend.enabled : true;
if(_.has(scope.options, 'bottomMargin')) {
bottomMargin = parseInt(scope.options.bottomMargin);
scope.layout.margin.b = bottomMargin;
}
delete scope.layout.barmode;
delete scope.layout.xaxis;
delete scope.layout.yaxis;
@@ -276,18 +309,39 @@
percentBarStacking(scope.data);
}
}
scope.layout.margin.b = bottomMargin;
scope.layout.height = calculateHeight();
};
scope.$watch('series', recalculateOptions);
scope.$watch('options', recalculateOptions, true);
scope.layout = {margin: {l: 50, r: 50, b: 50, t: 20, pad: 4}, height: scope.height, autosize: true, hovermode: 'closest'};
scope.layout = {margin: {l: 50, r: 50, b: bottomMargin, t: 20, pad: 4}, height: calculateHeight(), autosize: true, hovermode: 'closest'};
scope.plotlyOptions = {showLink: false, displaylogo: false};
scope.data = [];
var element = element[0].children[0];
Plotly.newPlot(element, scope.data, scope.layout, scope.plotlyOptions);
element.on('plotly_afterplot', function(d) {
if(scope.options.globalSeriesType === 'area' && (scope.options.series.stacking === 'normal' || scope.options.series.stacking === 'percent')){
$(element).find(".legendtoggle").each(function(i, rectDiv) {
d3.select(rectDiv).on('click', function () {
var maxIndex = scope.data.length - 1;
var itemClicked = scope.data[maxIndex - i];
itemClicked.visible = (itemClicked.visible === true) ? 'legendonly' : true;
if (scope.options.series.stacking === 'normal') {
normalAreaStacking(scope.data);
} else if (scope.options.series.stacking === 'percent') {
percentAreaStacking(scope.data);
}
Plotly.redraw(element);
});
});
}
});
scope.$watch('layout', function (layout, old) {
if (angular.equals(layout, old)) {
return;

View File

@@ -10,29 +10,29 @@
},
template: '<a ng-href="{{link}}" class="query-link">{{query.name}}</a>',
link: function(scope, element) {
scope.link = 'queries/' + scope.query.id;
var hash = null;
if (scope.visualization) {
if (scope.visualization.type === 'TABLE') {
// link to hard-coded table tab instead of the (hidden) visualization tab
scope.link += '#table';
hash = 'table';
} else {
scope.link += '#' + scope.visualization.id;
hash = scope.visualization.id;
}
}
// element.find('a').attr('href', link);
scope.link = scope.query.getUrl(false, hash);
}
}
}
function querySourceLink() {
function querySourceLink($location) {
return {
restrict: 'E',
template: '<span ng-show="query.id && canViewSource">\
<a ng-show="!sourceMode"\
ng-href="queries/{{query.id}}/source#{{selectedTab}}" class="btn btn-default">Show Source\
ng-href="{{query.getUrl(true, selectedTab)}}" class="btn btn-default">Show Source\
</a>\
<a ng-show="sourceMode"\
ng-href="queries/{{query.id}}#{{selectedTab}}" class="btn btn-default">Hide Source\
ng-href="{{query.getUrl(false, selectedTab)}}" class="btn btn-default">Hide Source\
</a>\
</span>'
}
@@ -156,7 +156,7 @@
};
}
function queryFormatter($http) {
function queryFormatter($http, growl) {
return {
restrict: 'E',
// don't create new scope to avoid ui-codemirror bug
@@ -165,18 +165,29 @@
template: '<button type="button" class="btn btn-default btn-s"\
ng-click="formatQuery()">\
<span class="zmdi zmdi-format-indent-increase"></span>\
Format SQL\
Format Query\
</button>',
link: function($scope) {
$scope.formatQuery = function formatQuery() {
if ($scope.dataSource.syntax == 'json') {
try {
$scope.query.query = JSON.stringify(JSON.parse($scope.query.query), ' ', 4);
} catch(err) {
growl.addErrorMessage(err);
}
} else if ($scope.dataSource.syntax =='sql') {
$scope.queryFormatting = true;
$http.post('api/queries/format', {
'query': $scope.query.query
'query': $scope.query.query
}).success(function (response) {
$scope.query.query = response;
$scope.query.query = response;
}).finally(function () {
$scope.queryFormatting = false;
});
} else {
growl.addInfoMessage("Query formatting is not supported for your data source syntax.");
}
};
}
}
@@ -263,11 +274,15 @@
});
$scope.refreshOptions.push({
value: String(7 * 24 * 3600),
name: 'Once a week'
name: 'Every 7 days'
});
$scope.refreshOptions.push({
value: String(14 * 24 * 3600),
name: 'Every 14 days'
});
$scope.refreshOptions.push({
value: String(30 * 24 * 3600),
name: 'Every 30d'
name: 'Every 30 days'
});
$scope.$watch('refreshType', function() {
@@ -285,10 +300,10 @@
angular.module('redash.directives')
.directive('queryLink', queryLink)
.directive('querySourceLink', querySourceLink)
.directive('querySourceLink', ['$location', querySourceLink])
.directive('queryResultLink', queryResultLink)
.directive('queryEditor', queryEditor)
.directive('queryRefreshSelect', queryRefreshSelect)
.directive('queryTimePicker', queryTimePicker)
.directive('queryFormatter', ['$http', queryFormatter]);
.directive('queryFormatter', ['$http', 'growl', queryFormatter]);
})();

View File

@@ -120,4 +120,10 @@ angular.module('redash.filters', []).
filtered.push(items[i])
return filtered;
};
})
.filter('notEmpty', function() {
return function(collection) {
return !_.isEmpty(collection);
}
});

View File

@@ -42,7 +42,7 @@
return;
}
if (Notification.permission !== "granted") {
if (Notification.permission === "default") {
Notification.requestPermission(function (status) {
if (Notification.permission !== status) {
Notification.permission = status;

View File

@@ -174,9 +174,14 @@
};
return (memo && _.some(filter.current, function(v) {
// We compare with either the value or the String representation of the value,
// because Select2 casts true/false to "true"/"false".
return v == row[filter.name] || String(row[filter.name]) == v
var value = row[filter.name];
if (moment.isMoment(value)) {
return value.isSame(v);
} else {
// We compare with either the value or the String representation of the value,
// because Select2 casts true/false to "true"/"false".
return (v == value || String(value) == v);
}
}));
}, true);
});
@@ -279,7 +284,7 @@
var typeSplit;
if (column.indexOf("::") != -1) {
typeSplit = "::";
} else if (column.indexOf("__" != -1)) {
} else if (column.indexOf("__") != -1) {
typeSplit = "__";
} else {
return column;
@@ -353,7 +358,13 @@
});
_.each(filters, function(filter) {
filter.values = _.uniq(filter.values);
filter.values = _.uniq(filter.values, function(v) {
if (moment.isMoment(v)) {
return v.unix();
} else {
return v;
}
});
});
this.filters = filters;
@@ -372,7 +383,10 @@
refreshStatus(queryResult, query);
}, 3000);
}
})
}, function(error) {
console.log("Connection error", error);
queryResult.update({job: {error: 'failed communicating with server. Please check your Internet connection and try again.', status: 4}})
});
}
QueryResult.getById = function (id) {
@@ -406,6 +420,11 @@
}, function(error) {
if (error.status === 403) {
queryResult.update(error.data);
} else if (error.status === 400 && 'job' in error.data) {
queryResult.update(error.data);
} else {
console.log("Unknown error", error);
queryResult.update({job: {error: 'unknown error occurred. Please try again later.', status: 4}})
}
});
@@ -415,7 +434,7 @@
return QueryResult;
};
var Query = function ($resource, QueryResult, DataSource) {
var Query = function ($resource, $location, QueryResult) {
var Query = $resource('api/queries/:id', {id: '@id'},
{
search: {
@@ -427,32 +446,19 @@
method: 'get',
isArray: true,
url: "api/queries/recent"
}});
}
});
Query.newQuery = function () {
return new Query({
query: "",
name: "New Query",
schedule: null,
user: currentUser
user: currentUser,
options: {}
});
};
Query.collectParamsFromQueryString = function($location, query) {
var parameterNames = query.getParameters();
var parameters = {};
var queryString = $location.search();
_.each(parameterNames, function(param, i) {
var qsName = "p_" + param;
if (qsName in queryString) {
parameters[param] = queryString[qsName];
}
});
return parameters;
};
Query.prototype.getSourceLink = function () {
return '/queries/' + this.id + '/source';
};
@@ -475,32 +481,31 @@
};
Query.prototype.paramsRequired = function() {
var queryParameters = this.getParameters();
return !_.isEmpty(queryParameters);
return this.getParameters().isRequired();
};
Query.prototype.getQueryResult = function (maxAge, parameters) {
Query.prototype.getQueryResult = function (maxAge) {
if (!this.query) {
return;
}
var queryText = this.query;
var queryParameters = this.getParameters();
var paramsRequired = !_.isEmpty(queryParameters);
var parameters = this.getParameters();
var missingParams = parameters.getMissing();
var missingParams = parameters === undefined ? queryParameters : _.difference(queryParameters, _.keys(parameters));
if (paramsRequired && missingParams.length > 0) {
if (missingParams.length > 0) {
var paramsWord = "parameter";
var valuesWord = "value";
if (missingParams.length > 1) {
paramsWord = "parameters";
valuesWord = "values";
}
return new QueryResult({job: {error: "Missing values for " + missingParams.join(', ') + " "+paramsWord+".", status: 4}});
return new QueryResult({job: {error: "missing " + valuesWord + " for " + missingParams.join(', ') + " "+paramsWord+".", status: 4}});
}
if (paramsRequired) {
queryText = Mustache.render(queryText, parameters);
if (parameters.isRequired()) {
queryText = Mustache.render(queryText, parameters.getValues());
// Need to clear latest results, to make sure we don't use results for different params.
this.latest_query_data = null;
@@ -524,35 +529,143 @@
return this.queryResult;
};
Query.prototype.getUrl = function(source, hash) {
var url = "queries/" + this.id;
if (source) {
url += '/source';
}
var params = "";
if (this.getParameters().isRequired()) {
_.each(this.getParameters().getValues(), function(value, name) {
if (value === null) {
return;
}
if (params !== "") {
params += "&";
}
params += 'p_' + encodeURIComponent(name) + "=" + encodeURIComponent(value);
});
}
if (params !== "") {
url += "?" + params;
}
if (hash) {
url += "#" + hash;
}
return url;
}
Query.prototype.getQueryResultPromise = function() {
return this.getQueryResult().toPromise();
};
Query.prototype.getParameters = function() {
var parts = Mustache.parse(this.query);
var parameters = [];
var collectParams = function(parts) {
parameters = [];
_.each(parts, function(part) {
if (part[0] == 'name' || part[0] == '&') {
parameters.push(part[1]);
} else if (part[0] == '#') {
parameters = _.union(parameters, collectParams(part[4]));
var Parameters = function(query) {
this.query = query;
this.parseQuery = function() {
var parts = Mustache.parse(this.query.query);
var parameters = [];
var collectParams = function(parts) {
parameters = [];
_.each(parts, function(part) {
if (part[0] == 'name' || part[0] == '&') {
parameters.push(part[1]);
} else if (part[0] == '#') {
parameters = _.union(parameters, collectParams(part[4]));
}
});
return parameters;
};
parameters = _.uniq(collectParams(parts));
return parameters;
}
this.updateParameters = function() {
if (this.query.query === this.cachedQueryText) {
return;
}
this.cachedQueryText = this.query.query;
var parameterNames = this.parseQuery();
this.query.options.parameters = this.query.options.parameters || [];
var parametersMap = {};
_.each(this.query.options.parameters, function(param) {
parametersMap[param.name] = param;
});
_.each(parameterNames, function(param) {
if (!_.has(parametersMap, param)) {
this.query.options.parameters.push({
'title': param,
'name': param,
'type': 'text',
'value': null
});
}
}.bind(this));
this.query.options.parameters = _.filter(this.query.options.parameters, function(p) { return _.indexOf(parameterNames, p.name) !== -1});
}
this.initFromQueryString = function() {
var queryString = $location.search();
_.each(this.get(), function(param) {
var queryStringName = 'p_' + param.name;
if (_.has(queryString, queryStringName)) {
param.value = queryString[queryStringName];
}
});
return parameters;
};
}
parameters = collectParams(parts);
this.updateParameters();
this.initFromQueryString();
}
return parameters;
Parameters.prototype.get = function() {
this.updateParameters();
return this.query.options.parameters;
};
Parameters.prototype.getMissing = function() {
return _.pluck(_.filter(this.get(), function(p) { return p.value === null || p.value === ''; }), 'title');
}
Parameters.prototype.isRequired = function() {
return !_.isEmpty(this.get());
}
Parameters.prototype.getValues = function() {
var params = this.get();
return _.object(_.pluck(params, 'name'), _.pluck(params, 'value'));
}
Query.prototype.getParameters = function() {
if (!this.$parameters) {
this.$parameters = new Parameters(this);
}
return this.$parameters;
}
Query.prototype.getParametersDefs = function() {
return this.getParameters().get();
}
return Query;
};
var DataSource = function ($resource) {
var actions = {
'get': {'method': 'GET', 'cache': false, 'isArray': false},
@@ -565,6 +678,17 @@
return DataSourceResource;
};
var Destination = function ($resource) {
var actions = {
'get': {'method': 'GET', 'cache': false, 'isArray': false},
'query': {'method': 'GET', 'cache': false, 'isArray': true}
};
var DestinationResource = $resource('api/destinations/:id', {id: '@id'}, actions);
return DestinationResource;
};
var User = function ($resource, $http) {
var transformSingle = function(user) {
if (user.groups !== undefined) {
@@ -605,7 +729,7 @@
};
var AlertSubscription = function ($resource) {
var resource = $resource('api/alerts/:alertId/subscriptions/:userId', {alertId: '@alert_id', userId: '@user.id'});
var resource = $resource('api/alerts/:alertId/subscriptions/:subscriberId', {alertId: '@alert_id', subscriberId: '@id'});
return resource;
};
@@ -617,7 +741,9 @@
var newData = _.extend({}, data);
if (newData.query_id === undefined) {
newData.query_id = newData.query.id;
newData.destination_id = newData.destinations;
delete newData.query;
delete newData.destinations;
}
return newData;
@@ -652,8 +778,9 @@
angular.module('redash.services')
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
.factory('Query', ['$resource', '$location', 'QueryResult', Query])
.factory('DataSource', ['$resource', DataSource])
.factory('Destination', ['$resource', Destination])
.factory('Alert', ['$resource', '$http', Alert])
.factory('AlertSubscription', ['$resource', AlertSubscription])
.factory('Widget', ['$resource', 'Query', Widget])

505
rd_ui/app/scripts/vendor/cloud.js vendored Normal file
View File

@@ -0,0 +1,505 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.d3||(g.d3 = {}));g=(g.layout||(g.layout = {}));g.cloud = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
// Word cloud layout by Jason Davies, https://www.jasondavies.com/wordcloud/
// Algorithm due to Jonathan Feinberg, http://static.mrfeinberg.com/bv_ch03.pdf
var dispatch = require("d3-dispatch").dispatch;
var cloudRadians = Math.PI / 180,
cw = 1 << 11 >> 5,
ch = 1 << 11;
d3.cloud = function() {
var size = [256, 256],
text = cloudText,
font = cloudFont,
fontSize = cloudFontSize,
fontStyle = cloudFontNormal,
fontWeight = cloudFontNormal,
rotate = cloudRotate,
padding = cloudPadding,
spiral = archimedeanSpiral,
words = [],
timeInterval = Infinity,
event = dispatch("word", "end"),
timer = null,
random = Math.random,
cloud = {},
canvas = cloudCanvas;
cloud.canvas = function(_) {
return arguments.length ? (canvas = functor(_), cloud) : canvas;
};
cloud.start = function() {
var contextAndRatio = getContext(canvas()),
board = zeroArray((size[0] >> 5) * size[1]),
bounds = null,
n = words.length,
i = -1,
tags = [],
data = words.map(function(d, i) {
d.text = text.call(this, d, i);
d.font = font.call(this, d, i);
d.style = fontStyle.call(this, d, i);
d.weight = fontWeight.call(this, d, i);
d.rotate = rotate.call(this, d, i);
d.size = ~~fontSize.call(this, d, i);
d.padding = padding.call(this, d, i);
return d;
}).sort(function(a, b) { return b.size - a.size; });
if (timer) clearInterval(timer);
timer = setInterval(step, 0);
step();
return cloud;
function step() {
var start = Date.now();
while (Date.now() - start < timeInterval && ++i < n && timer) {
var d = data[i];
d.x = (size[0] * (random() + .5)) >> 1;
d.y = (size[1] * (random() + .5)) >> 1;
cloudSprite(contextAndRatio, d, data, i);
if (d.hasText && place(board, d, bounds)) {
tags.push(d);
event.word(d);
if (bounds) cloudBounds(bounds, d);
else bounds = [{x: d.x + d.x0, y: d.y + d.y0}, {x: d.x + d.x1, y: d.y + d.y1}];
// Temporary hack
d.x -= size[0] >> 1;
d.y -= size[1] >> 1;
}
}
if (i >= n) {
cloud.stop();
event.end(tags, bounds);
}
}
}
cloud.stop = function() {
if (timer) {
clearInterval(timer);
timer = null;
}
return cloud;
};
function getContext(canvas) {
canvas.width = canvas.height = 1;
var ratio = Math.sqrt(canvas.getContext("2d").getImageData(0, 0, 1, 1).data.length >> 2);
canvas.width = (cw << 5) / ratio;
canvas.height = ch / ratio;
var context = canvas.getContext("2d");
context.fillStyle = context.strokeStyle = "red";
context.textAlign = "center";
return {context: context, ratio: ratio};
}
function place(board, tag, bounds) {
var perimeter = [{x: 0, y: 0}, {x: size[0], y: size[1]}],
startX = tag.x,
startY = tag.y,
maxDelta = Math.sqrt(size[0] * size[0] + size[1] * size[1]),
s = spiral(size),
dt = random() < .5 ? 1 : -1,
t = -dt,
dxdy,
dx,
dy;
while (dxdy = s(t += dt)) {
dx = ~~dxdy[0];
dy = ~~dxdy[1];
if (Math.min(Math.abs(dx), Math.abs(dy)) >= maxDelta) break;
tag.x = startX + dx;
tag.y = startY + dy;
if (tag.x + tag.x0 < 0 || tag.y + tag.y0 < 0 ||
tag.x + tag.x1 > size[0] || tag.y + tag.y1 > size[1]) continue;
// TODO only check for collisions within current bounds.
if (!bounds || !cloudCollide(tag, board, size[0])) {
if (!bounds || collideRects(tag, bounds)) {
var sprite = tag.sprite,
w = tag.width >> 5,
sw = size[0] >> 5,
lx = tag.x - (w << 4),
sx = lx & 0x7f,
msx = 32 - sx,
h = tag.y1 - tag.y0,
x = (tag.y + tag.y0) * sw + (lx >> 5),
last;
for (var j = 0; j < h; j++) {
last = 0;
for (var i = 0; i <= w; i++) {
board[x + i] |= (last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0);
}
x += sw;
}
delete tag.sprite;
return true;
}
}
}
return false;
}
cloud.timeInterval = function(_) {
return arguments.length ? (timeInterval = _ == null ? Infinity : _, cloud) : timeInterval;
};
cloud.words = function(_) {
return arguments.length ? (words = _, cloud) : words;
};
cloud.size = function(_) {
return arguments.length ? (size = [+_[0], +_[1]], cloud) : size;
};
cloud.font = function(_) {
return arguments.length ? (font = functor(_), cloud) : font;
};
cloud.fontStyle = function(_) {
return arguments.length ? (fontStyle = functor(_), cloud) : fontStyle;
};
cloud.fontWeight = function(_) {
return arguments.length ? (fontWeight = functor(_), cloud) : fontWeight;
};
cloud.rotate = function(_) {
return arguments.length ? (rotate = functor(_), cloud) : rotate;
};
cloud.text = function(_) {
return arguments.length ? (text = functor(_), cloud) : text;
};
cloud.spiral = function(_) {
return arguments.length ? (spiral = spirals[_] || _, cloud) : spiral;
};
cloud.fontSize = function(_) {
return arguments.length ? (fontSize = functor(_), cloud) : fontSize;
};
cloud.padding = function(_) {
return arguments.length ? (padding = functor(_), cloud) : padding;
};
cloud.random = function(_) {
return arguments.length ? (random = _, cloud) : random;
};
cloud.on = function() {
var value = event.on.apply(event, arguments);
return value === event ? cloud : value;
};
return cloud;
};
function cloudText(d) {
return d.text;
}
function cloudFont() {
return "serif";
}
function cloudFontNormal() {
return "normal";
}
function cloudFontSize(d) {
return Math.sqrt(d.value);
}
function cloudRotate() {
return (~~(Math.random() * 6) - 3) * 30;
}
function cloudPadding() {
return 1;
}
// Fetches a monochrome sprite bitmap for the specified text.
// Load in batches for speed.
function cloudSprite(contextAndRatio, d, data, di) {
if (d.sprite) return;
var c = contextAndRatio.context,
ratio = contextAndRatio.ratio;
c.clearRect(0, 0, (cw << 5) / ratio, ch / ratio);
var x = 0,
y = 0,
maxh = 0,
n = data.length;
--di;
while (++di < n) {
d = data[di];
c.save();
c.font = d.style + " " + d.weight + " " + ~~((d.size + 1) / ratio) + "px " + d.font;
var w = c.measureText(d.text + "m").width * ratio,
h = d.size << 1;
if (d.rotate) {
var sr = Math.sin(d.rotate * cloudRadians),
cr = Math.cos(d.rotate * cloudRadians),
wcr = w * cr,
wsr = w * sr,
hcr = h * cr,
hsr = h * sr;
w = (Math.max(Math.abs(wcr + hsr), Math.abs(wcr - hsr)) + 0x1f) >> 5 << 5;
h = ~~Math.max(Math.abs(wsr + hcr), Math.abs(wsr - hcr));
} else {
w = (w + 0x1f) >> 5 << 5;
}
if (h > maxh) maxh = h;
if (x + w >= (cw << 5)) {
x = 0;
y += maxh;
maxh = 0;
}
if (y + h >= ch) break;
c.translate((x + (w >> 1)) / ratio, (y + (h >> 1)) / ratio);
if (d.rotate) c.rotate(d.rotate * cloudRadians);
c.fillText(d.text, 0, 0);
if (d.padding) c.lineWidth = 2 * d.padding, c.strokeText(d.text, 0, 0);
c.restore();
d.width = w;
d.height = h;
d.xoff = x;
d.yoff = y;
d.x1 = w >> 1;
d.y1 = h >> 1;
d.x0 = -d.x1;
d.y0 = -d.y1;
d.hasText = true;
x += w;
}
var pixels = c.getImageData(0, 0, (cw << 5) / ratio, ch / ratio).data,
sprite = [];
while (--di >= 0) {
d = data[di];
if (!d.hasText) continue;
var w = d.width,
w32 = w >> 5,
h = d.y1 - d.y0;
// Zero the buffer
for (var i = 0; i < h * w32; i++) sprite[i] = 0;
x = d.xoff;
if (x == null) return;
y = d.yoff;
var seen = 0,
seenRow = -1;
for (var j = 0; j < h; j++) {
for (var i = 0; i < w; i++) {
var k = w32 * j + (i >> 5),
m = pixels[((y + j) * (cw << 5) + (x + i)) << 2] ? 1 << (31 - (i % 32)) : 0;
sprite[k] |= m;
seen |= m;
}
if (seen) seenRow = j;
else {
d.y0++;
h--;
j--;
y++;
}
}
d.y1 = d.y0 + seenRow;
d.sprite = sprite.slice(0, (d.y1 - d.y0) * w32);
}
}
// Use mask-based collision detection.
function cloudCollide(tag, board, sw) {
sw >>= 5;
var sprite = tag.sprite,
w = tag.width >> 5,
lx = tag.x - (w << 4),
sx = lx & 0x7f,
msx = 32 - sx,
h = tag.y1 - tag.y0,
x = (tag.y + tag.y0) * sw + (lx >> 5),
last;
for (var j = 0; j < h; j++) {
last = 0;
for (var i = 0; i <= w; i++) {
if (((last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0))
& board[x + i]) return true;
}
x += sw;
}
return false;
}
function cloudBounds(bounds, d) {
var b0 = bounds[0],
b1 = bounds[1];
if (d.x + d.x0 < b0.x) b0.x = d.x + d.x0;
if (d.y + d.y0 < b0.y) b0.y = d.y + d.y0;
if (d.x + d.x1 > b1.x) b1.x = d.x + d.x1;
if (d.y + d.y1 > b1.y) b1.y = d.y + d.y1;
}
function collideRects(a, b) {
return a.x + a.x1 > b[0].x && a.x + a.x0 < b[1].x && a.y + a.y1 > b[0].y && a.y + a.y0 < b[1].y;
}
function archimedeanSpiral(size) {
var e = size[0] / size[1];
return function(t) {
return [e * (t *= .1) * Math.cos(t), t * Math.sin(t)];
};
}
function rectangularSpiral(size) {
var dy = 4,
dx = dy * size[0] / size[1],
x = 0,
y = 0;
return function(t) {
var sign = t < 0 ? -1 : 1;
// See triangular numbers: T_n = n * (n + 1) / 2.
switch ((Math.sqrt(1 + 4 * sign * t) - sign) & 3) {
case 0: x += dx; break;
case 1: y += dy; break;
case 2: x -= dx; break;
default: y -= dy; break;
}
return [x, y];
};
}
// TODO reuse arrays?
function zeroArray(n) {
var a = [],
i = -1;
while (++i < n) a[i] = 0;
return a;
}
function cloudCanvas() {
return document.createElement("canvas");
}
function functor(d) {
return typeof d === "function" ? d : function() { return d; };
}
var spirals = {
archimedean: archimedeanSpiral,
rectangular: rectangularSpiral
};
},{"d3-dispatch":2}],2:[function(require,module,exports){
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
factory((global.dispatch = {}));
}(this, function (exports) { 'use strict';
function Dispatch(types) {
var i = -1,
n = types.length,
callbacksByType = {},
callbackByName = {},
type,
that = this;
that.on = function(type, callback) {
type = parseType(type);
// Return the current callback, if any.
if (arguments.length < 2) {
return (callback = callbackByName[type.name]) && callback.value;
}
// If a type was specified…
if (type.type) {
var callbacks = callbacksByType[type.type],
callback0 = callbackByName[type.name],
i;
// Remove the current callback, if any, using copy-on-remove.
if (callback0) {
callback0.value = null;
i = callbacks.indexOf(callback0);
callbacksByType[type.type] = callbacks = callbacks.slice(0, i).concat(callbacks.slice(i + 1));
delete callbackByName[type.name];
}
// Add the new callback, if any.
if (callback) {
callback = {value: callback};
callbackByName[type.name] = callback;
callbacks.push(callback);
}
}
// Otherwise, if a null callback was specified, remove all callbacks with the given name.
else if (callback == null) {
for (var otherType in callbacksByType) {
if (callback = callbackByName[otherType + type.name]) {
callback.value = null;
var callbacks = callbacksByType[otherType], i = callbacks.indexOf(callback);
callbacksByType[otherType] = callbacks.slice(0, i).concat(callbacks.slice(i + 1));
delete callbackByName[callback.name];
}
}
}
return that;
};
while (++i < n) {
type = types[i] + "";
if (!type || (type in that)) throw new Error("illegal or duplicate type: " + type);
callbacksByType[type] = [];
that[type] = applier(type);
}
function parseType(type) {
var i = (type += "").indexOf("."), name = type;
if (i >= 0) type = type.slice(0, i); else name += ".";
if (type && !callbacksByType.hasOwnProperty(type)) throw new Error("unknown type: " + type);
return {type: type, name: name};
}
function applier(type) {
return function() {
var callbacks = callbacksByType[type], // Defensive reference; copy-on-remove.
callback,
callbackValue,
i = -1,
n = callbacks.length;
while (++i < n) {
if (callbackValue = (callback = callbacks[i]).value) {
callbackValue.apply(this, arguments);
}
}
return that;
};
}
}
function dispatch() {
return new Dispatch(arguments);
}
dispatch.prototype = Dispatch.prototype; // allow instanceof
exports.dispatch = dispatch;
}));
},{}]},{},[1])(1)
});

View File

@@ -13,7 +13,8 @@
xAxis: {type: 'datetime', labels: {enabled: true}},
series: {stacking: null},
seriesOptions: {},
columnMapping: {}
columnMapping: {},
bottomMargin: 50
};
VisualizationProvider.registerVisualization({
@@ -93,7 +94,7 @@
};
scope.xAxisScales = ['datetime', 'linear', 'logarithmic', 'category'];
scope.yAxisScales = ['linear', 'logarithmic'];
scope.yAxisScales = ['linear', 'logarithmic', 'datetime'];
var refreshColumns = function() {
scope.columns = scope.queryResult.getColumns();
@@ -194,6 +195,10 @@
scope.options.legend = {enabled: true};
}
if (!_.has(scope.options, 'bottomMargin')) {
scope.options.bottomMargin = 50;
}
if (scope.columnNames)
_.each(scope.options.columnMapping, function(value, key) {
if (scope.columnNames.length > 0 && !_.contains(scope.columnNames, key))

View File

@@ -1,43 +0,0 @@
(function (window) {
var module = angular.module('redash.visualization');
module.directive('dateRangeSelector', [function () {
return {
restrict: 'E',
scope: {
dateRange: "="
},
templateUrl: '/views/visualizations/date_range_selector.html',
replace: true,
controller: ['$scope', function ($scope) {
$scope.dateRangeHuman = {
min: null,
max: null
};
$scope.$watch('dateRange', function (dateRange, oldDateRange, scope) {
scope.dateRangeHuman.min = dateRange.min.format('YYYY-MM-DD');
scope.dateRangeHuman.max = dateRange.max.format('YYYY-MM-DD');
});
$scope.$watch('dateRangeHuman', function (dateRangeHuman, oldDateRangeHuman, scope) {
var newDateRangeMin = moment.utc(dateRangeHuman.min);
var newDateRangeMax = moment.utc(dateRangeHuman.max);
if (!newDateRangeMin ||
!newDateRangeMax ||
!newDateRangeMin.isValid() ||
!newDateRangeMax.isValid() ||
newDateRangeMin.isAfter(newDateRangeMax)) {
// Prevent invalid date input
// No need to show up a notification to user here, it will be too noisy.
// Instead, simply preventing changes to the scope silently.
scope.dateRangeHuman = oldDateRangeHuman;
return;
}
scope.dateRange.min = newDateRangeMin;
scope.dateRange.max = newDateRangeMax;
}, true);
}]
}
}]);
})(window);

View File

@@ -0,0 +1,97 @@
(function () {
var wordCloudVisualization = angular.module('redash.visualization');
wordCloudVisualization.config(['VisualizationProvider', function (VisualizationProvider) {
VisualizationProvider.registerVisualization({
type: 'WORD_CLOUD',
name: 'Word Cloud',
renderTemplate: '<word-cloud-renderer options="visualization.options" query-result="queryResult"></word-cloud-renderer>',
editorTemplate: '<word-cloud-editor></word-cloud-editor>'
});
}]);
wordCloudVisualization.directive('wordCloudRenderer', function () {
return {
restrict: 'E',
link: function($scope, elem, attrs) {
reloadCloud = function () {
if (!angular.isDefined($scope.queryResult)) retun;
data = $scope.queryResult.getData();
cloud = d3.cloud;
wordsHash = {};
if($scope.visualization.options.column){
data.map(function(d) {
d[$scope.visualization.options.column]
.toString()
.split(' ')
.map(function(d) {
if (d in wordsHash) {
wordsHash[d]+=1;
} else {
wordsHash[d]=1;
}
})
})
}
wordList = [];
for(var key in wordsHash) {
wordList.push({text: key, size: 10 + Math.pow(wordsHash[key],2)});
}
var fill = d3.scale.category20();
var layout = cloud()
.size([500, 500])
.words(wordList)
.padding(5)
.rotate(function() { return ~~(Math.random() * 2) * 90; })
.font("Impact")
.fontSize(function(d) { return d.size; })
.on("end", draw);
layout.start();
function draw(words) {
d3.select(elem[0].parentNode)
.select("svg")
.remove();
d3.select(elem[0].parentNode)
.append("svg")
.attr("width", layout.size()[0])
.attr("height", layout.size()[1])
.append("g")
.attr("transform", "translate(" + layout.size()[0] / 2 + "," + layout.size()[1] / 2 + ")")
.selectAll("text")
.data(words)
.enter().append("text")
.style("font-size", function(d) { return d.size + "px"; })
.style("font-family", "Impact")
.style("fill", function(d, i) { return fill(i); })
.attr("text-anchor", "middle")
.attr("transform", function(d) {
return "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")";
})
.text(function(d) { return d.text; });
}
}
$scope.$watch('queryResult && queryResult.getData()', reloadCloud);
$scope.$watch('visualization.options.column', reloadCloud);
}
}
});
wordCloudVisualization.directive('wordCloudEditor', function() {
return {
restrict: 'E',
templateUrl: '/views/visualizations/word_cloud_editor.html'
};
});
})();

View File

@@ -40,6 +40,8 @@
<!-- build:js /scripts/layout_vendor.js -->
<script src="/bower_components/jquery/jquery.js"></script>
<!-- endbuild -->
{% block scripts %}
{% endblock %}
{% include '_includes/signed_out_tail.html' %}

View File

@@ -158,23 +158,6 @@ a.navbar-brand img {
}
/* Visualization Filters */
.filters-container {
display: flex;
flex-wrap: wrap;
}
.filter {
width: 33%;
padding-left: 5px;
padding-bottom: 5px;
}
.filter > div {
width: 100%;
}
/* Gridster */
.gridster ul {
@@ -213,7 +196,7 @@ li.widget:hover {
}
.CodeMirror-scroll {
overflow-y: hidden;
overflow-y: auto;
overflow-x: auto;
}
@@ -657,3 +640,26 @@ div.table-name:hover {
.t-body a.actions.open > a {
background-color: rgba(0, 0, 0, 0.1);
}
/* ui-select adjustments for SuperFlat */
/* Same definition as .form-control */
.ui-select-toggle.btn-default {
height: 35px;
padding: 6px 12px;
font-size: 13px;
line-height: 1.42857143;
color: #9E9E9E;
background: #fff none;
border: 1px solid #e8e8e8;
border-radius: 5px;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
-webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
-o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
}
.t-header.widget {
padding: 5px;
}

View File

@@ -12,7 +12,7 @@
<ul class="tab-nav">
<rd-tab tab-id="in_progress" name="In Progress ({{tasks.in_progress.length}})" ng-click="setTab('in_progress')"></rd-tab>
<rd-tab tab-id="waiting" name="Waiting ({{tasks.waiting.length}})" ng-click="setTab('waiting')"></rd-tab>
<rd-tab tab-id="done" name="Done ({{tasks.done.length}})" ng-click="setTab('done')"></rd-tab>
<rd-tab tab-id="done" name="Done" ng-click="setTab('done')"></rd-tab>
</ul>
<smart-table rows="showingTasks" columns="gridColumns"

View File

@@ -0,0 +1,27 @@
<div class="p-5">
<h4>Notifications</h4>
<div>
<ui-select ng-model="newSubscription.destination" ng-disabled="destinations.length == 0">
<ui-select-match><span ng-bind-html="destinationsDisplay($select.selected)"></span></ui-select-match>
<ui-select-choices repeat="d in destinations">
<span ng-bind-html="destinationsDisplay(d)"></span>
</ui-select-choices>
</ui-select>
</div>
<div class="m-t-5">
<button class="btn btn-default" ng-click="saveSubscriber()" ng-disabled="destinations.length == 0" style="width:50%;">Add</button>
<span class="pull-right m-t-5">
<a href="destinations/new" ng-if="currentUser.isAdmin">Create New Destination</a>
</span>
</div>
<hr/>
<div>
<div class="list-group-item" ng-repeat="subscriber in subscribers">
<span ng-bind-html="destinationsDisplay(subscriber)"></span>
<button class="btn btn-xs btn-danger pull-right" ng-click="unsubscribe(subscriber)" ng-if="currentUser.isAdmin || currentUser.id == subscriber.user.id">Remove</button>
</div>
</div>
</div>

View File

@@ -7,10 +7,10 @@
<div class="container">
<div class="row bg-white p-10">
<div class="col-md-8">
<form name="alertForm" ng-submit="saveChanges()" class="form">
<form name="alertForm" class="form">
<div class="form-group">
<label>Query</label>
<ui-select ng-model="alert.query" reset-search-input="false" on-select="onQuerySelected($item)">
<ui-select ng-model="alert.query" reset-search-input="false" on-select="onQuerySelected($item)" ng-disabled="!canEdit">
<ui-select-match placeholder="Search a query by name">{{$select.selected.name}}</ui-select-match>
<ui-select-choices repeat="q in queries"
refresh="searchQueries($select.search)"
@@ -22,7 +22,7 @@
<div class="form-group" ng-show="selectedQuery">
<label>Name</label>
<input type="string" placeholder="{{getDefaultName()}}" class="form-control" ng-model="alert.name">
<input type="string" placeholder="{{getDefaultName()}}" class="form-control" ng-model="alert.name" ng-disabled="!canEdit">
</div>
<div ng-show="queryResult" class="form-horizontal">
@@ -30,7 +30,7 @@
<label class="control-label col-md-2">Value column</label>
<div class="col-md-4">
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="alert.options.column"
class="form-control"></select>
class="form-control" ng-disabled="!canEdit"></select>
</div>
<label class="control-label col-md-2">Value</label>
<div class="col-md-4">
@@ -40,29 +40,30 @@
<div class="form-group">
<label class="control-label col-md-2">Op</label>
<div class="col-md-4">
<select ng-options="name for name in ops" ng-model="alert.options.op" class="form-control"></select>
<select ng-options="name for name in ops" ng-model="alert.options.op" class="form-control" ng-disabled="!canEdit"></select>
</div>
<label class="control-label col-md-2">Reference</label>
<div class="col-md-4">
<input type="number" class="form-control" ng-model="alert.options.value" placeholder="reference value"
<input type="number" step="any" class="form-control" ng-model="alert.options.value" placeholder="reference value" ng-disabled="!canEdit"
required/>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-2">Rearm seconds</label>
<div class="col-md-4">
<input type="number" class="form-control" ng-model="alert.rearm"/>
<input type="number" class="form-control" ng-model="alert.rearm" ng-disabled="!canEdit"/>
</div>
</div>
</div>
<div class="form-group">
<button class="btn btn-primary" ng-disabled="!alertForm.$valid">Save</button>
<div class="form-group" ng-if="canEdit">
<button class="btn btn-primary" ng-disabled="!alertForm.$valid" ng-click="saveChanges()">Save</button>
<button class="btn btn-danger" ng-if="alert.id" ng-click="delete()">Delete</button>
</div>
</form>
</div>
<div class="col-md-4" ng-if="alert.id">
<alert-subscribers alert-id="alert.id"></alert-subscribers>
<alert-subscriptions alert-id="alert.id"></alert-subscriptions>
</div>
</div>
</div>

View File

@@ -1,4 +0,0 @@
<div>
<strong>Subscribers</strong> <subscribe-button alert-id="alertId" subscribers="subscribers"></subscribe-button><br/>
<img ng-src="{{s.user.gravatar_url}}" class="img-circle" alt="{{s.user.name}}" ng-repeat="s in subscribers"/>
</div>

View File

@@ -5,7 +5,7 @@
<page-header title="{{dashboard.name}}">
<span ng-if="!dashboard.is_archived && !public" class="hidden-print">
<button type="button" class="btn btn-sm" ng-class="{'btn-default': !refreshEnabled, 'btn-primary': refreshEnabled}" tooltip="Enable/Disable Auto Refresh" ng-click="triggerRefresh()">
<span class="zmdi zmdi-refresh-sync"></span>
<span class="zmdi zmdi-refresh"></span>
</button>
<button type="button" class="btn btn-sm" ng-class="{'btn-default': !isFullscreen, 'btn-primary': isFullscreen}" tooltip="Enable/Disable Fullscreen display" ng-click="toggleFullscreen()">
<span class="zmdi zmdi-fullscreen"></span>
@@ -29,11 +29,14 @@
This dashboard is archived and won't appear in the dashboards list or search results.
</div>
<div class="m-b-5">
<filters ng-if="dashboard.dashboard_filters_enabled"></filters>
</div>
<div ng-repeat="row in dashboard.widgets" class="row">
<div ng-repeat="widget in row" class="col-lg-{{widget.width | colWidth}}" ng-controller='WidgetCtrl'>
<div class="tile" ng-if="type=='visualization'">
<div class="t-header">
<div class="t-header widget">
<div class="th-title">
<p class="hidden-print">
<span ng-hide="currentUser.hasPermission('view_query')">{{query.name}}</span>
@@ -53,18 +56,31 @@
<li><a ng-disabled="!queryResult.getData()" query-result-link target="_self">Download as CSV File</a></li>
<li><a ng-disabled="!queryResult.getData()" file-type="xlsx" query-result-link target="_self" >Download as Excel File</a></li>
<li><a ng-href="queries/{{query.id}}#{{widget.visualization.id}}" ng-show="currentUser.hasPermission('view_query')">View Query</a></li>
<li><a ng-show="dashboard.canEdit()" ng-click="deleteWidget()">Remove From Dashbaord</a></li>
<li><a ng-show="dashboard.canEdit()" ng-click="deleteWidget()">Remove From Dashboard</a></li>
</ul>
</div>
</div>
<visualization-renderer visualization="widget.visualization" query-result="queryResult" class="t-body"></visualization-renderer>
<parameters parameters="widget.query.getParametersDefs()"></parameters>
<div class="panel-footer">
<span class="label label-default hidden-print">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
<div ng-switch="queryResult.getStatus()">
<div ng-switch-when="failed">
<div class="alert alert-danger m-5" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong></div>
</div>
<div ng-switch-when="done">
<visualization-renderer visualization="widget.visualization" query-result="queryResult" class="t-body"></visualization-renderer>
</div>
<div ng-switch-default class="text-center">
<i class="zmdi zmdi-refresh zmdi-hc-spin zmdi-hc-5x"></i>
</div>
</div>
<div class="p-5 clearfix" style="line-height:28px;">
<span class="small hidden-print">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
<span class="visible-print">
Updated: {{queryResult.getUpdatedAt() | dateTime}}
</span>
<button class="btn btn-sm btn-default pull-right hidden-print" ng-click="reload(true)" ng-if="!public"><i class="zmdi zmdi-refresh"></i></button>
</div>
</div>

View File

@@ -1,7 +1,9 @@
<settings-screen>
<div class="row">
<div class="col-md-8">
<data-source-form data-data-source="dataSource" />
<dynamic-form target="dataSource" type="data_sources">
<button class="btn btn-danger" ng-if="dataSource.id" ng-click="delete()">Delete</button>
</dynamic-form>
</div>
</div>
</settings-screen>

View File

@@ -1,28 +0,0 @@
<div>
<form name="dataSourceForm" ng-submit="saveChanges()">
<div class="form-group">
<label for="dataSourceName">Name</label>
<input type="string" class="form-control" name="dataSourceName" ng-model="dataSource.name" required>
</div>
<div class="form-group">
<label for="type">Type</label>
<select name="type" class="form-control" ng-options="type.type as type.name for type in dataSourceTypes"
ng-model="dataSource.type"></select>
</div>
<div class="form-group" ng-class='{"has-error": !inner.input.$valid}' ng-form="inner"
ng-repeat="(name, input) in type.configuration_schema.properties">
<label>{{input.title || name | capitalize}}</label>
<input name="input" type="{{input.type}}" class="form-control" ng-model="dataSource.options[name]"
ng-required="input.required"
ng-if="input.type !== 'file'" accesskey="tab" placeholder="{{input.default}}">
<input name="input" type="file" class="form-control" ng-model="files[name]"
ng-required="input.required && !dataSource.options[name]"
base-sixty-four-input
ng-if="input.type === 'file'">
</div>
</form>
<button class="btn btn-primary" ng-disabled="!dataSourceForm.$valid" ng-click="saveChanges()">Save</button>
<button class="btn btn-danger" ng-click="deleteDataSource()" ng-if="dataSource.id">Delete</button>
</div>

View File

@@ -0,0 +1,9 @@
<settings-screen>
<div class="row voffset1">
<div class="col-md-6">
<dynamic-form target="destination" type="destinations">
<button class="btn btn-danger" ng-if="destination.id" ng-click="delete()">Delete</button>
</dynamic-form>
</div>
</div>
</settings-screen>

View File

@@ -0,0 +1,12 @@
<settings-screen>
<div class="row voffset1">
<div class="col-md-4">
<p>
<a href="destinations/new" class="btn btn-default"><i class="fa fa-plus"></i> New Alert Destination</a>
</p>
<div class="list-group">
<a ng-href="destinations/{{destination.id}}" class="list-group-item" ng-repeat="destination in destinations"><i class="fa {{destination.icon}}"></i> {{destination.name}}</a>
</div>
</div>
</div>
</settings-screen>

View File

@@ -0,0 +1,21 @@
<div class="modal-header">
<button type="button" class="close" aria-label="Close" ng-click="close()"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">{{parameter.name}}</h4>
</div>
<div class="modal-body">
<div class="form">
<div class="form-group">
<label>Title</label>
<input type="text" class="form-control" ng-model="parameter.title">
</div>
<div class="form-group">
<label>Type</label>
<select ng-model="parameter.type" class="form-control">
<option value="text">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="datetime-local">Date and Time</option>
</select>
</div>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<form name="dataSourceForm">
<div class="form-group">
<label for="dataSourceName">Name</label>
<input type="string" class="form-control" name="dataSourceName" ng-model="target.name" required>
</div>
<div class="form-group">
<label for="type">Type</label>
<select name="type" class="form-control" ng-options="type.type as type.name for type in types" ng-model="target.type"></select>
</div>
<div class="form-group" ng-class='{"has-error": !inner.input.$valid}' ng-form="inner" ng-repeat="(name, input) in type.configuration_schema.properties">
<label>{{input.title || name | capitalize}}</label>
<input name="input" type="{{input.type}}" class="form-control" ng-model="target.options[name]" ng-required="input.required"
ng-if="input.type !== 'file'" accesskey="tab" placeholder="{{input.default}}">
<input name="input" type="file" class="form-control" ng-model="files[name]" ng-required="input.required && !target.options[name]"
base-sixty-four-input
ng-if="input.type === 'file'">
</div>
<button class="btn btn-primary" ng-disabled="!dataSourceForm.$valid" ng-click="saveChanges()">Save</button>
<span ng-transclude>
</span>
</form>

View File

@@ -0,0 +1,7 @@
<div class="form-inline bg-white p-5" ng-if="parameters | notEmpty" ui-sortable="{ 'ui-floating': true, 'disabled': !editable }" ng-model="parameters">
<div class="form-group" ng-repeat="param in parameters">
<label>{{param.title}}</label>
<button class="btn btn-default btn-xs" ng-click="showParameterSettings(param)" ng-if="editable"><i class="zmdi zmdi-settings"></i></button>
<input type="{{param.type}}" class="form-control" ng-model="param.value">
</div>
</div>

View File

@@ -7,6 +7,7 @@
<li ng-class="{'active': dsPage }" ng-if="showDsLink"><a href="data_sources">Data Sources</a></li>
<li ng-class="{'active': usersPage }" ng-if="showUsersLink"><a href="users">Users</a></li>
<li ng-class="{'active': groupsPage }" ng-if="showGroupsLink"><a href="groups">Groups</a></li>
<li ng-class="{'active': destinationsPage }" ng-if="showDestinationsLink"><a href="destinations">Alert Destinations</a></li>
</ul>
<div ng-transclude>

View File

@@ -2,7 +2,7 @@
<div class="tile m-t-10 m-b-5">
<div class="t-body p-5">
<a href="queries/new" class="btn btn-default">New Query</a>
<a href="queries/new" ng-show="currentUser.hasPermission('create_query')" class="btn btn-default">New Query</a>
<button ng-show="currentUser.hasPermission('create_dashboard')" type="button" class="btn btn-default"
data-toggle="modal" href="#new_dashboard_dialog">New Dashboard
</button>

View File

@@ -8,7 +8,7 @@
</div>
<div class="modal-body">
Are you sure you want to archive this query?
<br/> All dashboard widgets created with its visualizations will be deleted.
<br/> All alerts and dashboard widgets created with its visualizations will be deleted.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">No</button>
@@ -28,7 +28,7 @@
Looks like no data sources were created yet (or none of them available to the group(s) you're member of). Please
create one first, and then start querying.
<br/>
<a href="data_sources/new" class="btn btn-default">Create Data Source</a> <a href="groups" class="btn btn-default">Manage
<a href="data_sources/new" class="btn btn-primary">Create Data Source</a> <a href="groups" class="btn btn-default">Manage
Group Permissions</a>
</overlay>
<overlay ng-if="noDataSources && !currentUser.isAdmin">
@@ -221,6 +221,7 @@
<div class="t-body">
<div class="row">
<div class="col-lg-12">
<parameters parameters="query.getParametersDefs()" sync-values="!query.isNew()" editable="sourceMode && canEdit"></parameters>
<!-- Query Execution Status -->
<div class="alert alert-info" ng-show="queryResult.getStatus() == 'processing'">
Executing query&hellip;
@@ -255,9 +256,8 @@
<div class="row">
<div class="col-lg-12">
<ul class="tab-nav">
<rd-tab tab-id="table" name="Table"></rd-tab>
<rd-tab tab-id="pivot" name="Pivot Table"></rd-tab>
<rd-tab tab-id="{{vis.id}}" name="{{vis.name}}" ng-if="vis.type!='TABLE'"
<rd-tab tab-id="table" name="Table" base-path="query.getUrl(sourceMode)"></rd-tab>
<rd-tab tab-id="{{vis.id}}" name="{{vis.name}}" ng-if="vis.type!='TABLE'" base-path="query.getUrl(sourceMode)"
ng-repeat="vis in query.visualizations">
<span class="remove" ng-click="deleteVisualization($event, vis)"
ng-show="canEdit"> &times;</span>
@@ -276,13 +276,7 @@
<button class="btn btn-default" ng-if="!query.isNew()" ng-click="showEmbedDialog(query, vis)"><i class="zmdi zmdi-code"></i> Embed</button>
</div>
</div>
<div ng-show="selectedTab == 'pivot'">
<h3>
Pivot tables are now regular visualization, which you can create from the
<a hash="add" hash-link>"New Visualization" tab</a> and <strong>save</strong>.
</h3>
</div>
<div ng-show="selectedTab == vis.id" ng-repeat="vis in query.visualizations">
<div ng-if="selectedTab == vis.id" ng-repeat="vis in query.visualizations">
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
<div class="bg-ace p-5">
<button class="btn btn-default" ng-click="openVisualizationEditor(vis)" ng-if="canEdit">Edit</button>

View File

@@ -1,7 +1,7 @@
<settings-screen>
<email-settings-warning function="invite emails"></email-settings-warning>
<h3>{{user.name}}</h3>
<h3 class="p-l-5">{{user.name}}</h3>
<ul class="tab-nav">
<rd-tab tab-id="profile" name="Profile" ng-click="selectTab('profile')"></rd-tab>

View File

@@ -111,6 +111,11 @@
<i class="input-helper"></i> Show Labels
</label>
</div>
<div class="form-group">
<label class="control-label">Height</label>
<input name="x-axis-height" type="number" class="form-control" ng-model="options.bottomMargin">
</div>
</div>
<div ng-show="currentTab == 'yAxis'">

View File

@@ -1,8 +0,0 @@
<div>
<span>
From <input type="date" ng-model="dateRangeHuman.min">
</span>
<span>
To <input type="date" ng-model="dateRangeHuman.max">
</span>
</div>

View File

@@ -21,7 +21,6 @@
<label class="control-label">Visualization Name</label>
<input name="name" type="text" class="form-control" ng-model="visualization.name"
placeholder="{{visualization.type | capitalize}}">
</div>
<visualization-options-editor></visualization-options-editor>

View File

@@ -1,17 +1,19 @@
<div class="well well-sm filters-container" ng-show="filters">
<div class="filter" ng-repeat="filter in filters">
<ui-select ng-model="filter.current" ng-if="!filter.multiple">
<ui-select-match placeholder="Select value for {{filter.friendlyName}}...">{{filter.friendlyName}}: {{$select.selected | filterValue:filter}}</ui-select-match>
<ui-select-choices repeat="value in filter.values | filter: $select.search">
{{value | filterValue:filter }}
</ui-select-choices>
</ui-select>
<div class="container bg-white p-5" ng-show="filters">
<div class="row" ng-show="filters">
<div class="col-sm-6 m-t-5" ng-repeat="filter in filters">
<ui-select ng-model="filter.current" ng-if="!filter.multiple">
<ui-select-match placeholder="Select value for {{filter.friendlyName}}...">{{filter.friendlyName}}: {{$select.selected | filterValue:filter}}</ui-select-match>
<ui-select-choices repeat="value in filter.values | filter: $select.search">
{{value | filterValue:filter }}
</ui-select-choices>
</ui-select>
<ui-select ng-model="filter.current" multiple ng-if="filter.multiple">
<ui-select-match placeholder="Select value for {{filter.friendlyName}}...">{{filter.friendlyName}}: {{$item | filterValue:filter}}</ui-select-match>
<ui-select-choices repeat="value in filter.values | filter: $select.search">
{{value | filterValue:filter }}
</ui-select-choices>
</ui-select>
<ui-select ng-model="filter.current" multiple ng-if="filter.multiple">
<ui-select-match placeholder="Select value for {{filter.friendlyName}}...">{{filter.friendlyName}}: {{$item | filterValue:filter}}</ui-select-match>
<ui-select-choices repeat="value in filter.values | filter: $select.search">
{{value | filterValue:filter }}
</ui-select-choices>
</ui-select>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div>
</div>

View File

@@ -0,0 +1,8 @@
<div class="form-horizontal">
<div class="form-group">
<label class="col-lg-6">Word Cloud Column Name</label>
<div class="col-lg-6">
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.column" class="form-control"></select>
</div>
</div>
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -10,9 +10,10 @@ from flask_mail import Mail
from redash import settings
from redash.query_runner import import_query_runners
from redash.destinations import import_destinations
__version__ = '0.10.0'
__version__ = '0.11.1'
if settings.FEATURE_TABLES_PERMISSIONS:
@@ -61,6 +62,7 @@ mail.init_mail(settings.all_settings())
statsd_client = StatsClient(host=settings.STATSD_HOST, port=settings.STATSD_PORT, prefix=settings.STATSD_PREFIX)
import_query_runners(settings.QUERY_RUNNERS)
import_destinations(settings.DESTINATIONS)
from redash.version_check import reset_new_version_status
reset_new_version_status()

View File

@@ -69,6 +69,8 @@ def create_and_login_user(org, name, email):
login_user(user_object, remember=True)
return user_object
@blueprint.route('/<org_slug>/oauth/google', endpoint="authorize_org")
def org_login(org_slug):

View File

@@ -7,14 +7,14 @@ single Organization in your installation.
import logging
from flask import request
from flask import request, g
from werkzeug.local import LocalProxy
from redash.models import Organization
def _get_current_org():
slug = request.view_args.get('org_slug', 'default')
slug = request.view_args.get('org_slug', g.get('org_slug', 'default'))
org = Organization.get_by_slug(slug)
logging.debug("Current organization: %s (slug: %s)", org, slug)
return org

View File

@@ -85,7 +85,12 @@ def idp_initiated():
# This is what as known as "Just In Time (JIT) provisioning".
# What that means is that, if a user in a SAML assertion
# isn't in the user store, we create that user first, then log them in
create_and_login_user(current_org, name, email)
user = create_and_login_user(current_org, name, email)
if 'RedashGroups' in authn_response.ava:
group_names = authn_response.ava.get('RedashGroups')
user.update_group_assignments(group_names)
url = url_for('redash.index')
return redirect(url)

View File

@@ -2,16 +2,21 @@ import json
import click
from flask_script import Manager
from redash import models
from redash.query_runner import query_runners, get_configuration_schema_for_type
from redash.query_runner import query_runners, get_configuration_schema_for_query_runner_type
from redash.utils.configuration import ConfigurationContainer
manager = Manager(help="Data sources management commands.")
@manager.command
def list():
@manager.option('--org', dest='organization', default=None, help="The organization the user belongs to (leave blank for all organizations).")
def list(organization=None):
"""List currently configured data sources."""
for i, ds in enumerate(models.DataSource.select()):
if organization:
org = models.Organization.get_by_slug(organization)
data_sources = models.DataSource.select().where(models.DataSource.org==org.id)
else:
data_sources = models.DataSource.select()
for i, ds in enumerate(data_sources):
if i > 0:
print "-" * 20
@@ -24,8 +29,11 @@ def validate_data_source_type(type):
exit()
@manager.command
def new(name=None, type=None, options=None):
@manager.option('name', default=None, help="name of data source to create")
@manager.option('--type', dest='type', default=None, help="new type for the data source")
@manager.option('--options', dest='options', default=None, help="updated options for the data source")
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default').")
def new(name=None, type=None, options=None, organization='default'):
"""Create new data source."""
if name is None:
name = click.prompt("Name")
@@ -84,15 +92,20 @@ def new(name=None, type=None, options=None):
data_source = models.DataSource.create_with_group(name=name,
type=type,
options=options,
org=models.Organization.get_by_slug('default'))
org=models.Organization.get_by_slug(organization))
print "Id: {}".format(data_source.id)
@manager.command
def delete(name):
@manager.option('name', default=None, help="name of data source to delete")
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default').")
def delete(name, organization='default'):
"""Delete data source by name."""
try:
data_source = models.DataSource.get(models.DataSource.name==name)
org = models.Organization.get_by_slug(organization)
data_source = models.DataSource.get(
models.DataSource.name==name,
models.DataSource.org==org,
)
print "Deleting data source: {} (id={})".format(name, data_source.id)
data_source.delete_instance(recursive=True)
except models.DataSource.DoesNotExist:
@@ -110,16 +123,20 @@ def update_attr(obj, attr, new_value):
@manager.option('--name', dest='new_name', default=None, help="new name for the data source")
@manager.option('--options', dest='options', default=None, help="updated options for the data source")
@manager.option('--type', dest='type', default=None, help="new type for the data source")
def edit(name, new_name=None, options=None, type=None):
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default').")
def edit(name, new_name=None, options=None, type=None, organization='default'):
"""Edit data source settings (name, options, type)."""
try:
if type is not None:
validate_data_source_type(type)
data_source = models.DataSource.get(models.DataSource.name==name)
data_source = models.DataSource.get(
models.DataSource.name==name,
models.DataSource.org==org,
)
if options is not None:
schema = get_configuration_schema_for_type(data_source.type)
schema = get_configuration_schema_for_query_runner_type(data_source.type)
options = json.loads(options)
data_source.options.set_schema(schema)
data_source.options.update(options)

51
redash/cli/groups.py Normal file
View File

@@ -0,0 +1,51 @@
from flask_script import Manager, prompt_pass
from redash import models
manager = Manager(help="Groups management commands.")
@manager.option('name', help="Group's name")
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default').")
@manager.option('--permissions', dest='permissions', default=None, help="Comma seperated list of permissions ('create_dashboard', 'create_query', 'edit_dashboard', 'edit_query', 'view_query', 'view_source', 'execute_query', 'list_users', 'schedule_query', 'list_dashboards', 'list_alerts', 'list_data_sources') (leave blank for default).")
def create(name, permissions=None, organization='default'):
print "Creating group (%s)..." % (name)
org = models.Organization.get_by_slug(organization)
permissions = extract_permissions_string(permissions)
print "permissions: [%s]" % ",".join(permissions)
try:
models.Group.create(name=name, org=org, permissions=permissions)
except Exception, e:
print "Failed create group: %s" % e.message
@manager.option('id', help="Group's id")
@manager.option('--permissions', dest='permissions', default=None, help="Comma seperated list of permissions ('create_dashboard', 'create_query', 'edit_dashboard', 'edit_query', 'view_query', 'view_source', 'execute_query', 'list_users', 'schedule_query', 'list_dashboards', 'list_alerts', 'list_data_sources') (leave blank for default).")
def change_permissions(id, permissions=None):
print "Change permissions of group %s ..." % id
try:
group = models.Group.get_by_id(id)
except models.Group.DoesNotExist:
print "User [%s] not found." % id
return
permissions = extract_permissions_string(permissions)
print "current permissions [%s] will be modify to [%s]" % (",".join(group.permissions), ",".join(permissions))
group.permissions = permissions
try:
group.save()
except Exception, e:
print "Failed change permission: %s" % e.message
def extract_permissions_string(permissions):
if permissions is None:
permissions = models.Group.DEFAULT_PERMISSIONS
else:
permissions = permissions.split(',')
permissions = [p.strip() for p in permissions]
return permissions

View File

@@ -4,11 +4,10 @@ from peewee import IntegrityError
from redash import models
from redash.handlers.users import invite_user
manager = Manager(help="Users management commands. This commands assume single organization operation.")
manager = Manager(help="Users management commands.")
def build_groups(groups, is_admin):
org = models.Organization.get_by_slug('default')
def build_groups(org, groups, is_admin):
if isinstance(groups, basestring):
groups= groups.split(',')
groups.remove('') # in case it was empty string
@@ -23,9 +22,10 @@ def build_groups(groups, is_admin):
return groups
@manager.option('email', help="email address of the user to grant admin to")
def grant_admin(email):
@manager.option('--org', dest='organization', default='default', help="the organization the user belongs to, (leave blank for 'default').")
def grant_admin(email, organization='default'):
try:
org = models.Organization.get_by_slug('default')
org = models.Organization.get_by_slug(organization)
admin_group = org.admin_group
user = models.User.get_by_email_and_org(email, org)
@@ -42,17 +42,18 @@ def grant_admin(email):
@manager.option('email', help="User's email")
@manager.option('name', help="User's full name")
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default').")
@manager.option('--admin', dest='is_admin', action="store_true", default=False, help="set user as admin")
@manager.option('--google', dest='google_auth', action="store_true", default=False, help="user uses Google Auth to login")
@manager.option('--password', dest='password', default=None, help="Password for users who don't use Google Auth (leave blank for prompt).")
@manager.option('--groups', dest='groups', default=None, help="Comma seperated list of groups (leave blank for default).")
def create(email, name, groups, is_admin=False, google_auth=False, password=None):
print "Creating user (%s, %s)..." % (email, name)
def create(email, name, groups, is_admin=False, google_auth=False, password=None, organization='default'):
print "Creating user (%s, %s) in organization %s..." % (email, name, organization)
print "Admin: %r" % is_admin
print "Login with Google Auth: %r\n" % google_auth
org = models.Organization.get_by_slug('default')
groups = build_groups(groups, is_admin)
org = models.Organization.get_by_slug(organization)
groups = build_groups(org, groups, is_admin)
user = models.User(org=org, email=email, name=name, groups=groups)
if not google_auth:
@@ -66,16 +67,32 @@ def create(email, name, groups, is_admin=False, google_auth=False, password=None
@manager.option('email', help="email address of user to delete")
def delete(email):
deleted_count = models.User.delete().where(models.User.email == email).execute()
@manager.option('--org', dest='organization', default=None, help="The organization the user belongs to (leave blank for all organizations).")
def delete(email, organization=None):
if organization:
org = models.Organization.get_by_slug(organization)
deleted_count = models.User.delete().where(
models.User.email == email,
models.User.org == org.id,
).execute()
else:
deleted_count = models.User.delete().where(models.User.email == email).execute()
print "Deleted %d users." % deleted_count
@manager.option('password', help="new password for the user")
@manager.option('email', help="email address of the user to change password for")
def password(email, password):
@manager.option('--org', dest='organization', default=None, help="The organization the user belongs to (leave blank for all organizations).")
def password(email, password, organization=None):
try:
user = models.User.get_by_email_and_org(email, models.Organization.get_by_slug('default'))
if organization:
org = models.Organization.get_by_slug(organization)
user = models.User.select().where(
models.User.email == email,
models.User.org == org.id,
).first()
else:
user = models.User.select().where(models.User.email == email).first()
user.hash_password(password)
user.save()
@@ -88,11 +105,12 @@ def password(email, password):
@manager.option('email', help="The invitee's email")
@manager.option('name', help="The invitee's full name")
@manager.option('inviter_email', help="The email of the inviter")
@manager.option('--org', dest='organization', default='default', help="The organization the user belongs to (leave blank for 'default')")
@manager.option('--admin', dest='is_admin', action="store_true", default=False, help="set user as admin")
@manager.option('--groups', dest='groups', default=None, help="Comma seperated list of groups (leave blank for default).")
def invite(email, name, inviter_email, groups, is_admin=False):
org = models.Organization.get_by_slug('default')
groups = build_groups(groups, is_admin)
def invite(email, name, inviter_email, groups, is_admin=False, organization='default'):
org = models.Organization.get_by_slug(organization)
groups = build_groups(org, groups, is_admin)
try:
user_from = models.User.get_by_email_and_org(inviter_email, org)
user = models.User(org=org, name=name, email=email, groups=groups)
@@ -110,11 +128,16 @@ def invite(email, name, inviter_email, groups, is_admin=False):
print "The inviter [%s] was not found." % inviterEmail
@manager.command
def list():
@manager.option('--org', dest='organization', default=None, help="The organization the user belongs to (leave blank for all organizations)")
def list(organization=None):
"""List all users"""
for i, user in enumerate(models.User.select()):
if organization:
org = models.Organization.get_by_slug(organization)
users = models.Users.select().where(models.Users.org==org.id)
else:
users = models.DataSource.select()
for i, user in enumerate(users):
if i > 0:
print "-" * 20
print "Id: {}\nName: {}\nEmail: {}".format(user.id, user.name.encode('utf-8'), user.email)
print "Id: {}\nName: {}\nEmail: {}\nOrganization: {}".format(user.id, user.name.encode('utf-8'), user.email, user.org.name)

View File

@@ -0,0 +1,82 @@
import logging
import json
from redash import settings
logger = logging.getLogger(__name__)
__all__ = [
'BaseDestination',
'register',
'get_destination',
'import_destinations'
]
class BaseDestination(object):
def __init__(self, configuration):
self.configuration = configuration
@classmethod
def name(cls):
return cls.__name__
@classmethod
def type(cls):
return cls.__name__.lower()
@classmethod
def icon(cls):
return 'fa-bullseye'
@classmethod
def enabled(cls):
return True
@classmethod
def configuration_schema(cls):
return {}
def notify(self, alert, query, user, new_state, app, host, options):
raise NotImplementedError()
@classmethod
def to_dict(cls):
return {
'name': cls.name(),
'type': cls.type(),
'icon': cls.icon(),
'configuration_schema': cls.configuration_schema()
}
destinations = {}
def register(destination_class):
global destinations
if destination_class.enabled():
logger.debug("Registering %s (%s) destinations.", destination_class.name(), destination_class.type())
destinations[destination_class.type()] = destination_class
else:
logger.warning("%s destination enabled but not supported, not registering. Either disable or install missing dependencies.", destination_class.name())
def get_destination(destination_type, configuration):
destination_class = destinations.get(destination_type, None)
if destination_class is None:
return None
return destination_class(configuration)
def get_configuration_schema_for_destination_type(destination_type):
destination_class = destinations.get(destination_type, None)
if destination_class is None:
return None
return destination_class.configuration_schema()
def import_destinations(destination_imports):
for destination_import in destination_imports:
__import__(destination_import)

View File

@@ -0,0 +1,48 @@
import logging
from flask_mail import Message
from redash import models, mail
from redash.destinations import *
class Email(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"addresses": {
"type": "string"
},
},
"required": ["addresses"]
}
@classmethod
def icon(cls):
return 'fa-envelope'
def notify(self, alert, query, user, new_state, app, host, options):
recipients = [email for email in options.get('addresses', '').split(',') if email]
if not recipients:
logging.warning("No emails given. Skipping send.")
html = """
Check <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a>.
""".format(host=host, alert_id=alert.id, query_id=query.id)
logging.debug("Notifying: %s", recipients)
try:
with app.app_context():
message = Message(
recipients=recipients,
subject="[{1}] {0}".format(alert.name.encode('utf-8', 'ignore'), new_state.upper()),
html=html
)
mail.send(message)
except Exception:
logging.exception("Mail send error.")
register(Email)

View File

@@ -0,0 +1,57 @@
import json
import logging
import requests
from redash.destinations import *
from redash.models import Alert
colors = {
Alert.OK_STATE: 'green',
Alert.TRIGGERED_STATE: 'red',
Alert.UNKNOWN_STATE: 'yellow'
}
class HipChat(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {
"type": "string",
"title": "HipChat Notification URL (get it from the Integrations page)"
},
},
"required": ["url"]
}
@classmethod
def icon(cls):
return 'fa-comment-o'
def notify(self, alert, query, user, new_state, app, host, options):
try:
alert_url = '{host}/alerts/{alert_id}'.format(host=host, alert_id=alert.id);
query_url = '{host}/queries/{query_id}'.format(host=host, query_id=query.id);
message = '<a href="{alert_url}">{alert_name}</a> changed state to {new_state} (based on <a href="{query_url}">this query</a>).'.format(
alert_name=alert.name, new_state=new_state.upper(),
alert_url=alert_url,
query_url=query_url)
data = {
'message': message,
'color': colors.get(new_state, 'green')
}
headers = {'Content-Type': 'application/json'}
response = requests.post(options['url'], data=json.dumps(data), headers=headers)
if response.status_code != 204:
logging.error('Bad status code received from HipChat: %d', response.status_code)
except Exception:
logging.exception("HipChat Send ERROR.")
register(HipChat)

View File

@@ -0,0 +1,55 @@
import json
import logging
import requests
from redash.destinations import *
class Slack(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
'type': 'object',
'properties': {
'url': {
'type': 'string',
'title': 'Slack Webhook URL'
}
}
}
@classmethod
def icon(cls):
return 'fa-slack'
def notify(self, alert, query, user, new_state, app, host, options):
# Documentation: https://api.slack.com/docs/attachments
fields = [
{
"title": "Query",
"value": "{host}/queries/{query_id}".format(host=host, query_id=query.id),
"short": True
},
{
"title": "Alert",
"value": "{host}/alerts/{alert_id}".format(host=host, alert_id=alert.id),
"short": True
}
]
if new_state == "triggered":
text = alert.name + " just triggered"
color = "#c0392b"
else:
text = alert.name + " went back to normal"
color = "#27ae60"
payload = {'attachments': [{'text': text, 'color': color, 'fields': fields}]}
try:
resp = requests.post(options.get('url'), data=json.dumps(payload))
logging.warning(resp.text)
if resp.status_code != 200:
logging.error("Slack send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logging.exception("Slack send ERROR.")
register(Slack)

View File

@@ -0,0 +1,49 @@
import logging
import requests
from requests.auth import HTTPBasicAuth
from redash.destinations import *
from redash.utils import json_dumps
class Webhook(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {
"type": "string",
},
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"required": ["url"],
"secret": ["password"]
}
@classmethod
def icon(cls):
return 'fa-bolt'
def notify(self, alert, query, user, new_state, app, host, options):
try:
data = {
'event': 'alert_state_change',
'alert': alert.to_dict(full=False),
'url_base': host
}
headers = {'Content-Type': 'application/json'}
auth = HTTPBasicAuth(options.get('username'), options.get('password')) if options.get('username') else None
resp = requests.post(options.get('url'), data=json_dumps(data), auth=auth, headers=headers)
if resp.status_code != 200:
logging.error("webhook send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logging.exception("webhook send ERROR.")
register(Webhook)

View File

@@ -34,6 +34,11 @@ class AlertResource(BaseResource):
return alert.to_dict()
def delete(self, alert_id):
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
require_admin_or_owner(alert.user.id)
alert.delete_instance(recursive=True)
class AlertListResource(BaseResource):
def post(self):
@@ -57,16 +62,6 @@ class AlertListResource(BaseResource):
'object_type': 'alert'
})
# TODO: should be in model?
models.AlertSubscription.create(alert=alert, user=self.current_user)
self.record_event({
'action': 'subscribe',
'timestamp': int(time.time()),
'object_id': alert.id,
'object_type': 'alert'
})
return alert.to_dict()
@require_permission('list_alerts')
@@ -76,15 +71,24 @@ class AlertListResource(BaseResource):
class AlertSubscriptionListResource(BaseResource):
def post(self, alert_id):
req = request.get_json(True)
alert = models.Alert.get_by_id_and_org(alert_id, self.current_org)
require_access(alert.groups, self.current_user, view_only)
kwargs = {'alert': alert, 'user': self.current_user}
if 'destination_id' in req:
destination = models.NotificationDestination.get_by_id_and_org(req['destination_id'], self.current_org)
kwargs['destination'] = destination
subscription = models.AlertSubscription.create(**kwargs)
subscription = models.AlertSubscription.create(alert=alert_id, user=self.current_user)
self.record_event({
'action': 'subscribe',
'timestamp': int(time.time()),
'object_id': alert_id,
'object_type': 'alert'
'object_type': 'alert',
'destination': req.get('destination_id')
})
return subscription.to_dict()
@@ -99,8 +103,10 @@ class AlertSubscriptionListResource(BaseResource):
class AlertSubscriptionResource(BaseResource):
def delete(self, alert_id, subscriber_id):
models.AlertSubscription.unsubscribe(alert_id, subscriber_id)
require_admin_or_owner(subscriber_id)
subscription = get_object_or_404(models.AlertSubscription.get_by_id, subscriber_id)
require_admin_or_owner(subscription.user.id)
subscription.delete_instance()
self.record_event({
'action': 'unsubscribe',

View File

@@ -6,7 +6,7 @@ from redash.utils import json_dumps
from redash.handlers.base import org_scoped_rule
from redash.handlers.alerts import AlertResource, AlertListResource, AlertSubscriptionListResource, AlertSubscriptionResource
from redash.handlers.dashboards import DashboardListResource, RecentDashboardsResource, DashboardResource, DashboardShareResource
from redash.handlers.data_sources import DataSourceTypeListResource, DataSourceListResource, DataSourceSchemaResource, DataSourceResource
from redash.handlers.data_sources import DataSourceTypeListResource, DataSourceListResource, DataSourceSchemaResource, DataSourceResource, DataSourcePauseResource
from redash.handlers.events import EventResource
from redash.handlers.queries import QueryRefreshResource, QueryListResource, QueryRecentResource, QuerySearchResource, QueryResource
from redash.handlers.query_results import QueryResultListResource, QueryResultResource, JobResource
@@ -16,6 +16,7 @@ from redash.handlers.visualizations import VisualizationResource
from redash.handlers.widgets import WidgetResource, WidgetListResource
from redash.handlers.groups import GroupListResource, GroupResource, GroupMemberListResource, GroupMemberResource, \
GroupDataSourceListResource, GroupDataSourceResource
from redash.handlers.destinations import DestinationTypeListResource, DestinationResource, DestinationListResource
class ApiExt(Api):
@@ -49,6 +50,7 @@ api.add_org_resource(DashboardShareResource, '/api/dashboards/<dashboard_id>/sha
api.add_org_resource(DataSourceTypeListResource, '/api/data_sources/types', endpoint='data_source_types')
api.add_org_resource(DataSourceListResource, '/api/data_sources', endpoint='data_sources')
api.add_org_resource(DataSourceSchemaResource, '/api/data_sources/<data_source_id>/schema')
api.add_org_resource(DataSourcePauseResource, '/api/data_sources/<data_source_id>/pause')
api.add_org_resource(DataSourceResource, '/api/data_sources/<data_source_id>', endpoint='data_source')
api.add_org_resource(GroupListResource, '/api/groups', endpoint='groups')
@@ -85,4 +87,6 @@ api.add_org_resource(VisualizationResource, '/api/visualizations/<visualization_
api.add_org_resource(WidgetListResource, '/api/widgets', endpoint='widgets')
api.add_org_resource(WidgetResource, '/api/widgets/<int:widget_id>', endpoint='widget')
api.add_org_resource(DestinationTypeListResource, '/api/destinations/types', endpoint='destination_types')
api.add_org_resource(DestinationResource, '/api/destinations/<destination_id>', endpoint='destination')
api.add_org_resource(DestinationListResource, '/api/destinations', endpoint='destinations')

View File

@@ -11,11 +11,11 @@ from redash.handlers.base import BaseResource, get_object_or_404
class RecentDashboardsResource(BaseResource):
@require_permission('list_dashboards')
def get(self):
recent = [d.to_dict() for d in models.Dashboard.recent(self.current_user.groups, self.current_user.id, for_user=True)]
recent = [d.to_dict() for d in models.Dashboard.recent(self.current_org, self.current_user.groups, self.current_user.id, for_user=True)]
global_recent = []
if len(recent) < 10:
global_recent = [d.to_dict() for d in models.Dashboard.recent(self.current_user.groups, self.current_user.id)]
global_recent = [d.to_dict() for d in models.Dashboard.recent(self.current_org, self.current_user.groups, self.current_user.id)]
return take(20, distinct(chain(recent, global_recent), key=lambda d: d['id']))
@@ -23,7 +23,7 @@ class RecentDashboardsResource(BaseResource):
class DashboardListResource(BaseResource):
@require_permission('list_dashboards')
def get(self):
dashboards = [d.to_dict() for d in models.Dashboard.all(self.current_user.groups, self.current_user.id)]
dashboards = [d.to_dict() for d in models.Dashboard.all(self.current_org, self.current_user.groups, self.current_user)]
return dashboards

View File

@@ -5,7 +5,7 @@ from funcy import project
from redash import models
from redash.utils.configuration import ConfigurationContainer, ValidationError
from redash.permissions import require_admin, require_permission, require_access, view_only
from redash.query_runner import query_runners, get_configuration_schema_for_type
from redash.query_runner import query_runners, get_configuration_schema_for_query_runner_type
from redash.handlers.base import BaseResource, get_object_or_404
@@ -26,7 +26,7 @@ class DataSourceResource(BaseResource):
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
req = request.get_json(True)
schema = get_configuration_schema_for_type(req['type'])
schema = get_configuration_schema_for_query_runner_type(req['type'])
if schema is None:
abort(400)
@@ -35,7 +35,7 @@ class DataSourceResource(BaseResource):
data_source.options.update(req['options'])
except ValidationError:
abort(400)
data_source.type = req['type']
data_source.name = req['name']
data_source.save()
@@ -67,7 +67,7 @@ class DataSourceListResource(BaseResource):
d['view_only'] = all(project(ds.groups, self.current_user.groups).values())
response[ds.id] = d
return response.values()
return sorted(response.values(), key=lambda d: d['id'])
@require_admin
def post(self):
@@ -77,7 +77,7 @@ class DataSourceListResource(BaseResource):
if f not in req:
abort(400)
schema = get_configuration_schema_for_type(req['type'])
schema = get_configuration_schema_for_query_runner_type(req['type'])
if schema is None:
abort(400)
@@ -106,3 +106,38 @@ class DataSourceSchemaResource(BaseResource):
return schema
class DataSourcePauseResource(BaseResource):
@require_admin
def post(self, data_source_id):
data_source = get_object_or_404(models.DataSource.get_by_id_and_org, data_source_id, self.current_org)
data = request.get_json(force=True, silent=True)
if data:
reason = data.get('reason')
else:
reason = request.args.get('reason')
data_source.pause(reason)
data_source.save()
self.record_event({
'action': 'pause',
'object_id': data_source.id,
'object_type': 'datasource'
})
return data_source.to_dict()
@require_admin
def delete(self, data_source_id):
data_source = get_object_or_404(models.DataSource.get_by_id_and_org, data_source_id, self.current_org)
data_source.resume()
data_source.save()
self.record_event({
'action': 'resume',
'object_id': data_source.id,
'object_type': 'datasource'
})
return data_source.to_dict()

View File

@@ -0,0 +1,93 @@
import json
from flask import make_response, request
from flask.ext.restful import abort
from funcy import project
from redash import models
from redash.permissions import require_admin
from redash.destinations import destinations, get_configuration_schema_for_destination_type
from redash.utils.configuration import ConfigurationContainer, ValidationError
from redash.handlers.base import BaseResource, get_object_or_404
class DestinationTypeListResource(BaseResource):
@require_admin
def get(self):
return [q.to_dict() for q in destinations.values()]
class DestinationResource(BaseResource):
@require_admin
def get(self, destination_id):
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
return destination.to_dict(all=True)
@require_admin
def post(self, destination_id):
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
req = request.get_json(True)
schema = get_configuration_schema_for_destination_type(req['type'])
if schema is None:
abort(400)
try:
destination.options.set_schema(schema)
destination.options.update(req['options'])
except ValidationError:
abort(400)
destination.type = req['type']
destination.name = req['name']
destination.save()
return destination.to_dict(all=True)
@require_admin
def delete(self, destination_id):
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
destination.delete_instance(recursive=True)
return make_response('', 204)
class DestinationListResource(BaseResource):
def get(self):
destinations = models.NotificationDestination.all(self.current_org)
response = {}
for ds in destinations:
if ds.id in response:
continue
d = ds.to_dict()
response[ds.id] = d
return response.values()
@require_admin
def post(self):
req = request.get_json(True)
required_fields = ('options', 'name', 'type')
for f in required_fields:
if f not in req:
abort(400)
schema = get_configuration_schema_for_destination_type(req['type'])
if schema is None:
abort(400)
config = ConfigurationContainer(req['options'], schema)
if not config.is_valid():
abort(400)
destination = models.NotificationDestination(org=self.current_org,
name=req['name'],
type=req['type'],
options=config,
user=self.current_user)
destination.save()
return destination.to_dict(all=True)

View File

@@ -1,18 +1,69 @@
import json
import pystache
import time
import logging
from funcy import project
from flask import render_template, request
from flask_login import login_required, current_user
from flask_restful import abort
from redash import models, settings
from redash import models, settings, utils
from redash import serializers
from redash.utils import json_dumps
from redash.utils import json_dumps, collect_parameters_from_request, gen_query_hash
from redash.handlers import routes
from redash.handlers.base import org_scoped_rule, record_event
from redash.handlers.query_results import collect_query_parameters
from redash.permissions import require_access, view_only
from authentication import current_org
#
# Run a parameterized query synchronously and return the result
# DISCLAIMER: Temporary solution to support parameters in queries. Should be
# removed once we refactor the query results API endpoints and handling
# on the client side. Please don't reuse in other API handlers.
#
def run_query_sync(data_source, parameter_values, query_text, max_age=0):
query_parameters = set(collect_query_parameters(query_text))
missing_params = set(query_parameters) - set(parameter_values.keys())
if missing_params:
raise Exception('Missing parameter value for: {}'.format(", ".join(missing_params)))
if query_parameters:
query_text = pystache.render(query_text, parameter_values)
if max_age <= 0:
query_result = None
else:
query_result = models.QueryResult.get_latest(data_source, query_text, max_age)
query_hash = gen_query_hash(query_text)
if query_result:
logging.info("Returning cached result for query %s" % query_hash)
return query_result.data
try:
started_at = time.time()
data, error = data_source.query_runner.run_query(query_text)
if error:
return None
# update cache
if max_age > 0:
run_time = time.time() - started_at
query_result, updated_query_ids = models.QueryResult.store_result(data_source.org_id, data_source.id,
query_hash, query_text, data,
run_time, utils.utcnow())
return data
except Exception, e:
if max_age > 0:
abort(404, message="Unable to get result from the database, and no cached query result found.")
else:
abort(503, message="Unable to get result from the database.")
return None
@routes.route(org_scoped_rule('/embed/query/<query_id>/visualization/<visualization_id>'), methods=['GET'])
@login_required
@@ -22,10 +73,24 @@ def embed(query_id, visualization_id, org_slug=None):
vis = query.visualizations.where(models.Visualization.id == visualization_id).first()
qr = {}
parameter_values = collect_parameters_from_request(request.args)
if vis is not None:
vis = vis.to_dict()
qr = query.latest_query_data
if qr is None:
if settings.ALLOW_PARAMETERS_IN_EMBEDS == True and len(parameter_values) > 0:
# run parameterized query
#
# WARNING: Note that the external query parameters
# are a potential risk of SQL injections.
#
max_age = int(request.args.get('maxAge', 0))
results = run_query_sync(query.data_source, parameter_values, query.query, max_age=max_age)
if results is None:
abort(400, message="Unable to get results for this query")
else:
qr = {"data": json.loads(results)}
elif qr is None:
abort(400, message="No Results for this query")
else:
qr = qr.to_dict()
@@ -54,6 +119,7 @@ def embed(query_id, visualization_id, org_slug=None):
query_result=json_dumps(qr))
@routes.route(org_scoped_rule('/public/dashboards/<token>'), methods=['GET'])
@login_required
def public_dashboard(token, org_slug=None):

View File

@@ -176,5 +176,3 @@ class GroupDataSourceResource(BaseResource):
'object_type': 'group',
'member_id': data_source.id
})

View File

@@ -86,8 +86,6 @@ class QueryResource(BaseResource):
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'user', 'last_modified_by', 'org']:
query_def.pop(field, None)
# TODO(@arikfr): after running a query it updates all relevant queries with the new result. So is this really
# needed?
if 'latest_query_data_id' in query_def:
query_def['latest_query_data'] = query_def.pop('latest_query_data_id')

Some files were not shown because too many files have changed in this diff Show More