From 81e492297d22698c55eda6c64c116eb92a338f1a Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 28 Oct 2013 15:21:03 +0200 Subject: [PATCH 001/540] Link to the license file in README. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0831cf684..133358552 100644 --- a/README.md +++ b/README.md @@ -45,4 +45,4 @@ TBD. ## License -See LICENSE file. +See [LICENSE](https://github.com/EverythingMe/redash/blob/master/LICENSE) file. From d1d4510768d42c816055a0b37c251c404a0bc3e3 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 28 Oct 2013 15:23:10 +0200 Subject: [PATCH 002/540] README: fix link to new issue. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 133358552..dff162cb9 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ TBD. ## Reporting Bugs and Contributing Code -* Want to report a bug or request a feature? Please open [an issue](https://github.com/everythingme/re:dash/issues/new). +* Want to report a bug or request a feature? Please open [an issue](https://github.com/everythingme/redash/issues/new). * Want to help us build **_re:dash_**? Fork the project and make a pull request. We need all the help we can get! ## License From 306a1646cfb1c304710c182cb743bbbd12f47dc1 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 28 Oct 2013 16:51:49 +0200 Subject: [PATCH 003/540] updated roadmap --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dff162cb9..e7a9f4608 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,28 @@ TBD. ## Roadmap -TBD. +We plan to release new minor version every 2-3 weeks. Of course, if we get additional help from contributors it will help speed things up. + +Below you can see the "big" features of the next 3 releases (for full list, click on the link): + +### [v0.2](https://github.com/EverythingMe/redash/issues?milestone=1&state=open) + +- Ability to generate multiple visualizations for a single query (dataset) in a more flexible way than today. Also easier extensbility points to add additional visualizations. +- Dashboard filters: ability to filter/slice the data you see in a single dashboard using filters (date or selectors). +- UI Improvements (better notifications & flows, improved queries page) +- Comments on queries. + +### [v0.3](https://github.com/EverythingMe/redash/issues?milestone=2&state=open) + +- Support for API access using API keys, instead of Google Login. +- Multiple databases support (including other database type than PostgreSQL). +- Scheduled reports by email. + +### [v0.4](https://github.com/EverythingMe/redash/issues?milestone=3&state=open) + +- Query versioning. +- More "realtime" UI (using websockets). +- More visualizations. ## Reporting Bugs and Contributing Code From 6cf580e0ff1037527fce945df9362f84d76f12cf Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 28 Oct 2013 18:24:41 +0200 Subject: [PATCH 004/540] Add resolutions to bower.json. --- rd_ui/bower.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rd_ui/bower.json b/rd_ui/bower.json index 1af7dafa9..b7ff4ed63 100644 --- a/rd_ui/bower.json +++ b/rd_ui/bower.json @@ -21,5 +21,9 @@ "devDependencies": { "angular-mocks": "~1.0.7", "angular-scenario": "~1.0.7" + }, + "resolutions": { + "angular": "1.0.8", + "jquery": "~1.9.1" } } From 604a2946620f9d8a56676eb9b4b647a45ef7278f Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 28 Oct 2013 19:34:40 +0200 Subject: [PATCH 005/540] Fix use min configuration not to depend on the bower_components directory in dist. --- rd_ui/Gruntfile.js | 2 +- .../fonts/glyphicons-halflings-regular.eot | Bin 0 -> 14079 bytes .../fonts/glyphicons-halflings-regular.svg | 228 ++++++++++++++++++ .../fonts/glyphicons-halflings-regular.ttf | Bin 0 -> 29512 bytes .../fonts/glyphicons-halflings-regular.woff | Bin 0 -> 16448 bytes rd_ui/app/index.html | 5 +- rd_ui/bower.json | 3 +- 7 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 rd_ui/app/fonts/glyphicons-halflings-regular.eot create mode 100644 rd_ui/app/fonts/glyphicons-halflings-regular.svg create mode 100644 rd_ui/app/fonts/glyphicons-halflings-regular.ttf create mode 100644 rd_ui/app/fonts/glyphicons-halflings-regular.woff diff --git a/rd_ui/Gruntfile.js b/rd_ui/Gruntfile.js index ab51fd0fd..374e0cd6e 100644 --- a/rd_ui/Gruntfile.js +++ b/rd_ui/Gruntfile.js @@ -249,7 +249,7 @@ module.exports = function (grunt) { '.htaccess', 'bower_components/**/*', 'images/{,*/}*.{gif,webp}', - 'styles/fonts/*' + 'fonts/*' ] }, { expand: true, diff --git a/rd_ui/app/fonts/glyphicons-halflings-regular.eot b/rd_ui/app/fonts/glyphicons-halflings-regular.eot new file mode 100644 index 0000000000000000000000000000000000000000..87eaa434234e2a984c261e0450a2f4ad837aa7b4 GIT binary patch literal 14079 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 literal 0 HcmV?d00001 diff --git a/rd_ui/app/fonts/glyphicons-halflings-regular.svg b/rd_ui/app/fonts/glyphicons-halflings-regular.svg new file mode 100644 index 000000000..5fee06854 --- /dev/null +++ b/rd_ui/app/fonts/glyphicons-halflings-regular.svg @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/rd_ui/app/fonts/glyphicons-halflings-regular.ttf b/rd_ui/app/fonts/glyphicons-halflings-regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..be784dc1d5bcb92ab155f578f3723524a3dd9688 GIT binary patch literal 29512 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 DlhOCh literal 0 HcmV?d00001 diff --git a/rd_ui/app/fonts/glyphicons-halflings-regular.woff b/rd_ui/app/fonts/glyphicons-halflings-regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..2cc3e4852a5a42e6aadd6284e067b66e14a57bc7 GIT binary patch literal 16448 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 + - @@ -66,10 +66,9 @@
- - + diff --git a/rd_ui/bower.json b/rd_ui/bower.json index b7ff4ed63..6779f431d 100644 --- a/rd_ui/bower.json +++ b/rd_ui/bower.json @@ -16,7 +16,8 @@ "angular-resource": "1.0.7", "angular-growl": "~0.3.1", "pivottable": "git@github.com:arikfr/pivottable.git#master", - "cornelius": "git@github.com:restorando/cornelius.git" + "cornelius": "git@github.com:restorando/cornelius.git", + "gridster": "~0.2.0" }, "devDependencies": { "angular-mocks": "~1.0.7", From c24b6cabc9867f72b62246fdc55b7df1d7b8dbab Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 28 Oct 2013 19:48:20 +0200 Subject: [PATCH 006/540] Move cookie secret to settings. #7 --- rd_service/server.py | 2 +- rd_service/settings_example.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/rd_service/server.py b/rd_service/server.py index bf2cd5328..39f84a880 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -298,7 +298,7 @@ def get_application(static_path, is_debug, redis_connection, data_manager): static_path=static_path, debug=is_debug, login_url="/login", - cookie_secret="1f7146d871a6a23263089419cc443813", + cookie_secret=settings.COOKIE_SECRET, redis_connection=redis_connection, data_manager=data_manager) diff --git a/rd_service/settings_example.py b/rd_service/settings_example.py index 149366dbe..da2e35409 100644 --- a/rd_service/settings_example.py +++ b/rd_service/settings_example.py @@ -20,6 +20,7 @@ ADMINS = [] STATIC_ASSETS_PATH = "../rd_ui/dist/" WORKERS_COUNT = 2 MAX_CONNECTIONS = 3 +COOKIE_SECRET = "c292a0a3aa32397cdb050e233733900f" # Configuration of the operational database for the Django models django.conf.settings.configure(DATABASES = { 'default': { From 7280b6c558fadd29343511f5b8d79116f3cb0034 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 28 Oct 2013 21:52:21 +0200 Subject: [PATCH 007/540] Fix: sorting on number/dates columns was wrong --- rd_ui/app/scripts/query_fiddle/renderers.js | 35 ++++++++++++++------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index 1594d1ba2..21fdc7c49 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -104,7 +104,7 @@ renderers.directive('gridRenderer', function () { }, templateUrl: "/views/grid_renderer.html", replace: false, - controller: ['$scope', '$filter', function ($scope, $filter) { + controller: ['$scope', function ($scope) { $scope.gridColumns = []; $scope.gridData = []; $scope.gridConfig = { @@ -123,24 +123,16 @@ renderers.directive('gridRenderer', function () { $scope.gridData = []; $scope.filters = []; } else { - $scope.gridColumns = _.map($scope.queryResult.getColumnCleanNames(), function (col, i) { - return { - 'label': $scope.queryResult.getColumnFriendlyNames()[i], - 'map': col - } - }); $scope.filters = $scope.queryResult.getFilters(); var gridData = _.map($scope.queryResult.getData(), function (row) { var newRow = {}; _.each(row, function (val, key) { - // TODO: hack to detect date fields + // TODO: hack to detect date fields, needed only for backward compatability if (val > 1000 * 1000 * 1000 * 100) { - newRow[$scope.queryResult.getColumnCleanName(key)] = moment(val).format("DD/MM/YY HH:mm"); - } else if (angular.isNumber(val)) { - newRow[$scope.queryResult.getColumnCleanName(key)] = $filter('number')(val, 2); + newRow[$scope.queryResult.getColumnCleanName(key)] = moment(val); } else { newRow[$scope.queryResult.getColumnCleanName(key)] = val; } @@ -149,6 +141,27 @@ renderers.directive('gridRenderer', function () { return newRow; }); + $scope.gridColumns = _.map($scope.queryResult.getColumnCleanNames(), function (col, i) { + var columnDefinition = { + 'label': $scope.queryResult.getColumnFriendlyNames()[i], + 'map': col + }; + + if (gridData.length > 0) { + var exampleData = gridData[0][col]; + if (angular.isNumber(exampleData)) { + columnDefinition['formatFunction'] = 'number'; + columnDefinition['formatParameter'] = 3; + } else if (moment.isMoment(exampleData)) { + columnDefinition['formatFunction'] = function(value) { + return value.format("DD/MM/YY HH:mm"); + } + } + } + + return columnDefinition; + }); + $scope.gridData = _.clone(gridData); $scope.$watch('filters', function (filters) { From 81c59869e0abd5dc30e5756e2fc184534ecdf8f5 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 28 Oct 2013 22:01:18 +0200 Subject: [PATCH 008/540] Fix numbers format in grid. --- rd_ui/app/scripts/query_fiddle/renderers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index 21fdc7c49..2878850a8 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -151,7 +151,7 @@ renderers.directive('gridRenderer', function () { var exampleData = gridData[0][col]; if (angular.isNumber(exampleData)) { columnDefinition['formatFunction'] = 'number'; - columnDefinition['formatParameter'] = 3; + columnDefinition['formatParameter'] = 2; } else if (moment.isMoment(exampleData)) { columnDefinition['formatFunction'] = function(value) { return value.format("DD/MM/YY HH:mm"); From 43d5b68ec8145d4286b99405dedc5bd9220d401c Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 29 Oct 2013 09:13:07 +0200 Subject: [PATCH 009/540] Enable cohorts to all users --- rd_ui/app/scripts/controllers.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index baab47318..1fd1523c3 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -33,11 +33,7 @@ $scope.$parent.pageTitle = "Query Fiddle"; $scope.tabs = [{'key': 'table', 'name': 'Table'}, {'key': 'chart', 'name': 'Chart'}, - {'key': 'pivot', 'name': 'Pivot Table'}]; - - if (currentUser.is_admin) { - $scope.tabs.push({'key': 'cohort', 'name': 'Cohort'}); - } + {'key': 'pivot', 'name': 'Pivot Table'}, {'key': 'cohort', 'name': 'Cohort'}]; $scope.lockButton = function (lock) { $scope.queryExecuting = lock; From d4176e653d4c3d1440ac374a5c25e476e0cf26a3 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 29 Oct 2013 19:08:11 +0200 Subject: [PATCH 010/540] Group dashboards by category. --- rd_ui/app/index.html | 11 +++++++---- rd_ui/app/scripts/controllers.js | 19 +++++++++++++++++-- rd_ui/app/styles/redash.css | 20 ++++++++++++++++++++ rd_ui/app/views/index.html | 5 +++-- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index dd0936185..e6e3c77c9 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -37,10 +37,13 @@ diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 1fd1523c3..c6b89544e 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -184,7 +184,22 @@ } var MainCtrl = function ($scope, Dashboard, notifications) { - $scope.dashboards = Dashboard.query(); + $scope.dashboards = []; + $scope.reloadDashboards = function() { + Dashboard.query(function (dashboards) { + $scope.dashboards = _.sortBy(dashboards, "name"); + $scope.groupedDashboards = _.groupBy($scope.dashboards, function(d) { + parts = d.name.split(":"); + if (parts.length == 1) { + return "Other"; + } + return parts[0]; + }); + }); + } + + $scope.reloadDashboards(); + $scope.currentUser = currentUser; $scope.newDashboard = { 'name': null, @@ -203,7 +218,7 @@ $scope.archiveDashboard = function(dashboard) { if (confirm('Are you sure you want to delete "' + dashboard.name + '" dashboard?')) { dashboard.$delete(function() { - $scope.$parent.dashboards = Dashboard.query(); + $scope.$parent.reloadDashboards(); }); } } diff --git a/rd_ui/app/styles/redash.css b/rd_ui/app/styles/redash.css index 391370a52..51257114c 100644 --- a/rd_ui/app/styles/redash.css +++ b/rd_ui/app/styles/redash.css @@ -106,4 +106,24 @@ li.widget:hover { .CodeMirror-scroll { overflow-y: hidden; overflow-x: auto; +} + +/* Because of ng-repeat we add span between the .dropdown-menu element and the li element, so we had +to add those CSS styles here. */ + +.dropdown-menu > span > li > a { + display: block; + padding: 3px 20px; + clear: both; + font-weight: normal; + line-height: 1.428571429; + color: #333333; + white-space: nowrap; +} + +.dropdown-menu > span > li > a:hover, +.dropdown-menu > span > li > a:focus { + color: #ffffff; + text-decoration: none; + background-color: #428bca; } \ No newline at end of file diff --git a/rd_ui/app/views/index.html b/rd_ui/app/views/index.html index fc4f3d4e5..2f7342043 100644 --- a/rd_ui/app/views/index.html +++ b/rd_ui/app/views/index.html @@ -1,7 +1,8 @@
-
+

Dashboards

+
- Dashboards + {{name}}
From f1da4ad5421d79cf19e25eb1b7c02f124612a936 Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Wed, 30 Oct 2013 10:08:22 +0200 Subject: [PATCH 011/540] Update README.md fixed broken Tornado link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e7a9f4608..0e928df28 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This is the first release, which is more than usable but still has its rough edg ## Technology * [AngularJS](http://angularjs.org/) -* [Tornado](tornadoweb.org) +* [Tornado](http://tornadoweb.org) * [PostgreSQL](http://www.postgresql.org/) / [AWS Redshift](http://aws.amazon.com/redshift/) * [Redis](http://redis.io) From 0c5b0c892fb9efc1d756addd50bdb062797cfce2 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 12:23:16 +0200 Subject: [PATCH 012/540] Add link to demo instance. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 0e928df28..e96a91d23 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ Prior to **_re:dash_**, we tried to use tranditional BI suites and discovered a This is the first release, which is more than usable but still has its rough edges and way to go to fulfill its full potential. The Query Editor part is quite solid, but the visualizations need more work to enrich them and to make them more user friendly. +## Demo + +You can try out the demo instance: http://rd-demo.herokuapp.com/ (login with any Google account). +Due to Heroku dev plan limits, it has a small database of flights (see schema [here](http://rd-demo.herokuapp.com/dashboard/schema)). Also due to another Heroku limitation, it is running with the regular user, hence you can DELETE or INSERT data/tables. Please be nice and don't do this. + ## Technology * [AngularJS](http://angularjs.org/) From 8c9d8310ca92d3d5c30c300d1aa082fd168adf6c Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 12:23:49 +0200 Subject: [PATCH 013/540] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e96a91d23..5564fd341 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This is the first release, which is more than usable but still has its rough edg ## Demo You can try out the demo instance: http://rd-demo.herokuapp.com/ (login with any Google account). + Due to Heroku dev plan limits, it has a small database of flights (see schema [here](http://rd-demo.herokuapp.com/dashboard/schema)). Also due to another Heroku limitation, it is running with the regular user, hence you can DELETE or INSERT data/tables. Please be nice and don't do this. ## Technology From c60c8438e11345967a3ad68e1d1eb15e3df424ef Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 12:33:29 +0200 Subject: [PATCH 014/540] Add dist folder to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9de958bb8..a6086e0d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea *.pyc rd_service/settings.py +rd_ui/dist From eed3b4a7cf5e8eda908aad59a8f908c5bcdf8b3c Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 12:37:33 +0200 Subject: [PATCH 015/540] Add screenshots to README. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5564fd341..2fee1ebc3 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ This is the first release, which is more than usable but still has its rough edg ## Demo +![Screenshots](https://raw.github.com/EverythingMe/redash/screenshots/screenshots.gif) + You can try out the demo instance: http://rd-demo.herokuapp.com/ (login with any Google account). Due to Heroku dev plan limits, it has a small database of flights (see schema [here](http://rd-demo.herokuapp.com/dashboard/schema)). Also due to another Heroku limitation, it is running with the regular user, hence you can DELETE or INSERT data/tables. Please be nice and don't do this. From 7bdd3074f5f96e9cdc4e8d53f203e25299d7c369 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 13:10:16 +0200 Subject: [PATCH 016/540] Add getting started instructions. --- README.md | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2fee1ebc3..8eee5f588 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,41 @@ HighCharts is really great, but it's not free for commercial use. Please refer t It's very likely that in the future we will switch to [D3.js](http://d3js.org/) instead. -## How to install +## Getting Started -TBD. +1. Clone the repo: +```bash +git clone git@github.com:EverythingMe/redash.git +``` +2. Create settings file from the example one (& update relevant settings): +```bash +cp rd_service/settings_example.py rd_service/settings.py +``` +> It's highly recommended that the user you use to connect to the data database (the one you query) is read-only. + +3. Install `npm` packages (mainly: Bower & Grunt): +```bash +cd rd_ui +npm install +``` +4. Install `bower` packages: +```bash +bower install +``` +5. Build the UI: +```bash +grunt build +``` +6. Start the API server: +```bash +cd ../rd_service +python server.py +``` +7. Start the workers: +```bash +python cli.py worker +``` +8. Open `http://localhost:8888/` and query away. ## Roadmap From 03fbf41a350734746ef6ffc36cca30e7edb1201b Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 16:38:09 +0200 Subject: [PATCH 017/540] Fix: stop sending latest_query_data back to the server. Fixes #31 --- rd_ui/app/scripts/controllers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index c6b89544e..5fa5136d8 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -52,6 +52,7 @@ if (!oldId) { oldId = $scope.query.id; } + delete $scope.query.latest_query_data; $scope.query.$save(function (q) { if (duplicate) { growl.addInfoMessage("Query duplicated.", {ttl: 2000}); From 2017a2c9db50fff94039d2b6d5879e3ca4d0ddea Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 16:43:18 +0200 Subject: [PATCH 018/540] Fix #18: don't retrieve query results when already have them & ttl = -1. --- rd_ui/app/scripts/services.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 946395db2..d66277f09 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -248,9 +248,9 @@ } var queryResult = null; - if (this.latest_query_data && ttl > 0) { + if (this.latest_query_data && ttl != 0) { queryResult = new QueryResult({'query_result': this.latest_query_data}); - } else if (this.latest_query_data_id && ttl > 0) { + } else if (this.latest_query_data_id && ttl != 0) { queryResult = QueryResult.getById(this.latest_query_data_id); } else { queryResult = QueryResult.get(this.query, ttl); From cf1d20c958ef93052e0b6f134b6345fff8645f0e Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 16:51:08 +0200 Subject: [PATCH 019/540] Don't send query result when saving a query --- rd_service/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rd_service/server.py b/rd_service/server.py index 39f84a880..738b3e1f7 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -205,7 +205,7 @@ class QueriesHandler(BaseHandler): query = data.models.Query(**query_def) query.save() - self.write_json(query.to_dict()) + self.write_json(query.to_dict(with_result=False)) def get(self, id=None): if id: From c0c2fed6acb56472b934cd297330859541bbb080 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 17:11:00 +0200 Subject: [PATCH 020/540] Fix: when query had unicode characters it was failling to execute --- rd_service/data/utils.py | 2 +- rd_service/data/worker.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rd_service/data/utils.py b/rd_service/data/utils.py index 40d938efe..0d28a26c7 100644 --- a/rd_service/data/utils.py +++ b/rd_service/data/utils.py @@ -20,7 +20,7 @@ def gen_query_hash(sql): """ sql = COMMENTS_REGEX.sub("", sql) sql = "".join(sql.split()).lower() - return hashlib.md5(sql).hexdigest() + return hashlib.md5(sql.encode('utf-8')).hexdigest() class JSONEncoder(json.JSONEncoder): diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index b0915c215..2579f5ba8 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -24,7 +24,7 @@ class Job(object): wait_time=None, query_time=None, updated_at=None, status=None, error=None, query_result_id=None): self.data_manager = data_manager - self.query = query + self.query = query.decode('utf-8') self.priority = priority self.query_hash = gen_query_hash(self.query) self.query_result_id = query_result_id From 2c21ae28d96f438e25acce7996d916ffe9233383 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 17:38:05 +0200 Subject: [PATCH 021/540] Fix: move utf deocding to proper place --- rd_service/data/worker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index 2579f5ba8..97ad419e6 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -24,7 +24,7 @@ class Job(object): wait_time=None, query_time=None, updated_at=None, status=None, error=None, query_result_id=None): self.data_manager = data_manager - self.query = query.decode('utf-8') + self.query = query self.priority = priority self.query_hash = gen_query_hash(self.query) self.query_result_id = query_result_id @@ -111,7 +111,7 @@ class Job(object): job_dict = cls._load(data_manager, job_id) job = None if job_dict: - job = Job(data_manager, job_id=job_dict['id'], query=job_dict['query'], + job = Job(data_manager, job_id=job_dict['id'], query=job_dict['query'].decode('utf-8'), priority=int(job_dict['priority']), updated_at=float(job_dict['updated_at']), status=int(job_dict['status']), wait_time=float(job_dict['wait_time']), query_time=float(job_dict['query_time']), error=job_dict['error'], From 783328eebb4d1a744fb6cd42be2acbd4eda9c78b Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 17:45:26 +0200 Subject: [PATCH 022/540] Fix: columns with multiple spaces were not showing correctly --- rd_ui/app/scripts/services.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index d66277f09..18f4c926c 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -141,13 +141,13 @@ var parts = column.split('::'); var name = parts[1]; if (parts[0] != '') { - name = parts[0].replace(' ', '_').replace(/\?/g,''); + name = parts[0].replace(/ /g, '_').replace(/\?/g,''); } return name; } QueryResult.prototype.getColumnFriendlyName = function (column) { - return this.getColumnCleanName(column).replace('_', ' ').replace(/(?:^|\s)\S/g, function (a) { + return this.getColumnCleanName(column).replace(/_/g, ' ').replace(/(?:^|\s)\S/g, function (a) { return a.toUpperCase(); }); } From 7a1893cc8a728a03ad4b8d661e92c8342b728740 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 30 Oct 2013 18:25:17 +0200 Subject: [PATCH 023/540] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8eee5f588..8334d00b5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# [_re:dash_](https://github.com/everythingme/re:dash) +# [_re:dash_](https://github.com/everythingme/redash) **_re:dash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns. From 18e56fab57fdfb174173a9d06a894216106fe872 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Thu, 31 Oct 2013 12:22:45 +0200 Subject: [PATCH 024/540] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 8334d00b5..9d6fb1c64 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ bower install ```bash grunt build ``` +6. Install PIP packages: +```bash +pip install -r requirements.txt +``` 6. Start the API server: ```bash cd ../rd_service From e2e61b7c59b2212c319069e1e5ce8584d65a7155 Mon Sep 17 00:00:00 2001 From: Shay Elkin Date: Thu, 31 Oct 2013 17:14:59 +0200 Subject: [PATCH 025/540] Fixed installation dependencies, instructions * rd_ui/package.json: * Add missing bower, grunt-cli dependencies * Take grunt from HEAD, as 0.4.1 has broken dependency: https://github.com/gruntjs/grunt/commit/08a3af53ffe9a0fcb15102088f7407120bd5a397 * README.md: * Update instructions --- README.md | 6 +++--- rd_ui/package.json | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9d6fb1c64..921d062ef 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ git clone git@github.com:EverythingMe/redash.git ```bash cp rd_service/settings_example.py rd_service/settings.py ``` -> It's highly recommended that the user you use to connect to the data database (the one you query) is read-only. - +It's highly recommended that the user you use to connect to the data database (the one you query) is read-only. +3. Create the operational databases from rd_service/data/tables.sql 3. Install `npm` packages (mainly: Bower & Grunt): ```bash cd rd_ui @@ -65,7 +65,7 @@ grunt build ``` 6. Install PIP packages: ```bash -pip install -r requirements.txt +pip install -r ../rd_service/requirements.txt ``` 6. Start the API server: ```bash diff --git a/rd_ui/package.json b/rd_ui/package.json index 325a5acbd..3b476a555 100644 --- a/rd_ui/package.json +++ b/rd_ui/package.json @@ -5,7 +5,7 @@ "grunt-karma": "~0.6.1" }, "devDependencies": { - "grunt": "~0.4.1", + "grunt": "git+https://github.com/gruntjs/grunt.git", "grunt-contrib-copy": "~0.4.1", "grunt-contrib-concat": "~0.3.0", "grunt-contrib-coffee": "~0.7.0", @@ -28,7 +28,9 @@ "connect-livereload": "~0.2.0", "grunt-google-cdn": "~0.2.0", "grunt-ngmin": "~0.0.2", - "time-grunt": "~0.1.0" + "time-grunt": "~0.1.0", + "bower": "~1.2.7", + "grunt-cli": "~0.1.9" }, "engines": { "node": ">=0.8.0" From d6d3dfcb356aa5306156db92b19c9cc1cbf9ee34 Mon Sep 17 00:00:00 2001 From: Shay Elkin Date: Sat, 2 Nov 2013 15:15:31 +0200 Subject: [PATCH 026/540] Take specific grunt commit (that fixes gh-886) --- rd_ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rd_ui/package.json b/rd_ui/package.json index 3b476a555..f703c69eb 100644 --- a/rd_ui/package.json +++ b/rd_ui/package.json @@ -5,7 +5,7 @@ "grunt-karma": "~0.6.1" }, "devDependencies": { - "grunt": "git+https://github.com/gruntjs/grunt.git", + "grunt": "git+https://github.com/gruntjs/grunt.git#08a3af5", "grunt-contrib-copy": "~0.4.1", "grunt-contrib-concat": "~0.3.0", "grunt-contrib-coffee": "~0.7.0", From 7ca1b5e761d64743a895ce2c652221c36bc1a755 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 4 Nov 2013 15:51:58 +0200 Subject: [PATCH 027/540] Make qr take redis connection params from the general redis connection --- rd_service/data/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rd_service/data/manager.py b/rd_service/data/manager.py index 9babcba90..3af229f30 100644 --- a/rd_service/data/manager.py +++ b/rd_service/data/manager.py @@ -30,7 +30,7 @@ class Manager(object): self.workers = [] self.db_connection_pool = psycopg2.pool.ThreadedConnectionPool(1, db_max_connections, db_connection_string) - self.queue = qr.PriorityQueue("jobs") + self.queue = qr.PriorityQueue("jobs", **self.redis_connection.connection_pool.connection_kwargs) self.max_retries = 5 # TODO: Use our Django Models From 2b8a3f66e9b334575024181794dee98e61d6d8dd Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 4 Nov 2013 17:11:45 +0200 Subject: [PATCH 028/540] Update title to use | instead of : --- rd_ui/app/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index e6e3c77c9..ce59987c5 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -4,7 +4,7 @@ - + From 770a8a8d5714c4aa7e5f9b7439e0beb362345328 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 5 Nov 2013 13:27:28 +0200 Subject: [PATCH 029/540] Fix: when series name was 0 (the number) it would use the y name instead --- rd_ui/app/scripts/services.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 18f4c926c..61be9b940 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -86,10 +86,11 @@ _.each(this.getData(), function (row) { var point = {}; - var seriesName = ""; + var seriesName = undefined; var yName = ""; var xName = ""; + _.map(row, function (value, definition) { var type = definition.split("::")[1]; var name = definition.split("::")[0]; @@ -105,11 +106,11 @@ } if (type == 'series') { - seriesName = value; + seriesName = value.toString(); } }); - if (seriesName == "") { + if (seriesName === undefined) { seriesName = yName; } From 584c0d8509e17f5c710ea9ec64eae54202219d00 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 5 Nov 2013 13:50:27 +0200 Subject: [PATCH 030/540] Support multiple series on one row --- rd_ui/app/scripts/services.js | 38 ++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 61be9b940..136a4665a 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -87,21 +87,20 @@ _.each(this.getData(), function (row) { var point = {}; var seriesName = undefined; - var yName = ""; - var xName = ""; + var xValue = 0; + var yValues = {}; - - _.map(row, function (value, definition) { + _.each(row, function (value, definition) { var type = definition.split("::")[1]; var name = definition.split("::")[0]; if (type == 'x') { - xName = name; + xValue = value; point[type] = value; } if (type == 'y') { - yName = name; + yValues[name] = value; point[type] = value; } @@ -110,19 +109,26 @@ } }); - if (seriesName === undefined) { - seriesName = yName; - } - - if (series[seriesName] == undefined) { - series[seriesName] = { - name: seriesName, - type: 'column', - data: [] + var addPointToSeries = function(seriesName, point) { + if (series[seriesName] == undefined) { + series[seriesName] = { + name: seriesName, + type: 'column', + data: [] + } } + + series[seriesName]['data'].push(point); + } + + if (seriesName === undefined) { + _.each(yValues, function(yValue, seriesName) { + addPointToSeries(seriesName, {'x': xValue, 'y': yValue}); + }); + } else { + addPointToSeries(seriesName, point); } - series[seriesName]['data'].push(point); }); return _.values(series); From 5d3e40130288708d0bd5263a0d52e191dcfeea3d Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 6 Nov 2013 15:03:08 +0200 Subject: [PATCH 031/540] Show shared tooltip & percentage --- rd_ui/app/scripts/query_fiddle/renderers.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index 2878850a8..9b085e072 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -4,7 +4,24 @@ var defaultChartOptions = { "text": null }, "tooltip": { - valueDecimals: 2 + valueDecimals: 2, + formatter: function () { + var s = '' + moment(this.x).format("DD/MM/YY HH:mm") + '', + pointsCount = this.points.length; + + + $.each(this.points, function (i, point) { + s += '
' + point.series.name + ': ' + + Highcharts.numberFormat(point.y); + + if (pointsCount > 1 && point.percentage) { + s += " (" + Highcharts.numberFormat(point.percentage) + "%)"; + } + }); + + return s; + }, + shared: true }, xAxis: { type: 'datetime' From 340284e09b62e36a4b095e21817fb4edc96fa125 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 6 Nov 2013 15:24:41 +0200 Subject: [PATCH 032/540] Fixes #38: getChartData returns sorted data by x --- rd_ui/app/scripts/services.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 136a4665a..13ad4a7aa 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -128,7 +128,10 @@ } else { addPointToSeries(seriesName, point); } + }); + _.each(series, function(series) { + series.data = _.sortBy(series.data, 'x'); }); return _.values(series); From b88dbb1a6f59342f97be7c1ed1e90bcbdf9230e4 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sun, 17 Nov 2013 13:41:45 +0200 Subject: [PATCH 033/540] Add support for displaying cohorts in dashbaords --- rd_ui/app/scripts/directives.js | 2 +- rd_ui/app/views/dashboard.html | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index a2149954e..313f1944f 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -112,7 +112,7 @@ directives.directive('newWidgetForm', ['$http', function($http) { templateUrl: '/views/new_widget_form.html', replace: true, link: function($scope, element, attrs) { - $scope.widgetTypes = [{name: 'Chart', value: 'chart'}, {name: 'Table', value: 'grid'}]; + $scope.widgetTypes = [{name: 'Chart', value: 'chart'}, {name: 'Table', value: 'grid'}, {name: 'Cohort', value: 'cohort'}]; $scope.widgetSizes = [{name: 'Regular Size', value: 1}, {name: 'Double Size', value: 2}]; var reset = function() { diff --git a/rd_ui/app/views/dashboard.html b/rd_ui/app/views/dashboard.html index 30f2b4078..b16ac3ea3 100644 --- a/rd_ui/app/views/dashboard.html +++ b/rd_ui/app/views/dashboard.html @@ -37,6 +37,7 @@
+
From 0a7949540cd704c33a3dfcf1449108bcd41e8836 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sun, 24 Nov 2013 16:06:01 +0200 Subject: [PATCH 034/540] Fix: use String ctor function instead of toString to handle nulls --- rd_ui/app/scripts/services.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 13ad4a7aa..18d5df11b 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -105,7 +105,7 @@ } if (type == 'series') { - seriesName = value.toString(); + seriesName = value ? value.toString() : "null"; } }); From 0f89a12a3d7d2185452e6482319056ebbcbaba39 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sun, 24 Nov 2013 16:06:01 +0200 Subject: [PATCH 035/540] Fix: use String ctor function instead of toString to handle nulls --- rd_ui/app/scripts/services.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 18d5df11b..47b3a75e2 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -105,7 +105,7 @@ } if (type == 'series') { - seriesName = value ? value.toString() : "null"; + seriesName = String(value); } }); From 24492bbe2d8f8315085dedca3aa58bb689d4f5ef Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 29 Nov 2013 20:40:26 +0200 Subject: [PATCH 036/540] Log exception when refresh queries fails --- rd_service/cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rd_service/cli.py b/rd_service/cli.py index fae1c0a21..be6112717 100644 --- a/rd_service/cli.py +++ b/rd_service/cli.py @@ -21,8 +21,9 @@ def start_workers(data_manager): while True: try: data_manager.refresh_queries() - except Exception: - logging.error("Something went wrong with refreshing queries..."); + except Exception as e: + logging.error("Something went wrong with refreshing queries...") + logging.exception(e) time.sleep(60) except KeyboardInterrupt: logging.warning("Exiting; waiting for threads") @@ -32,6 +33,7 @@ def start_workers(data_manager): if __name__ == '__main__': channel = logging.StreamHandler() logging.getLogger().addHandler(channel) + # TODO: take logging level from configuration logging.getLogger().setLevel("DEBUG") parser = argparse.ArgumentParser() From 1131df9c7844a2ec5107a5b2d8ee3a3de5a224da Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 29 Nov 2013 20:40:44 +0200 Subject: [PATCH 037/540] Run query in forked process --- rd_service/data/manager.py | 2 +- rd_service/data/worker.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/rd_service/data/manager.py b/rd_service/data/manager.py index 3af229f30..bd4ab9acd 100644 --- a/rd_service/data/manager.py +++ b/rd_service/data/manager.py @@ -147,7 +147,7 @@ class Manager(object): pg_connection_pool = psycopg2.pool.ThreadedConnectionPool(1, max_connections, connection_string) runner = query_runner.redshift(pg_connection_pool) - self.workers = [worker.Worker(self, runner) for i in range(workers_count)] + self.workers = [worker.Worker(self, runner) for _ in range(workers_count)] for w in self.workers: w.start() diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index 97ad419e6..ff3833dfb 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -3,6 +3,7 @@ Worker implementation to execute incoming queries. """ import json import logging +import os import threading import uuid import datetime @@ -135,11 +136,21 @@ class Worker(threading.Thread): job_id = self.manager.queue.pop() if job_id: logging.info("[%s] Processing %s", self.name, job_id) - self._process(job_id) - logging.info("[%s] Finished Processing %s", self.name, job_id) + + self._fork_and_process(job_id) else: time.sleep(self.sleep_time) + def _fork_and_process(self, job_id): + child_pid = os.fork() + if child_pid == 0: + self._process(job_id) + else: + logging.info("[%s] Waiting for pid: %d", self.name, child_pid) + _, status = os.waitpid(child_pid, 0) + logging.info("[%s] Finished Processing %s (pid: %d status: %d)", + self.name, job_id, child_pid, status) + def _process(self, job_id): job = Job.load(self.manager, job_id) if job.is_finished(): From a88582a57986182abb2b976abe1eafd607efa8cc Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 29 Nov 2013 20:53:51 +0200 Subject: [PATCH 038/540] Fix: child process wasn't exiting --- rd_service/data/worker.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index ff3833dfb..801468632 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -127,6 +127,7 @@ class Worker(threading.Thread): self.continue_working = True self.query_runner = query_runner self.sleep_time = sleep_time + self.child_pid = None super(Worker, self).__init__(name="Worker-%s" % uuid.uuid1()) @@ -136,20 +137,21 @@ class Worker(threading.Thread): job_id = self.manager.queue.pop() if job_id: logging.info("[%s] Processing %s", self.name, job_id) - self._fork_and_process(job_id) + if self.child_pid == 0: + return else: time.sleep(self.sleep_time) def _fork_and_process(self, job_id): - child_pid = os.fork() - if child_pid == 0: + self.child_pid = os.fork() + if self.child_pid == 0: self._process(job_id) else: - logging.info("[%s] Waiting for pid: %d", self.name, child_pid) - _, status = os.waitpid(child_pid, 0) + logging.info("[%s] Waiting for pid: %d", self.name, self.child_pid) + _, status = os.waitpid(self.child_pid, 0) logging.info("[%s] Finished Processing %s (pid: %d status: %d)", - self.name, job_id, child_pid, status) + self.name, job_id, self.child_pid, status) def _process(self, job_id): job = Job.load(self.manager, job_id) From 7ef1ed400ba56cb10e58e5e24569e241c08f9e19 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 29 Nov 2013 20:54:41 +0200 Subject: [PATCH 039/540] Don't use connection pool in query runner --- rd_service/cli.py | 3 +-- rd_service/data/manager.py | 6 ++---- rd_service/data/query_runner.py | 9 ++++----- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/rd_service/cli.py b/rd_service/cli.py index be6112717..b706c7acb 100644 --- a/rd_service/cli.py +++ b/rd_service/cli.py @@ -14,8 +14,7 @@ import data def start_workers(data_manager): try: - data_manager.start_workers(settings.WORKERS_COUNT, settings.CONNECTION_STRING, - settings.MAX_CONNECTIONS) + data_manager.start_workers(settings.WORKERS_COUNT, settings.CONNECTION_STRING) logging.info("Workers started.") while True: diff --git a/rd_service/data/manager.py b/rd_service/data/manager.py index bd4ab9acd..825b7ede2 100644 --- a/rd_service/data/manager.py +++ b/rd_service/data/manager.py @@ -139,13 +139,11 @@ class Manager(object): return data - def start_workers(self, workers_count, connection_string, max_connections): + def start_workers(self, workers_count, connection_string): if self.workers: return self.workers - # TODO: who closes the connection pool? - pg_connection_pool = psycopg2.pool.ThreadedConnectionPool(1, max_connections, connection_string) - runner = query_runner.redshift(pg_connection_pool) + runner = query_runner.redshift(connection_string) self.workers = [worker.Worker(self, runner) for _ in range(workers_count)] for w in self.workers: diff --git a/rd_service/data/query_runner.py b/rd_service/data/query_runner.py index 5ea4d1a1d..2aeaa20d2 100644 --- a/rd_service/data/query_runner.py +++ b/rd_service/data/query_runner.py @@ -12,12 +12,12 @@ import sys from .utils import JSONEncoder -def redshift(connection_pool): +def redshift(connection_string): def column_friendly_name(column_name): return column_name def query_runner(query): - connection = connection_pool.getconn() + connection = psycopg2.connect(connection_string) cursor = connection.cursor() try: @@ -41,10 +41,9 @@ def redshift(connection_pool): except Exception as e: connection.rollback() - connection_pool.putconn(connection) raise sys.exc_info()[1], None, sys.exc_info()[2] - - connection_pool.putconn(connection) + finally: + connection.close() return json_data, error From 8381bd14c57afcb6e702c85c2ded035af588314f Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 29 Nov 2013 20:59:48 +0200 Subject: [PATCH 040/540] If worker forked process didn't exit cleanly, update job --- rd_service/data/worker.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index 801468632..7cf90dfc4 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -150,6 +150,13 @@ class Worker(threading.Thread): else: logging.info("[%s] Waiting for pid: %d", self.name, self.child_pid) _, status = os.waitpid(self.child_pid, 0) + if status > 0: + job = Job.load(self.manager, job_id) + if not job.is_finished(): + logging.info("[%s] process interrupted and job %s hasn't finished; registering interruption in job", + self.name, job_id) + job.done(None, "Interrupted/Cancelled while running.") + logging.info("[%s] Finished Processing %s (pid: %d status: %d)", self.name, job_id, self.child_pid, status) From a604fcf8a4aa7c7cc0b9947e3ee59eee22906ae1 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 29 Nov 2013 21:04:22 +0200 Subject: [PATCH 041/540] Store job process id --- rd_service/data/worker.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index 7cf90dfc4..4b2c3087b 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -23,12 +23,14 @@ class Job(object): def __init__(self, data_manager, query, priority, job_id=None, wait_time=None, query_time=None, - updated_at=None, status=None, error=None, query_result_id=None): + updated_at=None, status=None, error=None, query_result_id=None, + process_id=0): self.data_manager = data_manager self.query = query self.priority = priority self.query_hash = gen_query_hash(self.query) self.query_result_id = query_result_id + self.process_id = process_id if job_id is None: self.id = str(uuid.uuid1()) @@ -57,7 +59,8 @@ class Job(object): 'updated_at': self.updated_at, 'status': self.status, 'error': self.error, - 'query_result_id': self.query_result_id + 'query_result_id': self.query_result_id, + 'process_id': self.process_id } @staticmethod @@ -79,8 +82,9 @@ class Job(object): pipe.publish(self._redis_key(self.id), json.dumps(self.to_dict())) pipe.execute() - def processing(self): + def processing(self, process_id): self.status = self.PROCESSING + self.process_id = process_id self.wait_time = time.time() - self.updated_at self.updated_at = time.time() self.save() @@ -116,7 +120,8 @@ class Job(object): priority=int(job_dict['priority']), updated_at=float(job_dict['updated_at']), status=int(job_dict['status']), wait_time=float(job_dict['wait_time']), query_time=float(job_dict['query_time']), error=job_dict['error'], - query_result_id=job_dict['query_result_id']) + query_result_id=job_dict['query_result_id'], + process_id=int(job_dict['process_id'])) return job @@ -166,7 +171,7 @@ class Worker(threading.Thread): logging.warning("[%s][%s] tried to process finished job.", self.name, job) return - job.processing() + job.processing(os.getpid()) logging.info("[%s][%s] running query...", self.name, job.id) start_time = time.time() From e8aa8e094f21d0902e0e26ad10174b5adae0ba21 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 29 Nov 2013 21:09:24 +0200 Subject: [PATCH 042/540] Naive implementation of job cancel --- rd_service/data/worker.py | 12 ++++++++++++ rd_service/server.py | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index 4b2c3087b..7347f1b07 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -8,6 +8,7 @@ import threading import uuid import datetime import time +import signal from utils import gen_query_hash @@ -67,6 +68,17 @@ class Job(object): def _redis_key(job_id): return 'job:%s' % job_id + def cancel(self): + # TODO: Race condition: + # it's possible that it will be picked up by worker while processing the cancel order + if self.is_finished(): + return + + if self.status == self.PROCESSING: + os.kill(self.process_id, signal.SIGINT) + else: + self.done(None, "Interrupted/Cancelled while running.") + def save(self, pipe=None): if not pipe: pipe = self.data_manager.redis_connection.pipeline() diff --git a/rd_service/server.py b/rd_service/server.py index 738b3e1f7..5bfcd4ac6 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -251,8 +251,9 @@ class JobsHandler(BaseHandler): raise NotImplemented def delete(self, job_id): - raise NotImplemented - + job = data.Job.load(self.data_manager, job_id) + job.cancel() + class CsvQueryResultsHandler(BaseHandler): def get(self, query_result_id): From 1e3be5b4b87bb7c3857a27ff36d443631042dcfd Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 29 Nov 2013 21:27:50 +0200 Subject: [PATCH 043/540] Job: support for old job that had no process id. --- rd_service/data/worker.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index 7347f1b07..6d0cb4f9b 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -31,7 +31,10 @@ class Job(object): self.priority = priority self.query_hash = gen_query_hash(self.query) self.query_result_id = query_result_id - self.process_id = process_id + if process_id == 'None': + self.process_id = None + else: + self.process_id = int(process_id) if job_id is None: self.id = str(uuid.uuid1()) @@ -133,7 +136,7 @@ class Job(object): status=int(job_dict['status']), wait_time=float(job_dict['wait_time']), query_time=float(job_dict['query_time']), error=job_dict['error'], query_result_id=job_dict['query_result_id'], - process_id=int(job_dict['process_id'])) + process_id=job_dict['process_id']) return job From e47f78f657f5291c99287a9eb4321202e98a07de Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 29 Nov 2013 21:28:04 +0200 Subject: [PATCH 044/540] Job: when cancelling send SIGKILL instead of SIGINT. --- rd_service/data/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index 6d0cb4f9b..d4a551fb0 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -78,7 +78,7 @@ class Job(object): return if self.status == self.PROCESSING: - os.kill(self.process_id, signal.SIGINT) + os.kill(self.process_id, signal.SIGKILL) else: self.done(None, "Interrupted/Cancelled while running.") From 206709b703caa7171532eb9bb3904cb3c359031b Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 29 Nov 2013 21:28:20 +0200 Subject: [PATCH 045/540] Show cancel button in UI. --- rd_service/server.py | 2 +- rd_ui/app/scripts/controllers.js | 7 +++++++ rd_ui/app/scripts/services.js | 4 ++++ rd_ui/app/views/queryfiddle.html | 10 ++++++++-- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/rd_service/server.py b/rd_service/server.py index 5bfcd4ac6..d1463074c 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -253,7 +253,7 @@ class JobsHandler(BaseHandler): def delete(self, job_id): job = data.Job.load(self.data_manager, job_id) job.cancel() - + class CsvQueryResultsHandler(BaseHandler): def get(self, query_result_id): diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 5fa5136d8..e13af8fcb 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -154,7 +154,14 @@ $scope.executeQuery = function() { $scope.queryResult = $scope.query.getQueryResult(0); $scope.lockButton(true); + $scope.cancelling = false; } + + $scope.cancelExecution = function() { + $scope.cancelling = true; + $scope.queryResult.cancelExecution(); + } + } var QueriesCtrl = function($scope, $http, $location, Query) { diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 47b3a75e2..847e6afaa 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -51,6 +51,10 @@ return id; } + QueryResult.prototype.cancelExecution = function() { + Job.delete({id: this.job.id}); + } + QueryResult.prototype.getStatus = function() { return this.status || statuses[this.job.status]; } diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index cd44030bc..3da0f4082 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -34,8 +34,14 @@
-
Executing query...
-
Query in queue...
+
+ Executing query... + +
+
+ Query in queue... + +
Error running query: {{queryResult.getError()}}
From cd03948164934858692d16622daaf74f355c789f Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sun, 1 Dec 2013 11:52:14 +0200 Subject: [PATCH 046/540] Instead of killing the process, send SIGINT and cancel query on interupt. --- rd_service/data/query_runner.py | 26 ++++++++++++++++++++++---- rd_service/data/worker.py | 2 +- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/rd_service/data/query_runner.py b/rd_service/data/query_runner.py index 2aeaa20d2..29631ce39 100644 --- a/rd_service/data/query_runner.py +++ b/rd_service/data/query_runner.py @@ -9,6 +9,8 @@ query language (for example: HiveQL). import json import psycopg2 import sys +import signal +import select from .utils import JSONEncoder @@ -16,12 +18,27 @@ def redshift(connection_string): def column_friendly_name(column_name): return column_name + def wait(conn): + while 1: + state = conn.poll() + if state == psycopg2.extensions.POLL_OK: + break + elif state == psycopg2.extensions.POLL_WRITE: + select.select([], [conn.fileno()], []) + elif state == psycopg2.extensions.POLL_READ: + select.select([conn.fileno()], [], []) + else: + raise psycopg2.OperationalError("poll() returned %s" % state) + def query_runner(query): - connection = psycopg2.connect(connection_string) + connection = psycopg2.connect(connection_string, async=True) + wait(connection) + cursor = connection.cursor() try: cursor.execute(query) + wait(connection) column_names = [col.name for col in cursor.description] @@ -35,12 +52,13 @@ def redshift(connection_string): error = None cursor.close() except psycopg2.DatabaseError as e: - connection.rollback() json_data = None error = e.message - + except KeyboardInterrupt: + connection.cancel() + error = "Query cancelled by user." + json_data = None except Exception as e: - connection.rollback() raise sys.exc_info()[1], None, sys.exc_info()[2] finally: connection.close() diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index d4a551fb0..6d0cb4f9b 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -78,7 +78,7 @@ class Job(object): return if self.status == self.PROCESSING: - os.kill(self.process_id, signal.SIGKILL) + os.kill(self.process_id, signal.SIGINT) else: self.done(None, "Interrupted/Cancelled while running.") From 25c6bc2252c0e897c35b91e8b061e531e3db1a67 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 6 Dec 2013 15:14:39 +0200 Subject: [PATCH 047/540] Improved system status. --- rd_service/cli.py | 8 ++++++-- rd_service/data/manager.py | 17 ++++++++++++++++ rd_service/data/query_runner.py | 1 - rd_service/data/worker.py | 27 +++++++++++++++++++++++++- rd_service/server.py | 21 +++++++++++++++++++- rd_ui/app/scripts/admin_controllers.js | 4 ++++ rd_ui/app/views/admin_status.html | 16 +++++++++++++++ 7 files changed, 89 insertions(+), 5 deletions(-) diff --git a/rd_service/cli.py b/rd_service/cli.py index b706c7acb..36648bd06 100644 --- a/rd_service/cli.py +++ b/rd_service/cli.py @@ -14,6 +14,11 @@ import data def start_workers(data_manager): try: + old_workers = data_manager.redis_connection.smembers('workers') + data_manager.redis_connection.delete('workers') + + logging.info("Cleaning old workers: %s", old_workers) + data_manager.start_workers(settings.WORKERS_COUNT, settings.CONNECTION_STRING) logging.info("Workers started.") @@ -32,8 +37,7 @@ def start_workers(data_manager): if __name__ == '__main__': channel = logging.StreamHandler() logging.getLogger().addHandler(channel) - # TODO: take logging level from configuration - logging.getLogger().setLevel("DEBUG") + logging.getLogger().setLevel(settings.LOG_LEVEL) parser = argparse.ArgumentParser() parser.add_argument("command") diff --git a/rd_service/data/manager.py b/rd_service/data/manager.py index 825b7ede2..f307c4967 100644 --- a/rd_service/data/manager.py +++ b/rd_service/data/manager.py @@ -9,6 +9,7 @@ import psycopg2 import psycopg2.pool import qr import redis +import time import query_runner import worker from utils import gen_query_hash @@ -32,6 +33,12 @@ class Manager(object): db_connection_string) self.queue = qr.PriorityQueue("jobs", **self.redis_connection.connection_pool.connection_kwargs) self.max_retries = 5 + self.status = { + 'last_refresh_at': 0, + 'started_at': time.time() + } + + self._save_status() # TODO: Use our Django Models def get_query_result_by_id(self, query_result_id): @@ -95,6 +102,8 @@ class Manager(object): return job def refresh_queries(self): + logging.info("Refreshing queries...") + sql = """SELECT queries.query, queries.ttl, retrieved_at FROM (SELECT query, min(ttl) as ttl FROM queries WHERE ttl > 0 GROUP by query) queries JOIN (SELECT query, max(retrieved_at) as retrieved_at @@ -103,10 +112,15 @@ class Manager(object): WHERE queries.ttl > 0 AND query_results.retrieved_at + ttl * interval '1 second' < now() at time zone 'utc';""" + self.status['last_refresh_at'] = time.time() + self._save_status() + queries = self.run_query(sql) for query, ttl, retrieved_at in queries: self.add_job(query, worker.Job.LOW_PRIORITY) + logging.info("Done refreshing queries... %d" % len(queries)) + def store_query_result(self, query, data, run_time, retrieved_at): query_result_id = None query_hash = gen_query_hash(query) @@ -169,3 +183,6 @@ class Manager(object): connection.commit() finally: self.db_connection_pool.putconn(connection) + + def _save_status(self): + self.redis_connection.hmset('manager:status', self.status) diff --git a/rd_service/data/query_runner.py b/rd_service/data/query_runner.py index 29631ce39..a39fff8c6 100644 --- a/rd_service/data/query_runner.py +++ b/rd_service/data/query_runner.py @@ -9,7 +9,6 @@ query language (for example: HiveQL). import json import psycopg2 import sys -import signal import select from .utils import JSONEncoder diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index 6d0cb4f9b..f7df3b022 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -148,14 +148,25 @@ class Worker(threading.Thread): self.query_runner = query_runner self.sleep_time = sleep_time self.child_pid = None + self.worker_id = uuid.uuid1() + self.status = { + 'jobs_count': 0, + 'cancelled_jobs_count': 0, + 'done_jobs_count': 0, + 'updated_at': time.time(), + 'started_at': time.time() + } + self._save_status() + self.manager.redis_connection.sadd('workers', self._key) - super(Worker, self).__init__(name="Worker-%s" % uuid.uuid1()) + super(Worker, self).__init__(name="Worker-%s" % self.worker_id) def run(self): logging.info("[%s] started.", self.name) while self.continue_working: job_id = self.manager.queue.pop() if job_id: + self._update_status('jobs_count') logging.info("[%s] Processing %s", self.name, job_id) self._fork_and_process(job_id) if self.child_pid == 0: @@ -163,6 +174,18 @@ class Worker(threading.Thread): else: time.sleep(self.sleep_time) + def _update_status(self, counter): + self.status['updated_at'] = time.time() + self.status[counter] += 1 + self._save_status() + + @property + def _key(self): + return 'worker:%s' % self.worker_id + + def _save_status(self): + self.manager.redis_connection.hmset(self._key, self.status) + def _fork_and_process(self, job_id): self.child_pid = os.fork() if self.child_pid == 0: @@ -171,8 +194,10 @@ class Worker(threading.Thread): logging.info("[%s] Waiting for pid: %d", self.name, self.child_pid) _, status = os.waitpid(self.child_pid, 0) if status > 0: + self._update_status('done_jobs_count') job = Job.load(self.manager, job_id) if not job.is_finished(): + self._update_status('cancelled_jobs_count') logging.info("[%s] process interrupted and job %s hasn't finished; registering interruption in job", self.name, job_id) job.done(None, "Interrupted/Cancelled while running.") diff --git a/rd_service/server.py b/rd_service/server.py index d1463074c..3ecf1f4a1 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -30,6 +30,7 @@ import tornado.web import tornado.auth import tornado.options import settings +import time from data import utils import data @@ -105,12 +106,30 @@ class StatusHandler(BaseHandler): status = {} info = self.redis_connection.info() status['redis_used_memory'] = info['used_memory_human'] - status['queries_in_queue'] = self.redis_connection.zcard('jobs') + status['queries_count'] = data.models.Query.objects.count() status['query_results_count'] = data.models.QueryResult.objects.count() status['dashboards_count'] = data.models.Dashboard.objects.count() status['widgets_count'] = data.models.Widget.objects.count() + workers = [self.redis_connection.hgetall(w) + for w in self.redis_connection.smembers('workers')] + + status['workers'] = [] + for worker in workers: + w = {} + w['uptime'] = "%.0f seconds" % (time.time() - float(worker['started_at'])) + w['last_updated_at'] = datetime.datetime.fromtimestamp(float(worker['updated_at'])).strftime('%Y-%m-%d %H:%M:%S') + w['jobs_received'] = worker['jobs_count'] + w['jobs_done'] = worker['done_jobs_count'] + status['workers'].append(w) + + manager_status = self.redis_connection.hgetall('manager:status') + status['manager'] = {} + status['manager']['uptime'] = "%.0f seconds" % (time.time() - float(manager_status['started_at'])) + status['manager']['last_refresh_at'] = datetime.datetime.fromtimestamp(float(manager_status['last_refresh_at'])).strftime('%Y-%m-%d %H:%M:%S') + status['manager']['queue_size'] = self.redis_connection.zcard('jobs') + self.write_json(status) diff --git a/rd_ui/app/scripts/admin_controllers.js b/rd_ui/app/scripts/admin_controllers.js index 93311711d..f05c67caa 100644 --- a/rd_ui/app/scripts/admin_controllers.js +++ b/rd_ui/app/scripts/admin_controllers.js @@ -5,6 +5,10 @@ var refresh = function () { $scope.refresh_time = moment().add('minutes', 1); $http.get('/status.json').success(function (data) { + $scope.workers = data.workers; + delete data.workers; + $scope.manager = data.manager; + delete data.manager; $scope.status = data; }); diff --git a/rd_ui/app/views/admin_status.html b/rd_ui/app/views/admin_status.html index f2ff63d22..9e2ce64cb 100644 --- a/rd_ui/app/views/admin_status.html +++ b/rd_ui/app/views/admin_status.html @@ -10,6 +10,22 @@ {{name | toHuman}} +
    +
  • Manager
  • +
  • + {{value}} + {{name | toHuman}} +
  • +
+
    +
    +
  • Worker {{$index+1}}
  • +
  • + {{value}} + {{name | toHuman}} +
  • +
    +
From c10c7a959d9013edbdd7fb46228dd87f13fdfec7 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 6 Dec 2013 15:32:36 +0200 Subject: [PATCH 048/540] Jobs done counter was updated in the wrong place --- rd_service/data/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index f7df3b022..e4d13aed7 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -193,8 +193,8 @@ class Worker(threading.Thread): else: logging.info("[%s] Waiting for pid: %d", self.name, self.child_pid) _, status = os.waitpid(self.child_pid, 0) + self._update_status('done_jobs_count') if status > 0: - self._update_status('done_jobs_count') job = Job.load(self.manager, job_id) if not job.is_finished(): self._update_status('cancelled_jobs_count') From fef4dadd58a9adef377475f6272a5f2097d43cfd Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 6 Dec 2013 15:50:02 +0200 Subject: [PATCH 049/540] More readable status --- rd_service/server.py | 17 +++------------- rd_ui/app/views/admin_status.html | 34 ++++++++++++++++++++++++------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/rd_service/server.py b/rd_service/server.py index 3ecf1f4a1..4e0e7a2a8 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -112,22 +112,11 @@ class StatusHandler(BaseHandler): status['dashboards_count'] = data.models.Dashboard.objects.count() status['widgets_count'] = data.models.Widget.objects.count() - workers = [self.redis_connection.hgetall(w) - for w in self.redis_connection.smembers('workers')] - - status['workers'] = [] - for worker in workers: - w = {} - w['uptime'] = "%.0f seconds" % (time.time() - float(worker['started_at'])) - w['last_updated_at'] = datetime.datetime.fromtimestamp(float(worker['updated_at'])).strftime('%Y-%m-%d %H:%M:%S') - w['jobs_received'] = worker['jobs_count'] - w['jobs_done'] = worker['done_jobs_count'] - status['workers'].append(w) + status['workers'] = [self.redis_connection.hgetall(w) + for w in self.redis_connection.smembers('workers')] manager_status = self.redis_connection.hgetall('manager:status') - status['manager'] = {} - status['manager']['uptime'] = "%.0f seconds" % (time.time() - float(manager_status['started_at'])) - status['manager']['last_refresh_at'] = datetime.datetime.fromtimestamp(float(manager_status['last_refresh_at'])).strftime('%Y-%m-%d %H:%M:%S') + status['manager'] = manager_status status['manager']['queue_size'] = self.redis_connection.zcard('jobs') self.write_json(status) diff --git a/rd_ui/app/views/admin_status.html b/rd_ui/app/views/admin_status.html index 9e2ce64cb..a08e6c3ac 100644 --- a/rd_ui/app/views/admin_status.html +++ b/rd_ui/app/views/admin_status.html @@ -12,18 +12,38 @@
  • Manager
  • -
  • - {{value}} - {{name | toHuman}} +
  • + + Last Refresh +
  • +
  • + + Started +
  • +
  • + {{manager.queue_size}} + Queue Size
  • Worker {{$index+1}}
  • -
  • - {{value}} - {{name | toHuman}} -
  • +
  • + + Updated +
  • +
  • + + Started +
  • +
  • + {{worker.jobs_count}} + Jobs Received +
  • +
  • + {{worker.done_jobs_count}} + Jobs Done +
From fc7412adae79016b9654fd39b0ed8029246797a0 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 6 Dec 2013 17:52:13 +0200 Subject: [PATCH 050/540] Add log_level to settings_example --- rd_service/settings_example.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rd_service/settings_example.py b/rd_service/settings_example.py index da2e35409..af8c3d04e 100644 --- a/rd_service/settings_example.py +++ b/rd_service/settings_example.py @@ -21,6 +21,7 @@ STATIC_ASSETS_PATH = "../rd_ui/dist/" WORKERS_COUNT = 2 MAX_CONNECTIONS = 3 COOKIE_SECRET = "c292a0a3aa32397cdb050e233733900f" +LOG_LEVEL = "INFO" # Configuration of the operational database for the Django models django.conf.settings.configure(DATABASES = { 'default': { From 7068e68b8fdb168294591a072bef3269e0780857 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 6 Dec 2013 20:56:08 +0200 Subject: [PATCH 051/540] No more using connection pool in DataManager, as it used accross processes --- rd_service/data/manager.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/rd_service/data/manager.py b/rd_service/data/manager.py index f307c4967..81f7d46a2 100644 --- a/rd_service/data/manager.py +++ b/rd_service/data/manager.py @@ -6,7 +6,6 @@ from contextlib import contextmanager import json import logging import psycopg2 -import psycopg2.pool import qr import redis import time @@ -29,8 +28,7 @@ class Manager(object): def __init__(self, redis_connection, db_connection_string, db_max_connections): self.redis_connection = redis_connection self.workers = [] - self.db_connection_pool = psycopg2.pool.ThreadedConnectionPool(1, db_max_connections, - db_connection_string) + self.db_connection_string = db_connection_string self.queue = qr.PriorityQueue("jobs", **self.redis_connection.connection_pool.connection_kwargs) self.max_retries = 5 self.status = { @@ -102,8 +100,6 @@ class Manager(object): return job def refresh_queries(self): - logging.info("Refreshing queries...") - sql = """SELECT queries.query, queries.ttl, retrieved_at FROM (SELECT query, min(ttl) as ttl FROM queries WHERE ttl > 0 GROUP by query) queries JOIN (SELECT query, max(retrieved_at) as retrieved_at @@ -115,6 +111,7 @@ class Manager(object): self.status['last_refresh_at'] = time.time() self._save_status() + logging.info("Refreshing queries...") queries = self.run_query(sql) for query, ttl, retrieved_at in queries: self.add_job(query, worker.Job.LOW_PRIORITY) @@ -172,7 +169,7 @@ class Manager(object): @contextmanager def db_transaction(self): - connection = self.db_connection_pool.getconn() + connection = psycopg2.connect(self.db_connection_string) cursor = connection.cursor() try: yield cursor @@ -182,7 +179,7 @@ class Manager(object): else: connection.commit() finally: - self.db_connection_pool.putconn(connection) + connection.close() def _save_status(self): self.redis_connection.hmset('manager:status', self.status) From cc86cb5ffad1931e9dd94f5d9cc6f8942d0de765 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sat, 7 Dec 2013 13:14:02 +0200 Subject: [PATCH 052/540] Support for non date time x values. --- rd_ui/app/scripts/ng-highchart.js | 14 +++++++-- rd_ui/app/scripts/query_fiddle/renderers.js | 33 +++++++++++---------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/rd_ui/app/scripts/ng-highchart.js b/rd_ui/app/scripts/ng-highchart.js index 00e83c5db..2dae23aec 100644 --- a/rd_ui/app/scripts/ng-highchart.js +++ b/rd_ui/app/scripts/ng-highchart.js @@ -37,13 +37,23 @@ angular.module('highchart', []) if (!length || length == 0) { scope.chart.showLoading(); } else { - while(scope.chart.series.length > 0) { scope.chart.series[0].remove(true); } - scope.chart.counters.color = 0; + if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) { + scope.chart.xAxis[0].update({type: 'category'}); + _.each(scope.series, function(s) { + _.each(s.data, function(p) { + p.name = p.x; + delete p.x; + }) + }) + } else { + scope.chart.xAxis[0].update({type: 'datetime'}); + } + scope.chart.counters.color = 0; _.each(scope.series, function(s) { scope.chart.addSeries(s); diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index 9b085e072..7c47a4b0e 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -6,18 +6,25 @@ var defaultChartOptions = { "tooltip": { valueDecimals: 2, formatter: function () { - var s = '' + moment(this.x).format("DD/MM/YY HH:mm") + '', - pointsCount = this.points.length; + if (moment.isMoment(this.x)) { + var s = '' + moment(this.x).format("DD/MM/YY HH:mm") + '', + pointsCount = this.points.length; + $.each(this.points, function (i, point) { + s += '
' + point.series.name + ': ' + + Highcharts.numberFormat(point.y); - $.each(this.points, function (i, point) { - s += '
' + point.series.name + ': ' + - Highcharts.numberFormat(point.y); - - if (pointsCount > 1 && point.percentage) { - s += " (" + Highcharts.numberFormat(point.percentage) + "%)"; - } - }); + if (pointsCount > 1 && point.percentage) { + s += " (" + Highcharts.numberFormat(point.percentage) + "%)"; + } + }); + } else { + var s = "" + this.points[0].key + ""; + $.each(this.points, function (i, point) { + s+= '
' + point.series.name + ': ' + + Highcharts.numberFormat(point.y); + }); + } return s; }, @@ -86,11 +93,7 @@ renderers.directive('chartRenderer', function () { $scope.chartOptions = defaultChartOptions; $scope.$watch('queryResult && queryResult.getData()', function (data) { - if (!data) { - return; - } - - if ($scope.queryResult.getData() == null) { + if (!data || $scope.queryResult.getData() == null) { $scope.chartSeries.splice(0, $scope.chartSeries.length); } else { $scope.chartSeries.splice(0, $scope.chartSeries.length); From 7e73b307f00bfbda6634aed1290199f597132f4e Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sat, 7 Dec 2013 14:18:39 +0200 Subject: [PATCH 053/540] Bring back browser notifications (#1) --- rd_ui/app/scripts/controllers.js | 7 +++++-- rd_ui/app/scripts/query_fiddle/renderers.js | 3 --- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index e13af8fcb..cb8d7f992 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -29,7 +29,7 @@ $scope.updateTime = ''; } - var QueryFiddleCtrl = function ($scope, $routeParams, $http, $location, growl, Query) { + var QueryFiddleCtrl = function ($scope, $routeParams, $http, $location, growl, notifications, Query) { $scope.$parent.pageTitle = "Query Fiddle"; $scope.tabs = [{'key': 'table', 'name': 'Table'}, {'key': 'chart', 'name': 'Chart'}, @@ -134,6 +134,9 @@ Query.save({'id': $scope.query.id, 'latest_query_data_id': $scope.queryResult.getId()}) } $scope.query.latest_query_data_id = $scope.queryResult.getId(); + + notifications.showNotification("re:dash", $scope.query.name + " updated."); + $scope.lockButton(false); } }); @@ -236,7 +239,7 @@ .controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl]) .controller('WidgetCtrl', ['$scope', '$http', 'Query', WidgetCtrl]) .controller('QueriesCtrl', ['$scope', '$http', '$location', 'Query', QueriesCtrl]) - .controller('QueryFiddleCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Query', QueryFiddleCtrl]) + .controller('QueryFiddleCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', QueryFiddleCtrl]) .controller('IndexCtrl', ['$scope', 'Dashboard', IndexCtrl]) .controller('MainCtrl', ['$scope', 'Dashboard', 'notifications', MainCtrl]); })(); diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index 7c47a4b0e..36caa05ca 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -106,9 +106,6 @@ renderers.directive('chartRenderer', function () { _.each($scope.queryResult.getChartData(), function (s) { $scope.chartSeries.push(_.extend(s, {'stacking': stacking})); }); - - // TODO: move this to the parent controller - // notifications.showNotification("RedDash", $scope.query.name + " updated."); } }); }] From 1e92240cbdd4971bea3307df057683269eee6e07 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sun, 8 Dec 2013 15:25:44 +0200 Subject: [PATCH 054/540] Return query stats (runtime and such) in API. --- rd_service/data/models.py | 26 +++++++++++++++++++++++++- rd_service/server.py | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/rd_service/data/models.py b/rd_service/data/models.py index c49e0f195..9559e8ad6 100644 --- a/rd_service/data/models.py +++ b/rd_service/data/models.py @@ -48,7 +48,7 @@ class Query(models.Model): app_label = 'redash' db_table = 'queries' - def to_dict(self, with_result=True): + def to_dict(self, with_result=True, with_stats=False): d = { 'id': self.id, 'latest_query_data_id': self.latest_query_data_id, @@ -61,11 +61,35 @@ class Query(models.Model): 'created_at': self.created_at, } + if with_stats: + d['avg_runtime'] = self.avg_runtime + d['min_runtime'] = self.min_runtime + d['max_runtime'] = self.max_runtime + d['last_retrieved_at'] = self.last_retrieved_at + d['times_retrieved'] = self.times_retrieved + if with_result and self.latest_query_data_id: d['latest_query_data'] = self.latest_query_data.to_dict() return d + @classmethod + def all_queries(cls): + query = """SELECT queries.*, query_stats.* +FROM queries +LEFT OUTER JOIN + (SELECT qu.query_hash, + count(0) AS "times_retrieved", + avg(runtime) AS "avg_runtime", + min(runtime) AS "min_runtime", + max(runtime) AS "max_runtime", + max(retrieved_at) AS "last_retrieved_at" + FROM queries qu + JOIN query_results qr ON qu.query_hash=qr.query_hash + GROUP BY qu.query_hash) query_stats ON query_stats.query_hash = queries.query_hash + """ + return cls.objects.raw(query) + def save(self, *args, **kwargs): self.query_hash = utils.gen_query_hash(self.query) super(Query, self).save(*args, **kwargs) diff --git a/rd_service/server.py b/rd_service/server.py index 4e0e7a2a8..f74837bd7 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -223,7 +223,7 @@ class QueriesHandler(BaseHandler): else: self.send_error(404) else: - self.write_json([q.to_dict(with_result=False) for q in data.models.Query.objects.all()]) + self.write_json([q.to_dict(with_result=False, with_stats=True) for q in data.models.Query.all_queries()]) class QueryResultsHandler(BaseHandler): From 668c60e1e8a24edc84f2e79e4ba318d51f6e1d2b Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sun, 8 Dec 2013 15:26:35 +0200 Subject: [PATCH 055/540] Improvements to queries page: (#6) 1. Search (client side). 2. Stats about queries. 3. Pagination. --- rd_ui/app/scripts/controllers.js | 112 ++++++++++++++++--- rd_ui/app/scripts/filters.js | 36 ++++-- rd_ui/app/views/queries.html | 31 +---- rd_ui/app/views/queries_query_name_cell.html | 1 + 4 files changed, 123 insertions(+), 57 deletions(-) create mode 100644 rd_ui/app/views/queries_query_name_cell.html diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index cb8d7f992..4ea351c12 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -167,31 +167,109 @@ } - var QueriesCtrl = function($scope, $http, $location, Query) { + var QueriesCtrl = function($scope, $http, $location, $filter, Query) { $scope.$parent.pageTitle = "All Queries"; - $scope.queries = Query.query(); + var dateFormatter = function (value) { + if (!value) return "-"; + return value.format("DD/MM/YY HH:mm"); + } + + var filterQueries = function() { + $scope.queries = _.filter($scope.allQueries, function(query) { + if (!$scope.selectedTab) { + return false; + } + + if ($scope.selectedTab.key == 'my') { + return query.user == currentUser.name && query.name != 'New Query'; + } else if ($scope.selectedTab.key == 'drafts') { + return query.user == currentUser.name && query.name == 'New Query'; + } + + return query.name != 'New Query'; + }); + } + + $scope.gridConfig = { + isPaginationEnabled: true, + itemsByPage: 50, + maxSize: 8, + isGlobalSearchActivated: true + } + + $scope.allQueries = []; + $scope.queries = []; + Query.query(function(queries) { + $scope.allQueries = _.map(queries, function(query) { + query.created_at = moment(query.created_at); + query.last_retrieved_at = moment(query.last_retrieved_at); + return query; + }); + + filterQueries(); + }); + + $scope.gridColumns = [ + { + "label": "Name", + "map": "name", + "cellTemplateUrl": "/views/queries_query_name_cell.html" + }, + { + 'label': 'Created By', + 'map': 'user' + }, + { + 'label': 'Created At', + 'map': 'created_at', + 'formatFunction': dateFormatter + }, + { + 'label': 'Runtime (avg)', + 'map': 'avg_runtime', + 'formatFunction': 'number', + 'formatParameter': 2 + }, + { + 'label': 'Runtime (min)', + 'map': 'min_runtime', + 'formatFunction': 'number', + 'formatParameter': 2 + }, + { + 'label': 'Runtime (max)', + 'map': 'max_runtime', + 'formatFunction': 'number', + 'formatParameter': 2 + }, + { + 'label': 'Last Executed At', + 'map': 'last_retrieved_at', + 'formatFunction': dateFormatter + }, + { + 'label': 'Times Executed', + 'map': 'times_retrieved' + }, + { + 'label': 'Update Schedule', + 'map': 'ttl', + 'formatFunction': function(value) { + return $filter('refreshRateHumanize')(value); + } + } +// id: 672, + ] $scope.tabs = [{"name": "My Queries", "key": "my"}, {"key": "all", "name": "All Queries"}, {"key": "drafts", "name": "Drafts"}]; $scope.$watch('selectedTab', function(tab) { if (tab) { $scope.$parent.pageTitle = tab.name; } + + filterQueries(); }) - - $scope.filterQueries = function(query) { - if (!$scope.selectedTab) { - return false; - } - - if ($scope.selectedTab.key == 'my') { - return query.user == currentUser.name && query.name != 'New Query'; - } else if ($scope.selectedTab.key == 'drafts') { - return query.user == currentUser.name && query.name == 'New Query'; - } - - return query.name != 'New Query'; - } } var MainCtrl = function ($scope, Dashboard, notifications) { @@ -238,7 +316,7 @@ angular.module('redash.controllers', []) .controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl]) .controller('WidgetCtrl', ['$scope', '$http', 'Query', WidgetCtrl]) - .controller('QueriesCtrl', ['$scope', '$http', '$location', 'Query', QueriesCtrl]) + .controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl]) .controller('QueryFiddleCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', QueryFiddleCtrl]) .controller('IndexCtrl', ['$scope', 'Dashboard', IndexCtrl]) .controller('MainCtrl', ['$scope', 'Dashboard', 'notifications', MainCtrl]); diff --git a/rd_ui/app/scripts/filters.js b/rd_ui/app/scripts/filters.js index 629ded993..1ef898510 100644 --- a/rd_ui/app/scripts/filters.js +++ b/rd_ui/app/scripts/filters.js @@ -1,19 +1,31 @@ +var durationHumanize = function (duration) { + var humanized = ""; + if (duration == undefined) { + humanized = "-"; + } else if (duration < 60) { + humanized = Math.round(duration) + "s"; + } else if (duration >= 3600) { + var hours = Math.round(parseFloat(duration) / 60.0 / 60.0) + humanized = hours + "h"; + } else { + var minutes = Math.round(parseFloat(duration) / 60.0); + humanized = minutes + "m"; + } + return humanized; +} + angular.module('redash.filters', []). filter('durationHumanize', function () { - return function (duration) { - var humanized = ""; - if (duration == undefined) { - humanized = "-"; - } else if (duration < 60) { - humanized = Math.round(duration) + "s"; - } else if (duration >= 3600) { - var hours = Math.round(parseFloat(duration) / 60.0 / 60.0) - humanized = hours + "h"; + return durationHumanize; + }) + + .filter('refreshRateHumanize', function () { + return function (ttl) { + if (ttl==-1) { + return "Never"; } else { - var minutes = Math.round(parseFloat(duration) / 60.0); - humanized = minutes + "m"; + return "Every " + durationHumanize(ttl); } - return humanized; } }) diff --git a/rd_ui/app/views/queries.html b/rd_ui/app/views/queries.html index 2ae893e42..ee24cb80d 100644 --- a/rd_ui/app/views/queries.html +++ b/rd_ui/app/views/queries.html @@ -1,31 +1,6 @@
- - - - - - - - - - - - - - - - - - - - -
NameRuntimeCreated atUpdate ScheduleCreated By
{{query.name}}{{query.runtime | durationHumanize}} - - Never - - Every {{query.ttl | durationHumanize}} - Manual - {{query.user}}
- +
diff --git a/rd_ui/app/views/queries_query_name_cell.html b/rd_ui/app/views/queries_query_name_cell.html new file mode 100644 index 000000000..2685e458d --- /dev/null +++ b/rd_ui/app/views/queries_query_name_cell.html @@ -0,0 +1 @@ +{{dataRow.name}} \ No newline at end of file From 5075795704e4052d002bf850f23f6424175d5c26 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sun, 8 Dec 2013 15:44:46 +0200 Subject: [PATCH 056/540] Humanize query runtime --- rd_ui/app/scripts/controllers.js | 33 +++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 4ea351c12..5468875eb 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -169,6 +169,15 @@ var QueriesCtrl = function($scope, $http, $location, $filter, Query) { $scope.$parent.pageTitle = "All Queries"; + $scope.gridConfig = { + isPaginationEnabled: true, + itemsByPage: 50, + maxSize: 8, + isGlobalSearchActivated: true + } + + $scope.allQueries = []; + $scope.queries = []; var dateFormatter = function (value) { if (!value) return "-"; @@ -191,15 +200,6 @@ }); } - $scope.gridConfig = { - isPaginationEnabled: true, - itemsByPage: 50, - maxSize: 8, - isGlobalSearchActivated: true - } - - $scope.allQueries = []; - $scope.queries = []; Query.query(function(queries) { $scope.allQueries = _.map(queries, function(query) { query.created_at = moment(query.created_at); @@ -228,20 +228,23 @@ { 'label': 'Runtime (avg)', 'map': 'avg_runtime', - 'formatFunction': 'number', - 'formatParameter': 2 + 'formatFunction': function(value) { + return $filter('durationHumanize')(value); + } }, { 'label': 'Runtime (min)', 'map': 'min_runtime', - 'formatFunction': 'number', - 'formatParameter': 2 + 'formatFunction': function(value) { + return $filter('durationHumanize')(value); + } }, { 'label': 'Runtime (max)', 'map': 'max_runtime', - 'formatFunction': 'number', - 'formatParameter': 2 + 'formatFunction': function(value) { + return $filter('durationHumanize')(value); + } }, { 'label': 'Last Executed At', From c50639d13714f2d5f0590589916d0772cb10dfd9 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Fri, 13 Dec 2013 18:46:14 +0200 Subject: [PATCH 057/540] Add support for refreshing a query once a week --- rd_ui/app/scripts/controllers.js | 2 +- rd_ui/app/scripts/filters.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 5468875eb..88bc53478 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -98,7 +98,7 @@ }) $scope.refreshOptions.push({value: 24*3600, name: 'Every 24h'}); - + $scope.refreshOptions.push({value: 7*24*3600, name: 'Once a week'}); $scope.$watch('queryResult && queryResult.getError()', function (newError, oldError) { if (newError == undefined) { diff --git a/rd_ui/app/scripts/filters.js b/rd_ui/app/scripts/filters.js index 1ef898510..baf5618d6 100644 --- a/rd_ui/app/scripts/filters.js +++ b/rd_ui/app/scripts/filters.js @@ -4,8 +4,11 @@ var durationHumanize = function (duration) { humanized = "-"; } else if (duration < 60) { humanized = Math.round(duration) + "s"; + } else if (duration > 3600*24) { + var days = Math.round(parseFloat(duration) / 60.0 / 60.0 / 24.0); + humanized = days + "days"; } else if (duration >= 3600) { - var hours = Math.round(parseFloat(duration) / 60.0 / 60.0) + var hours = Math.round(parseFloat(duration) / 60.0 / 60.0); humanized = hours + "h"; } else { var minutes = Math.round(parseFloat(duration) / 60.0); From 0f09bbc25332dd995b46f8a2da1064f7eb91737a Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 16 Dec 2013 18:52:30 +0200 Subject: [PATCH 058/540] Set proctitle for worker --- rd_service/data/worker.py | 14 ++++++++++++++ rd_service/requirements.txt | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index e4d13aed7..b8117b820 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -9,6 +9,7 @@ import uuid import datetime import time import signal +import setproctitle from utils import gen_query_hash @@ -161,6 +162,15 @@ class Worker(threading.Thread): super(Worker, self).__init__(name="Worker-%s" % self.worker_id) + def set_title(self, title=None): + base_title = "redash worker:%s" % self.worker_id + if title: + full_title = "%s - %s" % (base_title, title) + else: + full_title = base_title + + setproctitle.setproctitle(full_title) + def run(self): logging.info("[%s] started.", self.name) while self.continue_working: @@ -189,6 +199,7 @@ class Worker(threading.Thread): def _fork_and_process(self, job_id): self.child_pid = os.fork() if self.child_pid == 0: + self.set_title("processing %s" % job_id) self._process(job_id) else: logging.info("[%s] Waiting for pid: %d", self.name, self.child_pid) @@ -215,6 +226,7 @@ class Worker(threading.Thread): logging.info("[%s][%s] running query...", self.name, job.id) start_time = time.time() + self.set_title("running query %s" % job_id) data, error = self.query_runner(job.query) run_time = time.time() - start_time logging.info("[%s][%s] query finished... data length=%s, error=%s", @@ -224,7 +236,9 @@ class Worker(threading.Thread): # while we already marked the job as done query_result_id = None if not error: + self.set_title("storing results %s" % job_id) query_result_id = self.manager.store_query_result(job.query, data, run_time, datetime.datetime.utcnow()) + self.set_title("marking job as done %s" % job_id) job.done(query_result_id, error) diff --git a/rd_service/requirements.txt b/rd_service/requirements.txt index 83351077f..e38d6691d 100644 --- a/rd_service/requirements.txt +++ b/rd_service/requirements.txt @@ -5,4 +5,5 @@ sqlparse==0.1.8 Django==1.5.4 django-db-pool==0.0.10 qr==0.6.0 -python-dateutil==2.1 \ No newline at end of file +python-dateutil==2.1 +setproctitle==1.1.7 \ No newline at end of file From 6e0d1c613c5998cb19ce5b3bb74e192bb073b4bf Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 17 Dec 2013 12:02:58 +0200 Subject: [PATCH 059/540] Change Job to use redis connection instead of data manager & use own redis connection in forked process. --- rd_service/data/manager.py | 4 +++- rd_service/data/worker.py | 25 ++++++++++++++----------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/rd_service/data/manager.py b/rd_service/data/manager.py index 81f7d46a2..1eabb7a1d 100644 --- a/rd_service/data/manager.py +++ b/rd_service/data/manager.py @@ -156,7 +156,9 @@ class Manager(object): runner = query_runner.redshift(connection_string) - self.workers = [worker.Worker(self, runner) for _ in range(workers_count)] + redis_connection_params = self.redis_connection.connection_pool.connection_kwargs + self.workers = [worker.Worker(self, redis_connection_params, runner) + for _ in range(workers_count)] for w in self.workers: w.start() diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index b8117b820..80e74ac81 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -10,6 +10,7 @@ import datetime import time import signal import setproctitle +import redis from utils import gen_query_hash @@ -22,12 +23,12 @@ class Job(object): DONE = 3 FAILED = 4 - def __init__(self, data_manager, query, priority, + def __init__(self, redis_connection, query, priority, job_id=None, wait_time=None, query_time=None, updated_at=None, status=None, error=None, query_result_id=None, process_id=0): - self.data_manager = data_manager + self.redis_connection = redis_connection self.query = query self.priority = priority self.query_hash = gen_query_hash(self.query) @@ -85,7 +86,7 @@ class Job(object): def save(self, pipe=None): if not pipe: - pipe = self.data_manager.redis_connection.pipeline() + pipe = self.redis_connection.pipeline() if self.new_job: pipe.set('query_hash_job:%s' % self.query_hash, self.id) @@ -124,15 +125,15 @@ class Job(object): return "" % (self.id, self.priority, self.status) @classmethod - def _load(cls, data_manager, job_id): - return data_manager.redis_connection.hgetall(cls._redis_key(job_id)) + def _load(cls, redis_connection, job_id): + return redis_connection.hgetall(cls._redis_key(job_id)) @classmethod - def load(cls, data_manager, job_id): - job_dict = cls._load(data_manager, job_id) + def load(cls, redis_connection, job_id): + job_dict = cls._load(redis_connection, job_id) job = None if job_dict: - job = Job(data_manager, job_id=job_dict['id'], query=job_dict['query'].decode('utf-8'), + job = Job(redis_connection, job_id=job_dict['id'], query=job_dict['query'].decode('utf-8'), priority=int(job_dict['priority']), updated_at=float(job_dict['updated_at']), status=int(job_dict['status']), wait_time=float(job_dict['wait_time']), query_time=float(job_dict['query_time']), error=job_dict['error'], @@ -143,8 +144,9 @@ class Job(object): class Worker(threading.Thread): - def __init__(self, manager, query_runner, sleep_time=0.1): + def __init__(self, manager, redis_connection_params, query_runner, sleep_time=0.1): self.manager = manager + self.redis_connection_params = redis_connection_params self.continue_working = True self.query_runner = query_runner self.sleep_time = sleep_time @@ -206,7 +208,7 @@ class Worker(threading.Thread): _, status = os.waitpid(self.child_pid, 0) self._update_status('done_jobs_count') if status > 0: - job = Job.load(self.manager, job_id) + job = Job.load(self.manager.redis_connection, job_id) if not job.is_finished(): self._update_status('cancelled_jobs_count') logging.info("[%s] process interrupted and job %s hasn't finished; registering interruption in job", @@ -217,7 +219,8 @@ class Worker(threading.Thread): self.name, job_id, self.child_pid, status) def _process(self, job_id): - job = Job.load(self.manager, job_id) + redis_connection = redis.StrictRedis(**self.redis_connection_params) + job = Job.load(redis_connection, job_id) if job.is_finished(): logging.warning("[%s][%s] tried to process finished job.", self.name, job) return From f08ebf22562feb684e81bd8b4fce850235129fa0 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 17 Dec 2013 12:03:10 +0200 Subject: [PATCH 060/540] Run annotated queries. --- rd_service/data/worker.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index 80e74ac81..f7998f7d2 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -225,12 +225,17 @@ class Worker(threading.Thread): logging.warning("[%s][%s] tried to process finished job.", self.name, job) return - job.processing(os.getpid()) + pid = os.getpid() + job.processing(pid) logging.info("[%s][%s] running query...", self.name, job.id) start_time = time.time() self.set_title("running query %s" % job_id) - data, error = self.query_runner(job.query) + + annotated_query = "/* Pid: %s, Job Id: %s, Query hash: %s, Priority: %s */ %s" % \ + (pid, job.id, job.query_hash, job.priority, job.query) + + data, error = self.query_runner(annotated_query) run_time = time.time() - start_time logging.info("[%s][%s] query finished... data length=%s, error=%s", self.name, job.id, data and len(data), error) From eea0c2e060a7eaec3903b83a75ac8fec8eeef627 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 17 Dec 2013 12:19:50 +0200 Subject: [PATCH 061/540] More places where I need to use redis_connection --- rd_service/data/manager.py | 4 ++-- rd_service/server.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rd_service/data/manager.py b/rd_service/data/manager.py index 1eabb7a1d..cf0760a87 100644 --- a/rd_service/data/manager.py +++ b/rd_service/data/manager.py @@ -82,9 +82,9 @@ class Manager(object): job_id = pipe.get('query_hash_job:%s' % query_hash) if job_id: logging.info("[Manager][%s] Found existing job: %s", query_hash, job_id) - job = worker.Job.load(self, job_id) + job = worker.Job.load(self.redis_connectino, job_id) else: - job = worker.Job(self, query, priority) + job = worker.Job(self.redis_connectino, query, priority) pipe.multi() job.save(pipe) logging.info("[Manager][%s] Created new job: %s", query_hash, job.id) diff --git a/rd_service/server.py b/rd_service/server.py index f74837bd7..1c777e838 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -253,13 +253,13 @@ class JobsHandler(BaseHandler): def get(self, job_id=None): if job_id: # TODO: if finished, include the query result - job = data.Job.load(self.data_manager, job_id) + job = data.Job.load(self.data_manager.redis_connectino, job_id) self.write({'job': job.to_dict()}) else: raise NotImplemented def delete(self, job_id): - job = data.Job.load(self.data_manager, job_id) + job = data.Job.load(self.data_manager.redis_connectino, job_id) job.cancel() From 93162fed8587c5397d94f217028bc63798b9dbd1 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 17 Dec 2013 12:46:06 +0200 Subject: [PATCH 062/540] Take only needed vals for connection params --- rd_service/data/manager.py | 4 ++-- rd_service/data/worker.py | 4 +++- rd_service/server.py | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/rd_service/data/manager.py b/rd_service/data/manager.py index cf0760a87..e69e7290b 100644 --- a/rd_service/data/manager.py +++ b/rd_service/data/manager.py @@ -82,9 +82,9 @@ class Manager(object): job_id = pipe.get('query_hash_job:%s' % query_hash) if job_id: logging.info("[Manager][%s] Found existing job: %s", query_hash, job_id) - job = worker.Job.load(self.redis_connectino, job_id) + job = worker.Job.load(self.redis_connection, job_id) else: - job = worker.Job(self.redis_connectino, query, priority) + job = worker.Job(self.redis_connection, query, priority) pipe.multi() job.save(pipe) logging.info("[Manager][%s] Created new job: %s", query_hash, job.id) diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index f7998f7d2..c89e8236d 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -146,7 +146,9 @@ class Job(object): class Worker(threading.Thread): def __init__(self, manager, redis_connection_params, query_runner, sleep_time=0.1): self.manager = manager - self.redis_connection_params = redis_connection_params + + self.redis_connection_params = {k: v for k, v in redis_connection_params.iteritems() + if k in ('host', 'db', 'password', 'port')} self.continue_working = True self.query_runner = query_runner self.sleep_time = sleep_time diff --git a/rd_service/server.py b/rd_service/server.py index 1c777e838..416f7514e 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -253,13 +253,13 @@ class JobsHandler(BaseHandler): def get(self, job_id=None): if job_id: # TODO: if finished, include the query result - job = data.Job.load(self.data_manager.redis_connectino, job_id) + job = data.Job.load(self.data_manager.redis_connection, job_id) self.write({'job': job.to_dict()}) else: raise NotImplemented def delete(self, job_id): - job = data.Job.load(self.data_manager.redis_connectino, job_id) + job = data.Job.load(self.data_manager.redis_connection, job_id) job.cancel() From 02f5979e9a98abec4321eb28417e8bd140efc95c Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 18 Dec 2013 09:40:48 +0200 Subject: [PATCH 063/540] Add afork to mitigate issues of mixing threading and forking --- rd_service/cli.py | 5 +++++ rd_service/data/worker.py | 1 + rd_service/requirements.txt | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/rd_service/cli.py b/rd_service/cli.py index 36648bd06..64bea6226 100644 --- a/rd_service/cli.py +++ b/rd_service/cli.py @@ -3,6 +3,11 @@ CLI to start the workers. TODO: move API server startup here. """ +import atfork +atfork.monkeypatch_os_fork_functions() +import atfork.stdlib_fixer +atfork.stdlib_fixer.fix_logging_module() + import argparse import logging import urlparse diff --git a/rd_service/data/worker.py b/rd_service/data/worker.py index c89e8236d..d75ac1951 100644 --- a/rd_service/data/worker.py +++ b/rd_service/data/worker.py @@ -237,6 +237,7 @@ class Worker(threading.Thread): annotated_query = "/* Pid: %s, Job Id: %s, Query hash: %s, Priority: %s */ %s" % \ (pid, job.id, job.query_hash, job.priority, job.query) + # TODO: here's the part that needs to be forked, not all of the worker process... data, error = self.query_runner(annotated_query) run_time = time.time() - start_time logging.info("[%s][%s] query finished... data length=%s, error=%s", diff --git a/rd_service/requirements.txt b/rd_service/requirements.txt index e38d6691d..f49582580 100644 --- a/rd_service/requirements.txt +++ b/rd_service/requirements.txt @@ -6,4 +6,5 @@ Django==1.5.4 django-db-pool==0.0.10 qr==0.6.0 python-dateutil==2.1 -setproctitle==1.1.7 \ No newline at end of file +setproctitle==1.1.7 +atfork==0.1.2 \ No newline at end of file From 442da290a4c9926c1da05df14d2e08a495c37240 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sun, 22 Dec 2013 17:05:40 +0200 Subject: [PATCH 064/540] Fix: need to make sure that each category has a value --- rd_ui/app/scripts/ng-highchart.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/rd_ui/app/scripts/ng-highchart.js b/rd_ui/app/scripts/ng-highchart.js index 2dae23aec..1e027277b 100644 --- a/rd_ui/app/scripts/ng-highchart.js +++ b/rd_ui/app/scripts/ng-highchart.js @@ -43,12 +43,22 @@ angular.module('highchart', []) if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) { scope.chart.xAxis[0].update({type: 'category'}); + + // We need to make sure that for each category, each series has a value. + var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')})); + _.each(scope.series, function(s) { - _.each(s.data, function(p) { - p.name = p.x; - delete p.x; - }) - }) + // TODO: move this logic to Query#getChartData + var yValues = _.groupBy(s.data, 'x'); + + var newData = _.sortBy(_.map(categories, function(category) { + return { + name: category, + y: yValues[category][0].y + } + }), 'y'); + s.data = newData; + }); } else { scope.chart.xAxis[0].update({type: 'datetime'}); } From 42c3fcf2482832fcd3637724dbff1d60959db445 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sun, 22 Dec 2013 17:23:39 +0200 Subject: [PATCH 065/540] having values ordered by Y, messes up the data so removing this --- rd_ui/app/scripts/ng-highchart.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/rd_ui/app/scripts/ng-highchart.js b/rd_ui/app/scripts/ng-highchart.js index 1e027277b..1131f05a6 100644 --- a/rd_ui/app/scripts/ng-highchart.js +++ b/rd_ui/app/scripts/ng-highchart.js @@ -46,7 +46,7 @@ angular.module('highchart', []) // We need to make sure that for each category, each series has a value. var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')})); - + _.each(scope.series, function(s) { // TODO: move this logic to Query#getChartData var yValues = _.groupBy(s.data, 'x'); @@ -54,9 +54,10 @@ angular.module('highchart', []) var newData = _.sortBy(_.map(categories, function(category) { return { name: category, - y: yValues[category][0].y + y: yValues[category] && yValues[category][0].y } - }), 'y'); + }), 'name'); + s.data = newData; }); } else { From 3bd8b177bed7678b3970b4e545d6056e89139151 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 23 Dec 2013 21:23:52 +0200 Subject: [PATCH 066/540] Dashboard menu has sub-menus now --- rd_ui/app/index.html | 15 +++++++--- rd_ui/app/scripts/controllers.js | 3 +- rd_ui/app/scripts/ng-highchart.js | 2 +- rd_ui/app/styles/redash.css | 49 +++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index ce59987c5..7a311bdf2 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -38,12 +38,19 @@ diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 88bc53478..b8da0ced6 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -287,6 +287,8 @@ } return parts[0]; }); + $scope.otherDashboards = $scope.groupedDashboards['Other'] || []; + delete $scope.groupedDashboards['Other']; }); } @@ -306,7 +308,6 @@ var IndexCtrl = function($scope, Dashboard) { $scope.$parent.pageTitle = "Home"; - $scope.archiveDashboard = function(dashboard) { if (confirm('Are you sure you want to delete "' + dashboard.name + '" dashboard?')) { dashboard.$delete(function() { diff --git a/rd_ui/app/scripts/ng-highchart.js b/rd_ui/app/scripts/ng-highchart.js index 1131f05a6..b4c445a29 100644 --- a/rd_ui/app/scripts/ng-highchart.js +++ b/rd_ui/app/scripts/ng-highchart.js @@ -46,7 +46,7 @@ angular.module('highchart', []) // We need to make sure that for each category, each series has a value. var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')})); - + _.each(scope.series, function(s) { // TODO: move this logic to Query#getChartData var yValues = _.groupBy(s.data, 'x'); diff --git a/rd_ui/app/styles/redash.css b/rd_ui/app/styles/redash.css index 51257114c..aee73ddc2 100644 --- a/rd_ui/app/styles/redash.css +++ b/rd_ui/app/styles/redash.css @@ -126,4 +126,53 @@ to add those CSS styles here. */ color: #ffffff; text-decoration: none; background-color: #428bca; +} + +/* Dropdown submenus */ +.dropdown-submenu { + position: relative; +} + +.dropdown-submenu > .dropdown-menu { + top: 0; + left: 100%; + margin-top: -6px; + margin-left: -1px; + -webkit-border-radius: 0 6px 6px 6px; + -moz-border-radius: 0 6px 6px 6px; + border-radius: 0 6px 6px 6px; +} + +.dropdown-submenu:hover > .dropdown-menu { + display: block; +} + +.dropdown-submenu > a:after { + display: block; + content: " "; + float: right; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + border-width: 5px 0 5px 5px; + border-left-color: #cccccc; + margin-top: 5px; + margin-right: -10px; +} + +.dropdown-submenu:hover > a:after { + /*border-left-color: #ffffff;*/ +} + +.dropdown-submenu.pull-left { + float: none; +} + +.dropdown-submenu.pull-left > .dropdown-menu { + left: -100%; + margin-left: 10px; + -webkit-border-radius: 6px 0 6px 6px; + -moz-border-radius: 6px 0 6px 6px; + border-radius: 6px 0 6px 6px; } \ No newline at end of file From f5f52584221b3b582753d24799e5f52659dc54d3 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 23 Dec 2013 21:32:38 +0200 Subject: [PATCH 067/540] Escape % in column names. Fixes #42 --- rd_ui/app/scripts/services.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 847e6afaa..94bb9c2d5 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -155,13 +155,15 @@ var parts = column.split('::'); var name = parts[1]; if (parts[0] != '') { - name = parts[0].replace(/ /g, '_').replace(/\?/g,''); + // TODO: it's probably time to generalize this. + // see also getColumnFriendlyName + name = parts[0].replace(/%/g, '--pct').replace(/ /g, '_').replace(/\?/g,''); } return name; } QueryResult.prototype.getColumnFriendlyName = function (column) { - return this.getColumnCleanName(column).replace(/_/g, ' ').replace(/(?:^|\s)\S/g, function (a) { + return this.getColumnCleanName(column).replace('--pct', '%').replace(/_/g, ' ').replace(/(?:^|\s)\S/g, function (a) { return a.toUpperCase(); }); } From d46c2b086c965af1eb664c3af241c76a8bbe0005 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 23 Dec 2013 21:56:01 +0200 Subject: [PATCH 068/540] Prevent from setting a blank query title. Fixes #34 --- rd_ui/app/scripts/controllers.js | 1 - rd_ui/app/scripts/directives.js | 12 +++++++++++- rd_ui/app/views/queryfiddle.html | 4 +--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index b8da0ced6..03b5b19da 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -262,7 +262,6 @@ return $filter('refreshRateHumanize')(value); } } -// id: 672, ] $scope.tabs = [{"name": "My Queries", "key": "my"}, {"key": "all", "name": "All Queries"}, {"key": "drafts", "name": "Drafts"}]; diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index 313f1944f..9d739b46d 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -158,7 +158,10 @@ directives.directive('newWidgetForm', ['$http', function($http) { directives.directive('editInPlace', function () { return { restrict: 'E', - scope: { value: '=' }, + scope: { + value: '=', + ignoreBlanks: '=' + }, template: '', link: function ($scope, element, attrs) { // Let's get a reference to the input element, as we'll want to reference it. @@ -172,6 +175,10 @@ directives.directive('editInPlace', function () { // ng-click handler to activate edit-in-place $scope.edit = function () { + if ($scope.ignoreBlanks) { + $scope.oldValue = $scope.value; + } + $scope.editing = true; // We control display through a class on the directive itself. See the CSS. @@ -184,6 +191,9 @@ directives.directive('editInPlace', function () { }; $(inputElement).blur(function() { + if ($scope.ignoreBlanks && _.isEmpty($scope.value)) { + $scope.value = $scope.oldValue; + } $scope.editing = false; element.removeClass('active'); }) diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 3da0f4082..4ff57c0d8 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -3,14 +3,12 @@

- +

- -
Download Data Set From ba0291dd3e9efa29a60468a0f7ea6394a8f541c0 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 23 Dec 2013 21:59:01 +0200 Subject: [PATCH 069/540] You can't have dashes in object properties. Fixes #42 --- rd_ui/app/scripts/services.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 94bb9c2d5..ab32673ce 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -157,13 +157,13 @@ if (parts[0] != '') { // TODO: it's probably time to generalize this. // see also getColumnFriendlyName - name = parts[0].replace(/%/g, '--pct').replace(/ /g, '_').replace(/\?/g,''); + name = parts[0].replace(/%/g, '__pct').replace(/ /g, '_').replace(/\?/g,''); } return name; } QueryResult.prototype.getColumnFriendlyName = function (column) { - return this.getColumnCleanName(column).replace('--pct', '%').replace(/_/g, ' ').replace(/(?:^|\s)\S/g, function (a) { + return this.getColumnCleanName(column).replace('__pct', '%').replace(/_/g, ' ').replace(/(?:^|\s)\S/g, function (a) { return a.toUpperCase(); }); } From 27294fd81c170c8d0bfd24a923423e362caa5ee7 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Thu, 26 Dec 2013 14:09:59 +0200 Subject: [PATCH 070/540] Limit page title size --- rd_ui/app/index.html | 2 +- rd_ui/app/styles/redash.css | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index 7a311bdf2..71b1db3a2 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -33,7 +33,7 @@
From 304a14e5a13fbea3c67251bc522a4d5d6b0368ff Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Mon, 30 Dec 2013 16:34:48 +0200 Subject: [PATCH 074/540] Show confirm box, when trying to leave the page before saving the query (FED #1) --- rd_ui/app/scripts/controllers.js | 30 ++++++++++++++++++++++++++---- rd_ui/app/views/queryfiddle.html | 2 +- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 2a4989e8b..aca4c7862 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -29,7 +29,23 @@ $scope.updateTime = ''; } - var QueryFiddleCtrl = function ($scope, $routeParams, $http, $location, growl, notifications, Query) { + var QueryFiddleCtrl = function ($scope, $window, $routeParams, $http, $location, growl, notifications, Query) { + var leavingPageText = "You will lose your changes if you leave"; + var pristineQuery = null; + + $window.onbeforeunload = function(){ + if ($scope.queryChanged) { + return leavingPageText; + } + } + + $scope.$on('$locationChangeStart', function(event, next, current) { + if($scope.queryChanged && + !confirm(leavingPageText + "\n\nAre you sure you want to leave this page?")) { + event.preventDefault(); + } + }); + $scope.$parent.pageTitle = "Query Fiddle"; $scope.tabs = [{'key': 'table', 'name': 'Table'}, {'key': 'chart', 'name': 'Chart'}, @@ -54,7 +70,8 @@ } delete $scope.query.latest_query_data; $scope.query.$save(function (q) { - $scope.pristineQuery = q.query; + pristineQuery = q.query; + $scope.queryChanged = false; if (duplicate) { growl.addInfoMessage("Query duplicated.", {ttl: 2000}); @@ -147,7 +164,7 @@ if ($routeParams.queryId != undefined) { $scope.query = Query.get({id: $routeParams.queryId}, function(q) { - $scope.pristineQuery = q.query; + pristineQuery = q.query; $scope.queryResult = $scope.query.getQueryResult(); }); } else { @@ -158,6 +175,11 @@ $scope.$watch('query.name', function() { $scope.$parent.pageTitle = $scope.query.name; }); + $scope.$watch('query.query', function(q) { + if (q) { + $scope.queryChanged = (q != pristineQuery); + } + }); $scope.executeQuery = function() { $scope.queryResult = $scope.query.getQueryResult(0); @@ -325,7 +347,7 @@ .controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl]) .controller('WidgetCtrl', ['$scope', '$http', 'Query', WidgetCtrl]) .controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl]) - .controller('QueryFiddleCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', QueryFiddleCtrl]) + .controller('QueryFiddleCtrl', ['$scope', '$window', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', QueryFiddleCtrl]) .controller('IndexCtrl', ['$scope', 'Dashboard', IndexCtrl]) .controller('MainCtrl', ['$scope', 'Dashboard', 'notifications', MainCtrl]); })(); diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 2e436f746..3614ddb1a 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -18,7 +18,7 @@
From 0a185cf0512a5fc7684522a5fccd94a386217fa2 Mon Sep 17 00:00:00 2001 From: Timor Date: Sun, 17 Nov 2013 15:19:53 +0200 Subject: [PATCH 075/540] add vanila vagrant configuration --- vagrant/.gitignore | 1 + vagrant/Vagrantfile | 118 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 vagrant/.gitignore create mode 100644 vagrant/Vagrantfile diff --git a/vagrant/.gitignore b/vagrant/.gitignore new file mode 100644 index 000000000..8000dd9db --- /dev/null +++ b/vagrant/.gitignore @@ -0,0 +1 @@ +.vagrant diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile new file mode 100644 index 000000000..39d18d822 --- /dev/null +++ b/vagrant/Vagrantfile @@ -0,0 +1,118 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# Vagrantfile API/syntax version. Don't touch unless you know what you're doing! +VAGRANTFILE_API_VERSION = "2" + +Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| + # All Vagrant configuration is done here. The most common configuration + # options are documented and commented below. For a complete reference, + # please see the online documentation at vagrantup.com. + + # Every Vagrant virtual environment requires a box to build off of. + config.vm.box = "precise32" + + # The url from where the 'config.vm.box' box will be fetched if it + # doesn't already exist on the user's system. + config.vm.box_url = "http://files.vagrantup.com/precise32.box" + + # Create a forwarded port mapping which allows access to a specific port + # within the machine from a port on the host machine. In the example below, + # accessing "localhost:8080" will access port 80 on the guest machine. + # config.vm.network :forwarded_port, guest: 80, host: 8080 + + # Create a private network, which allows host-only access to the machine + # using a specific IP. + # config.vm.network :private_network, ip: "192.168.33.10" + + # Create a public network, which generally matched to bridged network. + # Bridged networks make the machine appear as another physical device on + # your network. + # config.vm.network :public_network + + # If true, then any SSH connections made will enable agent forwarding. + # Default value: false + # config.ssh.forward_agent = true + + # Share an additional folder to the guest VM. The first argument is + # the path on the host to the actual folder. The second argument is + # the path on the guest to mount the folder. And the optional third + # argument is a set of non-required options. + # config.vm.synced_folder "../data", "/vagrant_data" + + # Provider-specific configuration so you can fine-tune various + # backing providers for Vagrant. These expose provider-specific options. + # Example for VirtualBox: + # + # config.vm.provider :virtualbox do |vb| + # # Don't boot with headless mode + # vb.gui = true + # + # # Use VBoxManage to customize the VM. For example to change memory: + # vb.customize ["modifyvm", :id, "--memory", "1024"] + # end + # + # View the documentation for the provider you're using for more + # information on available options. + + # Enable provisioning with Puppet stand alone. Puppet manifests + # are contained in a directory path relative to this Vagrantfile. + # You will need to create the manifests directory and a manifest in + # the file precise32.pp in the manifests_path directory. + # + # An example Puppet manifest to provision the message of the day: + # + # # group { "puppet": + # # ensure => "present", + # # } + # # + # # File { owner => 0, group => 0, mode => 0644 } + # # + # # file { '/etc/motd': + # # content => "Welcome to your Vagrant-built virtual machine! + # # Managed by Puppet.\n" + # # } + # + # config.vm.provision :puppet do |puppet| + # puppet.manifests_path = "manifests" + # puppet.manifest_file = "site.pp" + # end + + # Enable provisioning with chef solo, specifying a cookbooks path, roles + # path, and data_bags path (all relative to this Vagrantfile), and adding + # some recipes and/or roles. + # + # config.vm.provision :chef_solo do |chef| + # chef.cookbooks_path = "../my-recipes/cookbooks" + # chef.roles_path = "../my-recipes/roles" + # chef.data_bags_path = "../my-recipes/data_bags" + # chef.add_recipe "mysql" + # chef.add_role "web" + # + # # You may also specify custom JSON attributes: + # chef.json = { :mysql_password => "foo" } + # end + + # Enable provisioning with chef server, specifying the chef server URL, + # and the path to the validation key (relative to this Vagrantfile). + # + # The Opscode Platform uses HTTPS. Substitute your organization for + # ORGNAME in the URL and validation key. + # + # If you have your own Chef Server, use the appropriate URL, which may be + # HTTP instead of HTTPS depending on your configuration. Also change the + # validation key to validation.pem. + # + # config.vm.provision :chef_client do |chef| + # chef.chef_server_url = "https://api.opscode.com/organizations/ORGNAME" + # chef.validation_key_path = "ORGNAME-validator.pem" + # end + # + # If you're using the Opscode platform, your validator client is + # ORGNAME-validator, replacing ORGNAME with your organization name. + # + # If you have your own Chef Server, the default validation client name is + # chef-validator, unless you changed the configuration. + # + # chef.validation_client_name = "ORGNAME-validator" +end From 5d68f46a72d8827a92cb231d1826cc0eabee6826 Mon Sep 17 00:00:00 2001 From: Timor Raiman Date: Sun, 17 Nov 2013 18:16:45 +0200 Subject: [PATCH 076/540] Add .DS_Store to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a6086e0d1..3dd65166f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.pyc rd_service/settings.py rd_ui/dist +.DS_Store From 82fc2eb812a144b639735b1e38d9335824ab2141 Mon Sep 17 00:00:00 2001 From: Timor Raiman Date: Wed, 20 Nov 2013 14:22:59 +0200 Subject: [PATCH 077/540] Still hitting the encoding conversion error --- cookbooks/redash/Berksfile | 1 + cookbooks/redash/README.md | 73 ++++++++++ cookbooks/redash/attributes/default.rb | 39 ++++++ cookbooks/redash/metadata.rb | 17 +++ cookbooks/redash/recipes/default.rb | 125 +++++++++++++++++ .../redash/templates/default/settings.py.erb | 38 ++++++ .../default/sv-redash-server-log-run.erb | 3 + .../default/sv-redash-server-run.erb | 4 + .../default/sv-redash-worker-log-run.erb | 3 + .../default/sv-redash-worker-run.erb | 4 + vagrant/Berksfile | 2 + vagrant/Berksfile.lock | 67 +++++++++ vagrant/Vagrantfile | 129 +++--------------- vagrant/notes | 5 + 14 files changed, 403 insertions(+), 107 deletions(-) create mode 100644 cookbooks/redash/Berksfile create mode 100644 cookbooks/redash/README.md create mode 100644 cookbooks/redash/attributes/default.rb create mode 100644 cookbooks/redash/metadata.rb create mode 100644 cookbooks/redash/recipes/default.rb create mode 100644 cookbooks/redash/templates/default/settings.py.erb create mode 100644 cookbooks/redash/templates/default/sv-redash-server-log-run.erb create mode 100644 cookbooks/redash/templates/default/sv-redash-server-run.erb create mode 100644 cookbooks/redash/templates/default/sv-redash-worker-log-run.erb create mode 100644 cookbooks/redash/templates/default/sv-redash-worker-run.erb create mode 100644 vagrant/Berksfile create mode 100644 vagrant/Berksfile.lock create mode 100644 vagrant/notes diff --git a/cookbooks/redash/Berksfile b/cookbooks/redash/Berksfile new file mode 100644 index 000000000..850a120dc --- /dev/null +++ b/cookbooks/redash/Berksfile @@ -0,0 +1 @@ +metadata diff --git a/cookbooks/redash/README.md b/cookbooks/redash/README.md new file mode 100644 index 000000000..b112cdb6c --- /dev/null +++ b/cookbooks/redash/README.md @@ -0,0 +1,73 @@ +redash Cookbook +================= +TODO: Enter the cookbook description here. + +e.g. +This cookbook makes your favorite breakfast sandwich. + +Requirements +------------ +TODO: List your cookbook requirements. Be sure to include any requirements this cookbook has on platforms, libraries, other cookbooks, packages, operating systems, etc. + +Expect to have these attriburtes defined: +node['postgresql']['password']['postgres'] <=== used for the initial connection to PG, where the redash role+databases are created + + + +e.g. +#### packages +- `toaster` - thelight needs toaster to brown your bagel. + +Attributes +---------- +TODO: List you cookbook attributes here. + +e.g. +#### thelight::default + + + + + + + + + + + + + +
KeyTypeDescriptionDefault
['thelight']['bacon']Booleanwhether to include bacontrue
+ +Usage +----- +#### thelight::default +TODO: Write usage instructions for each cookbook. + +e.g. +Just include `thelight` in your node's `run_list`: + +```json +{ + "name":"my_node", + "run_list": [ + "recipe[thelight]" + ] +} +``` + +Contributing +------------ +TODO: (optional) If this is a public cookbook, detail the process for contributing. If this is a private cookbook, remove this section. + +e.g. +1. Fork the repository on Github +2. Create a named feature branch (like `add_component_x`) +3. Write your change +4. Write tests for your change (if applicable) +5. Run the tests, ensuring they all pass +6. Submit a Pull Request using Github + +License and Authors +------------------- +Authors: TODO: List authors diff --git a/cookbooks/redash/attributes/default.rb b/cookbooks/redash/attributes/default.rb new file mode 100644 index 000000000..94628bfc8 --- /dev/null +++ b/cookbooks/redash/attributes/default.rb @@ -0,0 +1,39 @@ + +default["redash"]["install_path"] = "/opt" + +default["redash"]["user"] = "redash" + +default['redash']['redis_url'] = "redis://localhost:6379" + +default['redash']['db']['user'] = "redash" +default['redash']['db']['password'] = "secureredisecure" +#default['redash']['db']['host'] = "localhost" +default['redash']['db']['port'] = 5432 +default['redash']['db']['dbname'] = "redash_db0" +default['redash']['cfg']['dbname'] = "redash_cfgdb0" +default['redash']['cfg']['user'] = "redash" +default['redash']['cfg']['password'] = "secureredisecure" +default['redash']['cfg']['host'] = node['redash']['db']['host'] +default['redash']['cfg']['port'] = 5432 +default['redash']['db']['postgres_pwd'] = node['postgresql']['password']['postgres'] +default['redash']['cfg']['postgres_pwd'] = node['postgresql']['password']['postgres'] + + +#Accept logins from open id's verified by google accounts: +default['redash']['allow']['google_app_domain'] = "everything.me" +#Two strings with python list inside...: +default['redash']['allow']['google_app_users'] = ['joe@gmail.com','max@gmail.com'] +default['redash']['allow']['admins'] = ['timor@everything.me','arik@everything.me'] + +default['redash']['workers_count'] = 2 +default['redash']['max_connections'] = 3 + +default['redash']['cookie_secret'] = "c292a0a3aa32397cdb050e233733900f" + +default['redash']['server']['log'] = node['redash']['install_path']+"/redash/log/server.log" +default['redash']['worker']['log'] = node['redash']['install_path']+"/redash/log/worker.log" + +default['redash']['server']['py'] = "./main" +default['redash']['worker']['py'] = "./main" + +default['redash']['svlog_opt'] = "-tt" \ No newline at end of file diff --git a/cookbooks/redash/metadata.rb b/cookbooks/redash/metadata.rb new file mode 100644 index 000000000..caa9fcb90 --- /dev/null +++ b/cookbooks/redash/metadata.rb @@ -0,0 +1,17 @@ +name 'redash' +maintainer 'Everything.me' +maintainer_email 'timor@everything.me' +license 'All rights reserved' +description 'Installs/Configures redash' +long_description IO.read(File.join(File.dirname(__FILE__), 'README.md')) +version '0.4' + +depends "postgresql" +depends "python" +depends "ark" +depends "database" + +%w(python dhcp sysctl git).each do |cookbook| + depends cookbook +end +depends 'runit', '>= 1.1.0' diff --git a/cookbooks/redash/recipes/default.rb b/cookbooks/redash/recipes/default.rb new file mode 100644 index 000000000..dda97ca5a --- /dev/null +++ b/cookbooks/redash/recipes/default.rb @@ -0,0 +1,125 @@ +# +# Cookbook Name:: redash +# Recipe:: default +# + +include_recipe "postgresql::client" +include_recipe "python" +include_recipe "runit" + +#Enable cheff to interact with pg: +include_recipe "database::postgresql" + +#Download and deploy the redash release +#TODO: version should be acc. to what's in metadata.rb + +#TODO: install path should be a configurable attribute +user node['redash']['user'] do + system true +end + + +#Ark fails due to errors in remote_file not telling rest-client to expect binary.. +#solution: use an older rest-client.. +chef_gem "rest-client__p" do + action :purge + package_name "rest-client" +end +chef_gem "rest-client__i" do + action :install + package_name "rest-client" + version "1.5.0" +end + +ark "bla" do + #url "http://github.com/EverythingMe/redash/releases/download/v0.1.35/redash.35.tar.gz" + #url "http://www.xmlcan.ca/~timor/redash.35.tar.gz" + url "http://www.xmlcan.ca/~timor/bla.tar.gz" + action :put + path node["redash"]["install_path"] + + #Due to peculiarity of the way the archive gets created: + strip_leading_dir false +end + +#Install dependencies acc. to file: +bash ":install pip dependencies" do + code <<-EOS + cd #{node["redash"]["install_path"]}/redash + pip install -r ./rd_service/requirements.txt + EOS +end + +#Configure: +template "#{node["redash"]["install_path"]}/redash/rd_service/settings.py" do + source "settings.py.erb" +end + +#Setup pg user(s) and database(s): +pg_db_super_connection = { + :host => node['redash']['db']['host'], + :port => node['redash']['db']['port'], + :username => 'postgres', + :password => node['redash']['db']['postgres_pwd'] +} +pg_cfg_super_connection = { + :host => node['redash']['cfg']['host'], + :port => node['redash']['cfg']['port'], + :username => 'postgres', + :password => node['redash']['cfg']['postgres_pwd'] +} + +#The data db: +postgresql_database node['redash']['db']['dbname'] do + connection pg_db_super_connection + action :create +end + +#The configuration db: +postgresql_database node['redash']['cfg']['dbname'] do + connection pg_cfg_super_connection + action :create +end + + +postgresql_database_user "redash_db_user" do + username node['redash']['db']['user'] + connection pg_db_super_connection + password node['redash']['db']['password'] + database_name node['redash']['db']['dbname'] + privileges [:all] + action [:create,:grant] +end + +postgresql_database_user "redash_cfg_user" do + username node['redash']['cfg']['user'] + connection pg_cfg_super_connection + password node['redash']['cfg']['password'] + database_name node['redash']['cfg']['dbname'] + privileges [:all] + action [:create,:grant] +end + + +#Initialize the DB, connecting as normal user: +#Setup pg user(s) and database(s): +pg_db_connection = { + :host => node['redash']['db']['host'], + :port => node['redash']['db']['port'], + :user => node['redash']['db']['user'], + :password => node['redash']['db']['password'], + :dbname => node['redash']['cfg']['dbname'] +} +constr = pg_db_connection.map{|(k,v)| "#{k}=#{v}"}.join(" ") +bash ":initialize db" do + code <<-EOS + cd #{node["redash"]["install_path"]}/redash + + psql "#{constr}" < ./rd_service/data/tables.sql + EOS +end + + +#Install runit scripts and bring the system up: +runit_service "redash-server" +runit_service "redash-worker" diff --git a/cookbooks/redash/templates/default/settings.py.erb b/cookbooks/redash/templates/default/settings.py.erb new file mode 100644 index 000000000..386af10ad --- /dev/null +++ b/cookbooks/redash/templates/default/settings.py.erb @@ -0,0 +1,38 @@ +""" +Settings module generated and dropped off by Chef. +Will be overwritten. +""" + +import django.conf + +REDIS_URL = "<%= node['redash']['redis_url'] %>" +# Connection string for the database that is used to run queries against +CONNECTION_STRING = "user=<%= node['redash']['db']['user'] %> password=<%= node['redash']['db']['password'] %> host=<%= node['redash']['db']['host'] %> port=<%= node['redash']['db']['port'] %> dbname=<%= node['redash']['db']['dbname'] %>" +# Connection string for the operational databases (where we store the queries, results, etc) +INTERNAL_DB_CONNECTION_STRING = "user=<%= node['redash']['cfg']['user'] %> password=<%= node['redash']['cfg']['password'] %> host=<%= node['redash']['cfg']['host'] %> port=<%= node['redash']['cfg']['port'] %> dbname=<%= node['redash']['cfg']['dbname'] %>" +# Google Apps domain to allow access from; any user with email in this Google Apps will be allowed +# access +GOOGLE_APPS_DOMAIN = "<%= node['redash']['allow']['google_app_domain'] %>" +# Email addresses of specific users not from the above set Google Apps Domain, that you want to +# allow access to re:dash +ALLOWED_USERS = <%= Chef::JSONCompat.to_json node['redash']['allow']['google_app_users'] %> +# Email addresses of admin users +ADMINS = <%= Chef::JSONCompat.to_json node['redash']['allow']['admins'] %> + +STATIC_ASSETS_PATH = "<%= node['redash']['install_path'] %>/rd_ui/dist/" + +WORKERS_COUNT = <%= node['redash']['workers_count'] %> +MAX_CONNECTIONS = <%= node['redash']['max_connections'] %> + +COOKIE_SECRET = "c292a0a3aa32397cdb050e233733900f" + +# Configuration of the operational database for the Django models +django.conf.settings.configure(DATABASES = { 'default': { + 'ENGINE': 'dbpool.db.backends.postgresql_psycopg2', + 'OPTIONS': {'MAX_CONNS': <%= node['redash']['max_connections'] %>, 'MIN_CONNS': 1}, + 'NAME': '<%= node['redash']['db']['dbname'] %>', + 'USER': '<%= node['redash']['db']['user'] %>', + 'PASSWORD': '<%= node['redash']['db']['password'] %>', + 'HOST': '<%= node['redash']['db']['host'] %>', + 'PORT': '<%= node['redash']['db']['port'] %>', + },}, TIME_ZONE = 'UTC') diff --git a/cookbooks/redash/templates/default/sv-redash-server-log-run.erb b/cookbooks/redash/templates/default/sv-redash-server-log-run.erb new file mode 100644 index 000000000..981434db3 --- /dev/null +++ b/cookbooks/redash/templates/default/sv-redash-server-log-run.erb @@ -0,0 +1,3 @@ +#!/bin/sh +exec svlogd <%= node['redash']['svlog_opt'] %> <%= node['redash']['server']['log'] %> + diff --git a/cookbooks/redash/templates/default/sv-redash-server-run.erb b/cookbooks/redash/templates/default/sv-redash-server-run.erb new file mode 100644 index 000000000..6a4b1f1ee --- /dev/null +++ b/cookbooks/redash/templates/default/sv-redash-server-run.erb @@ -0,0 +1,4 @@ +#!/bin/sh +exec 2>&1 +exec chpst -u <%= node['redash']['user'] %> env python <%= node['redash']['server']['py'] %> + diff --git a/cookbooks/redash/templates/default/sv-redash-worker-log-run.erb b/cookbooks/redash/templates/default/sv-redash-worker-log-run.erb new file mode 100644 index 000000000..bea350159 --- /dev/null +++ b/cookbooks/redash/templates/default/sv-redash-worker-log-run.erb @@ -0,0 +1,3 @@ +#!/bin/sh +exec svlogd <%= node['redash']['svlog_opt'] %> <%= node['redash']['worker']['log'] %> + diff --git a/cookbooks/redash/templates/default/sv-redash-worker-run.erb b/cookbooks/redash/templates/default/sv-redash-worker-run.erb new file mode 100644 index 000000000..60563e544 --- /dev/null +++ b/cookbooks/redash/templates/default/sv-redash-worker-run.erb @@ -0,0 +1,4 @@ +#!/bin/sh +exec 2>&1 +exec chpst -u <%= node['redash']['user'] %> env python <%= node['redash']['worker']['py'] %> + diff --git a/vagrant/Berksfile b/vagrant/Berksfile new file mode 100644 index 000000000..773ffdab0 --- /dev/null +++ b/vagrant/Berksfile @@ -0,0 +1,2 @@ +cookbook 'redash', path: '../cookbooks/redash' +cookbook 'apt' diff --git a/vagrant/Berksfile.lock b/vagrant/Berksfile.lock new file mode 100644 index 000000000..a84f20756 --- /dev/null +++ b/vagrant/Berksfile.lock @@ -0,0 +1,67 @@ +{ + "sources": { + "redash": { + "path": "../cookbooks/redash" + }, + "apt": { + "locked_version": "2.3.0" + }, + "postgresql": { + "locked_version": "3.2.0" + }, + "build-essential": { + "locked_version": "1.4.2" + }, + "openssl": { + "locked_version": "1.1.0" + }, + "python": { + "locked_version": "1.4.4" + }, + "yum": { + "locked_version": "2.4.2" + }, + "ark": { + "locked_version": "0.4.0" + }, + "database": { + "locked_version": "1.5.2" + }, + "mysql": { + "locked_version": "4.0.4" + }, + "aws": { + "locked_version": "1.0.0" + }, + "xfs": { + "locked_version": "1.1.0" + }, + "dhcp": { + "locked_version": "2.0.1" + }, + "ruby-helper": { + "locked_version": "0.0.1" + }, + "helpers-databags": { + "locked_version": "1.0.0" + }, + "sysctl": { + "locked_version": "0.3.4" + }, + "git": { + "locked_version": "2.7.0" + }, + "dmg": { + "locked_version": "2.0.8" + }, + "windows": { + "locked_version": "1.11.0" + }, + "chef_handler": { + "locked_version": "1.1.4" + }, + "runit": { + "locked_version": "1.4.0" + } + } +} diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile index 39d18d822..f60de9eed 100644 --- a/vagrant/Vagrantfile +++ b/vagrant/Vagrantfile @@ -5,114 +5,29 @@ VAGRANTFILE_API_VERSION = "2" Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| - # All Vagrant configuration is done here. The most common configuration - # options are documented and commented below. For a complete reference, - # please see the online documentation at vagrantup.com. - - # Every Vagrant virtual environment requires a box to build off of. + config.vm.box = "precise32" - # The url from where the 'config.vm.box' box will be fetched if it - # doesn't already exist on the user's system. - config.vm.box_url = "http://files.vagrantup.com/precise32.box" + config.vm.box_url = "http://cloud-images.ubuntu.com/vagrant/precise/current/precise-server-cloudimg-amd64-vagrant-disk1.box" - # Create a forwarded port mapping which allows access to a specific port - # within the machine from a port on the host machine. In the example below, - # accessing "localhost:8080" will access port 80 on the guest machine. - # config.vm.network :forwarded_port, guest: 80, host: 8080 - - # Create a private network, which allows host-only access to the machine - # using a specific IP. - # config.vm.network :private_network, ip: "192.168.33.10" - - # Create a public network, which generally matched to bridged network. - # Bridged networks make the machine appear as another physical device on - # your network. - # config.vm.network :public_network - - # If true, then any SSH connections made will enable agent forwarding. - # Default value: false - # config.ssh.forward_agent = true - - # Share an additional folder to the guest VM. The first argument is - # the path on the host to the actual folder. The second argument is - # the path on the guest to mount the folder. And the optional third - # argument is a set of non-required options. - # config.vm.synced_folder "../data", "/vagrant_data" - - # Provider-specific configuration so you can fine-tune various - # backing providers for Vagrant. These expose provider-specific options. - # Example for VirtualBox: - # - # config.vm.provider :virtualbox do |vb| - # # Don't boot with headless mode - # vb.gui = true - # - # # Use VBoxManage to customize the VM. For example to change memory: - # vb.customize ["modifyvm", :id, "--memory", "1024"] - # end - # - # View the documentation for the provider you're using for more - # information on available options. - - # Enable provisioning with Puppet stand alone. Puppet manifests - # are contained in a directory path relative to this Vagrantfile. - # You will need to create the manifests directory and a manifest in - # the file precise32.pp in the manifests_path directory. - # - # An example Puppet manifest to provision the message of the day: - # - # # group { "puppet": - # # ensure => "present", - # # } - # # - # # File { owner => 0, group => 0, mode => 0644 } - # # - # # file { '/etc/motd': - # # content => "Welcome to your Vagrant-built virtual machine! - # # Managed by Puppet.\n" - # # } - # - # config.vm.provision :puppet do |puppet| - # puppet.manifests_path = "manifests" - # puppet.manifest_file = "site.pp" - # end - - # Enable provisioning with chef solo, specifying a cookbooks path, roles - # path, and data_bags path (all relative to this Vagrantfile), and adding - # some recipes and/or roles. - # - # config.vm.provision :chef_solo do |chef| - # chef.cookbooks_path = "../my-recipes/cookbooks" - # chef.roles_path = "../my-recipes/roles" - # chef.data_bags_path = "../my-recipes/data_bags" - # chef.add_recipe "mysql" - # chef.add_role "web" - # - # # You may also specify custom JSON attributes: - # chef.json = { :mysql_password => "foo" } - # end - - # Enable provisioning with chef server, specifying the chef server URL, - # and the path to the validation key (relative to this Vagrantfile). - # - # The Opscode Platform uses HTTPS. Substitute your organization for - # ORGNAME in the URL and validation key. - # - # If you have your own Chef Server, use the appropriate URL, which may be - # HTTP instead of HTTPS depending on your configuration. Also change the - # validation key to validation.pem. - # - # config.vm.provision :chef_client do |chef| - # chef.chef_server_url = "https://api.opscode.com/organizations/ORGNAME" - # chef.validation_key_path = "ORGNAME-validator.pem" - # end - # - # If you're using the Opscode platform, your validator client is - # ORGNAME-validator, replacing ORGNAME with your organization name. - # - # If you have your own Chef Server, the default validation client name is - # chef-validator, unless you changed the configuration. - # - # chef.validation_client_name = "ORGNAME-validator" + config.berkshelf.enabled = true + #config.omnibus.chef_version = :latest + config.omnibus.chef_version = "11.6.0" + + config.vm.network "forwarded_port", guest: 8888, host: 9999 + + + config.vm.provision :chef_solo do |chef| + #Run apt-get update before anything else (specifically postgresql).. + chef.add_recipe "apt" + chef.add_recipe "redash" + chef.add_recipe "postgresql" + + chef.json = { + "apt" => {"compiletime" => true}, + "postgresql" => {"password" => {"postgres" => "securepass"}}, + "redash" => {"db" => {"host" => "localhost"}}, + } + + end end diff --git a/vagrant/notes b/vagrant/notes new file mode 100644 index 000000000..2895a84ff --- /dev/null +++ b/vagrant/notes @@ -0,0 +1,5 @@ +Download: +https://github.com/EverythingMe/redash/releases/download/v0.1.35/redash.35.tar.gz +--> /opt/redash +deploy postgresql db, sql file. +edit / replace settings file. From 74142187c155b6339c92b1d9856bcff20ac677c9 Mon Sep 17 00:00:00 2001 From: Timor Raiman Date: Wed, 20 Nov 2013 16:08:08 +0200 Subject: [PATCH 078/540] Cleanup --- cookbooks/redash/recipes/default.rb | 22 +++------- vagrant/.gitignore | 1 + vagrant/Berksfile.lock | 67 ----------------------------- vagrant/notes | 5 --- 4 files changed, 6 insertions(+), 89 deletions(-) delete mode 100644 vagrant/Berksfile.lock delete mode 100644 vagrant/notes diff --git a/cookbooks/redash/recipes/default.rb b/cookbooks/redash/recipes/default.rb index dda97ca5a..57e4a119d 100644 --- a/cookbooks/redash/recipes/default.rb +++ b/cookbooks/redash/recipes/default.rb @@ -3,6 +3,9 @@ # Recipe:: default # +#Workaround for endocind errors with remote_file: +Encoding.default_external = Encoding::ASCII_8BIT + include_recipe "postgresql::client" include_recipe "python" include_recipe "runit" @@ -18,23 +21,8 @@ user node['redash']['user'] do system true end - -#Ark fails due to errors in remote_file not telling rest-client to expect binary.. -#solution: use an older rest-client.. -chef_gem "rest-client__p" do - action :purge - package_name "rest-client" -end -chef_gem "rest-client__i" do - action :install - package_name "rest-client" - version "1.5.0" -end - -ark "bla" do - #url "http://github.com/EverythingMe/redash/releases/download/v0.1.35/redash.35.tar.gz" - #url "http://www.xmlcan.ca/~timor/redash.35.tar.gz" - url "http://www.xmlcan.ca/~timor/bla.tar.gz" +ark "redash" do + url "http://github.com/EverythingMe/redash/releases/download/v0.1.35/redash.35.tar.gz" action :put path node["redash"]["install_path"] diff --git a/vagrant/.gitignore b/vagrant/.gitignore index 8000dd9db..43c013aee 100644 --- a/vagrant/.gitignore +++ b/vagrant/.gitignore @@ -1 +1,2 @@ .vagrant +Berksfile.lock diff --git a/vagrant/Berksfile.lock b/vagrant/Berksfile.lock deleted file mode 100644 index a84f20756..000000000 --- a/vagrant/Berksfile.lock +++ /dev/null @@ -1,67 +0,0 @@ -{ - "sources": { - "redash": { - "path": "../cookbooks/redash" - }, - "apt": { - "locked_version": "2.3.0" - }, - "postgresql": { - "locked_version": "3.2.0" - }, - "build-essential": { - "locked_version": "1.4.2" - }, - "openssl": { - "locked_version": "1.1.0" - }, - "python": { - "locked_version": "1.4.4" - }, - "yum": { - "locked_version": "2.4.2" - }, - "ark": { - "locked_version": "0.4.0" - }, - "database": { - "locked_version": "1.5.2" - }, - "mysql": { - "locked_version": "4.0.4" - }, - "aws": { - "locked_version": "1.0.0" - }, - "xfs": { - "locked_version": "1.1.0" - }, - "dhcp": { - "locked_version": "2.0.1" - }, - "ruby-helper": { - "locked_version": "0.0.1" - }, - "helpers-databags": { - "locked_version": "1.0.0" - }, - "sysctl": { - "locked_version": "0.3.4" - }, - "git": { - "locked_version": "2.7.0" - }, - "dmg": { - "locked_version": "2.0.8" - }, - "windows": { - "locked_version": "1.11.0" - }, - "chef_handler": { - "locked_version": "1.1.4" - }, - "runit": { - "locked_version": "1.4.0" - } - } -} diff --git a/vagrant/notes b/vagrant/notes deleted file mode 100644 index 2895a84ff..000000000 --- a/vagrant/notes +++ /dev/null @@ -1,5 +0,0 @@ -Download: -https://github.com/EverythingMe/redash/releases/download/v0.1.35/redash.35.tar.gz ---> /opt/redash -deploy postgresql db, sql file. -edit / replace settings file. From a3cf6711a55bc80e7c5d2299e507409155bd18d1 Mon Sep 17 00:00:00 2001 From: Timor Raiman Date: Wed, 20 Nov 2013 16:54:40 +0200 Subject: [PATCH 079/540] Remove chef cookbooks (migrated to separate repo) --- cookbooks/redash/Berksfile | 1 - cookbooks/redash/README.md | 73 ----------- cookbooks/redash/attributes/default.rb | 39 ------ cookbooks/redash/metadata.rb | 17 --- cookbooks/redash/recipes/default.rb | 113 ------------------ .../redash/templates/default/settings.py.erb | 38 ------ .../default/sv-redash-server-log-run.erb | 3 - .../default/sv-redash-server-run.erb | 4 - .../default/sv-redash-worker-log-run.erb | 3 - .../default/sv-redash-worker-run.erb | 4 - 10 files changed, 295 deletions(-) delete mode 100644 cookbooks/redash/Berksfile delete mode 100644 cookbooks/redash/README.md delete mode 100644 cookbooks/redash/attributes/default.rb delete mode 100644 cookbooks/redash/metadata.rb delete mode 100644 cookbooks/redash/recipes/default.rb delete mode 100644 cookbooks/redash/templates/default/settings.py.erb delete mode 100644 cookbooks/redash/templates/default/sv-redash-server-log-run.erb delete mode 100644 cookbooks/redash/templates/default/sv-redash-server-run.erb delete mode 100644 cookbooks/redash/templates/default/sv-redash-worker-log-run.erb delete mode 100644 cookbooks/redash/templates/default/sv-redash-worker-run.erb diff --git a/cookbooks/redash/Berksfile b/cookbooks/redash/Berksfile deleted file mode 100644 index 850a120dc..000000000 --- a/cookbooks/redash/Berksfile +++ /dev/null @@ -1 +0,0 @@ -metadata diff --git a/cookbooks/redash/README.md b/cookbooks/redash/README.md deleted file mode 100644 index b112cdb6c..000000000 --- a/cookbooks/redash/README.md +++ /dev/null @@ -1,73 +0,0 @@ -redash Cookbook -================= -TODO: Enter the cookbook description here. - -e.g. -This cookbook makes your favorite breakfast sandwich. - -Requirements ------------- -TODO: List your cookbook requirements. Be sure to include any requirements this cookbook has on platforms, libraries, other cookbooks, packages, operating systems, etc. - -Expect to have these attriburtes defined: -node['postgresql']['password']['postgres'] <=== used for the initial connection to PG, where the redash role+databases are created - - - -e.g. -#### packages -- `toaster` - thelight needs toaster to brown your bagel. - -Attributes ----------- -TODO: List you cookbook attributes here. - -e.g. -#### thelight::default - - - - - - - - - - - - - -
KeyTypeDescriptionDefault
['thelight']['bacon']Booleanwhether to include bacontrue
- -Usage ------ -#### thelight::default -TODO: Write usage instructions for each cookbook. - -e.g. -Just include `thelight` in your node's `run_list`: - -```json -{ - "name":"my_node", - "run_list": [ - "recipe[thelight]" - ] -} -``` - -Contributing ------------- -TODO: (optional) If this is a public cookbook, detail the process for contributing. If this is a private cookbook, remove this section. - -e.g. -1. Fork the repository on Github -2. Create a named feature branch (like `add_component_x`) -3. Write your change -4. Write tests for your change (if applicable) -5. Run the tests, ensuring they all pass -6. Submit a Pull Request using Github - -License and Authors -------------------- -Authors: TODO: List authors diff --git a/cookbooks/redash/attributes/default.rb b/cookbooks/redash/attributes/default.rb deleted file mode 100644 index 94628bfc8..000000000 --- a/cookbooks/redash/attributes/default.rb +++ /dev/null @@ -1,39 +0,0 @@ - -default["redash"]["install_path"] = "/opt" - -default["redash"]["user"] = "redash" - -default['redash']['redis_url'] = "redis://localhost:6379" - -default['redash']['db']['user'] = "redash" -default['redash']['db']['password'] = "secureredisecure" -#default['redash']['db']['host'] = "localhost" -default['redash']['db']['port'] = 5432 -default['redash']['db']['dbname'] = "redash_db0" -default['redash']['cfg']['dbname'] = "redash_cfgdb0" -default['redash']['cfg']['user'] = "redash" -default['redash']['cfg']['password'] = "secureredisecure" -default['redash']['cfg']['host'] = node['redash']['db']['host'] -default['redash']['cfg']['port'] = 5432 -default['redash']['db']['postgres_pwd'] = node['postgresql']['password']['postgres'] -default['redash']['cfg']['postgres_pwd'] = node['postgresql']['password']['postgres'] - - -#Accept logins from open id's verified by google accounts: -default['redash']['allow']['google_app_domain'] = "everything.me" -#Two strings with python list inside...: -default['redash']['allow']['google_app_users'] = ['joe@gmail.com','max@gmail.com'] -default['redash']['allow']['admins'] = ['timor@everything.me','arik@everything.me'] - -default['redash']['workers_count'] = 2 -default['redash']['max_connections'] = 3 - -default['redash']['cookie_secret'] = "c292a0a3aa32397cdb050e233733900f" - -default['redash']['server']['log'] = node['redash']['install_path']+"/redash/log/server.log" -default['redash']['worker']['log'] = node['redash']['install_path']+"/redash/log/worker.log" - -default['redash']['server']['py'] = "./main" -default['redash']['worker']['py'] = "./main" - -default['redash']['svlog_opt'] = "-tt" \ No newline at end of file diff --git a/cookbooks/redash/metadata.rb b/cookbooks/redash/metadata.rb deleted file mode 100644 index caa9fcb90..000000000 --- a/cookbooks/redash/metadata.rb +++ /dev/null @@ -1,17 +0,0 @@ -name 'redash' -maintainer 'Everything.me' -maintainer_email 'timor@everything.me' -license 'All rights reserved' -description 'Installs/Configures redash' -long_description IO.read(File.join(File.dirname(__FILE__), 'README.md')) -version '0.4' - -depends "postgresql" -depends "python" -depends "ark" -depends "database" - -%w(python dhcp sysctl git).each do |cookbook| - depends cookbook -end -depends 'runit', '>= 1.1.0' diff --git a/cookbooks/redash/recipes/default.rb b/cookbooks/redash/recipes/default.rb deleted file mode 100644 index 57e4a119d..000000000 --- a/cookbooks/redash/recipes/default.rb +++ /dev/null @@ -1,113 +0,0 @@ -# -# Cookbook Name:: redash -# Recipe:: default -# - -#Workaround for endocind errors with remote_file: -Encoding.default_external = Encoding::ASCII_8BIT - -include_recipe "postgresql::client" -include_recipe "python" -include_recipe "runit" - -#Enable cheff to interact with pg: -include_recipe "database::postgresql" - -#Download and deploy the redash release -#TODO: version should be acc. to what's in metadata.rb - -#TODO: install path should be a configurable attribute -user node['redash']['user'] do - system true -end - -ark "redash" do - url "http://github.com/EverythingMe/redash/releases/download/v0.1.35/redash.35.tar.gz" - action :put - path node["redash"]["install_path"] - - #Due to peculiarity of the way the archive gets created: - strip_leading_dir false -end - -#Install dependencies acc. to file: -bash ":install pip dependencies" do - code <<-EOS - cd #{node["redash"]["install_path"]}/redash - pip install -r ./rd_service/requirements.txt - EOS -end - -#Configure: -template "#{node["redash"]["install_path"]}/redash/rd_service/settings.py" do - source "settings.py.erb" -end - -#Setup pg user(s) and database(s): -pg_db_super_connection = { - :host => node['redash']['db']['host'], - :port => node['redash']['db']['port'], - :username => 'postgres', - :password => node['redash']['db']['postgres_pwd'] -} -pg_cfg_super_connection = { - :host => node['redash']['cfg']['host'], - :port => node['redash']['cfg']['port'], - :username => 'postgres', - :password => node['redash']['cfg']['postgres_pwd'] -} - -#The data db: -postgresql_database node['redash']['db']['dbname'] do - connection pg_db_super_connection - action :create -end - -#The configuration db: -postgresql_database node['redash']['cfg']['dbname'] do - connection pg_cfg_super_connection - action :create -end - - -postgresql_database_user "redash_db_user" do - username node['redash']['db']['user'] - connection pg_db_super_connection - password node['redash']['db']['password'] - database_name node['redash']['db']['dbname'] - privileges [:all] - action [:create,:grant] -end - -postgresql_database_user "redash_cfg_user" do - username node['redash']['cfg']['user'] - connection pg_cfg_super_connection - password node['redash']['cfg']['password'] - database_name node['redash']['cfg']['dbname'] - privileges [:all] - action [:create,:grant] -end - - -#Initialize the DB, connecting as normal user: -#Setup pg user(s) and database(s): -pg_db_connection = { - :host => node['redash']['db']['host'], - :port => node['redash']['db']['port'], - :user => node['redash']['db']['user'], - :password => node['redash']['db']['password'], - :dbname => node['redash']['cfg']['dbname'] -} -constr = pg_db_connection.map{|(k,v)| "#{k}=#{v}"}.join(" ") -bash ":initialize db" do - code <<-EOS - cd #{node["redash"]["install_path"]}/redash - - psql "#{constr}" < ./rd_service/data/tables.sql - EOS -end - - -#Install runit scripts and bring the system up: -runit_service "redash-server" -runit_service "redash-worker" diff --git a/cookbooks/redash/templates/default/settings.py.erb b/cookbooks/redash/templates/default/settings.py.erb deleted file mode 100644 index 386af10ad..000000000 --- a/cookbooks/redash/templates/default/settings.py.erb +++ /dev/null @@ -1,38 +0,0 @@ -""" -Settings module generated and dropped off by Chef. -Will be overwritten. -""" - -import django.conf - -REDIS_URL = "<%= node['redash']['redis_url'] %>" -# Connection string for the database that is used to run queries against -CONNECTION_STRING = "user=<%= node['redash']['db']['user'] %> password=<%= node['redash']['db']['password'] %> host=<%= node['redash']['db']['host'] %> port=<%= node['redash']['db']['port'] %> dbname=<%= node['redash']['db']['dbname'] %>" -# Connection string for the operational databases (where we store the queries, results, etc) -INTERNAL_DB_CONNECTION_STRING = "user=<%= node['redash']['cfg']['user'] %> password=<%= node['redash']['cfg']['password'] %> host=<%= node['redash']['cfg']['host'] %> port=<%= node['redash']['cfg']['port'] %> dbname=<%= node['redash']['cfg']['dbname'] %>" -# Google Apps domain to allow access from; any user with email in this Google Apps will be allowed -# access -GOOGLE_APPS_DOMAIN = "<%= node['redash']['allow']['google_app_domain'] %>" -# Email addresses of specific users not from the above set Google Apps Domain, that you want to -# allow access to re:dash -ALLOWED_USERS = <%= Chef::JSONCompat.to_json node['redash']['allow']['google_app_users'] %> -# Email addresses of admin users -ADMINS = <%= Chef::JSONCompat.to_json node['redash']['allow']['admins'] %> - -STATIC_ASSETS_PATH = "<%= node['redash']['install_path'] %>/rd_ui/dist/" - -WORKERS_COUNT = <%= node['redash']['workers_count'] %> -MAX_CONNECTIONS = <%= node['redash']['max_connections'] %> - -COOKIE_SECRET = "c292a0a3aa32397cdb050e233733900f" - -# Configuration of the operational database for the Django models -django.conf.settings.configure(DATABASES = { 'default': { - 'ENGINE': 'dbpool.db.backends.postgresql_psycopg2', - 'OPTIONS': {'MAX_CONNS': <%= node['redash']['max_connections'] %>, 'MIN_CONNS': 1}, - 'NAME': '<%= node['redash']['db']['dbname'] %>', - 'USER': '<%= node['redash']['db']['user'] %>', - 'PASSWORD': '<%= node['redash']['db']['password'] %>', - 'HOST': '<%= node['redash']['db']['host'] %>', - 'PORT': '<%= node['redash']['db']['port'] %>', - },}, TIME_ZONE = 'UTC') diff --git a/cookbooks/redash/templates/default/sv-redash-server-log-run.erb b/cookbooks/redash/templates/default/sv-redash-server-log-run.erb deleted file mode 100644 index 981434db3..000000000 --- a/cookbooks/redash/templates/default/sv-redash-server-log-run.erb +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -exec svlogd <%= node['redash']['svlog_opt'] %> <%= node['redash']['server']['log'] %> - diff --git a/cookbooks/redash/templates/default/sv-redash-server-run.erb b/cookbooks/redash/templates/default/sv-redash-server-run.erb deleted file mode 100644 index 6a4b1f1ee..000000000 --- a/cookbooks/redash/templates/default/sv-redash-server-run.erb +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -exec 2>&1 -exec chpst -u <%= node['redash']['user'] %> env python <%= node['redash']['server']['py'] %> - diff --git a/cookbooks/redash/templates/default/sv-redash-worker-log-run.erb b/cookbooks/redash/templates/default/sv-redash-worker-log-run.erb deleted file mode 100644 index bea350159..000000000 --- a/cookbooks/redash/templates/default/sv-redash-worker-log-run.erb +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -exec svlogd <%= node['redash']['svlog_opt'] %> <%= node['redash']['worker']['log'] %> - diff --git a/cookbooks/redash/templates/default/sv-redash-worker-run.erb b/cookbooks/redash/templates/default/sv-redash-worker-run.erb deleted file mode 100644 index 60563e544..000000000 --- a/cookbooks/redash/templates/default/sv-redash-worker-run.erb +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -exec 2>&1 -exec chpst -u <%= node['redash']['user'] %> env python <%= node['redash']['worker']['py'] %> - From 5b286b74b0d3378a643eeb4bfae85d68af034fc5 Mon Sep 17 00:00:00 2001 From: Timor Raiman Date: Thu, 21 Nov 2013 19:54:47 +0200 Subject: [PATCH 080/540] Code review by Yoni --- vagrant/Berksfile | 4 +++- vagrant/Vagrantfile | 33 ++++++++++++++++++--------------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/vagrant/Berksfile b/vagrant/Berksfile index 773ffdab0..91560b352 100644 --- a/vagrant/Berksfile +++ b/vagrant/Berksfile @@ -1,2 +1,4 @@ -cookbook 'redash', path: '../cookbooks/redash' cookbook 'apt' +cookbook 'postgresql' +#cookbook 'redash', git: 'git@gitlab.doit9.com:ops/chef-redash.git' +cookbook 'redash', path: '../../chef-redash' diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile index f60de9eed..8e762be6f 100644 --- a/vagrant/Vagrantfile +++ b/vagrant/Vagrantfile @@ -4,30 +4,33 @@ # Vagrantfile API/syntax version. Don't touch unless you know what you're doing! VAGRANTFILE_API_VERSION = "2" -Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| - - config.vm.box = "precise32" +# TODO: should be result of encryption of readable password +__pg_password = "securepass" + +Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| + config.vm.box = "ubuntu-precise-cloudimg-amd64" config.vm.box_url = "http://cloud-images.ubuntu.com/vagrant/precise/current/precise-server-cloudimg-amd64-vagrant-disk1.box" - config.berkshelf.enabled = true - #config.omnibus.chef_version = :latest - config.omnibus.chef_version = "11.6.0" - + config.berkshelf.enabled = true + config.omnibus.chef_version = :latest + config.vm.network "forwarded_port", guest: 8888, host: 9999 config.vm.provision :chef_solo do |chef| - #Run apt-get update before anything else (specifically postgresql).. + # run apt-get update before anything else (specifically postgresql).. chef.add_recipe "apt" - chef.add_recipe "redash" chef.add_recipe "postgresql" - + chef.add_recipe "redash::redash_pg_schema" + chef.add_recipe "redash::redash" + chef.json = { - "apt" => {"compiletime" => true}, - "postgresql" => {"password" => {"postgres" => "securepass"}}, - "redash" => {"db" => {"host" => "localhost"}}, - } - + "apt" => { "compiletime" => true }, + "postgresql" => { "password" => { "postgres" => __pg_password } }, + "redash" => { "db" => { "host" => "localhost", + "user" => "postgres", + "password" => __pg_password } } + } end end From c6bf7511e176f6e424316534ff15e8dbcd955b0f Mon Sep 17 00:00:00 2001 From: Timor Raiman Date: Mon, 25 Nov 2013 10:54:24 +0200 Subject: [PATCH 081/540] Improve Vagrantfile --- vagrant/Vagrantfile | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile index 8e762be6f..0886d19b2 100644 --- a/vagrant/Vagrantfile +++ b/vagrant/Vagrantfile @@ -2,35 +2,45 @@ # vi: set ft=ruby : # Vagrantfile API/syntax version. Don't touch unless you know what you're doing! -VAGRANTFILE_API_VERSION = "2" +VAGRANTFILE_API_VERSION = '2' # TODO: should be result of encryption of readable password -__pg_password = "securepass" +POSTGRES_PASSWORD = 'securepass' + +# After starting the vagrant machine, the application is accessible via the URL +# http://localhost:9999 +HOST_PORT_TO_FORWARD_TO_REDASH = 9999 Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| - config.vm.box = "ubuntu-precise-cloudimg-amd64" - config.vm.box_url = "http://cloud-images.ubuntu.com/vagrant/precise/current/precise-server-cloudimg-amd64-vagrant-disk1.box" - + config.vm.box = 'ubuntu-precise-cloudimg-amd64' + config.vm.box_url = 'http://cloud-images.ubuntu.com/vagrant/precise/current/precise-server-cloudimg-amd64-vagrant-disk1.box' + + if config.respond_to? :cache + config.cache.auto_detect = true + end + config.berkshelf.enabled = true config.omnibus.chef_version = :latest - config.vm.network "forwarded_port", guest: 8888, host: 9999 + config.vm.network 'forwarded_port', guest: 8888, host: HOST_PORT_TO_FORWARD_TO_REDASH config.vm.provision :chef_solo do |chef| # run apt-get update before anything else (specifically postgresql).. - chef.add_recipe "apt" - chef.add_recipe "postgresql" - chef.add_recipe "redash::redash_pg_schema" - chef.add_recipe "redash::redash" - + chef.add_recipe 'apt' + chef.add_recipe 'postgresql' + chef.add_recipe 'redash::redash_pg_schema' + chef.add_recipe 'redash::redash' + # chef.log_level = :debug chef.json = { - "apt" => { "compiletime" => true }, - "postgresql" => { "password" => { "postgres" => __pg_password } }, - "redash" => { "db" => { "host" => "localhost", - "user" => "postgres", - "password" => __pg_password } } + 'apt' => { 'compiletime' => true }, + 'postgresql' => { 'password' => {'postgres' => POSTGRES_PASSWORD } }, + 'redash' => { 'db' => {'host' => 'localhost', + 'user' => 'postgres', + 'password' => POSTGRES_PASSWORD }, + 'allow' => {'google_app_domain' => 'gmail.com', + 'admins' => ['joe@egmail.com','jack@gmail.com']}} } end end From 75f1ab6183020b43edc078193f2ea235fd08d77d Mon Sep 17 00:00:00 2001 From: Timor Raiman Date: Wed, 27 Nov 2013 18:37:58 +0200 Subject: [PATCH 082/540] Force postgresql::server in Vagrant --- vagrant/Vagrantfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile index 0886d19b2..ee2098867 100644 --- a/vagrant/Vagrantfile +++ b/vagrant/Vagrantfile @@ -29,7 +29,8 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.provision :chef_solo do |chef| # run apt-get update before anything else (specifically postgresql).. chef.add_recipe 'apt' - chef.add_recipe 'postgresql' + chef.add_recipe 'postgresql::client' + chef.add_recipe 'postgresql::server' chef.add_recipe 'redash::redash_pg_schema' chef.add_recipe 'redash::redash' # chef.log_level = :debug From 7315d9671dc08b5bb8401cf4f252b9a71597d98a Mon Sep 17 00:00:00 2001 From: Timor Raiman Date: Wed, 27 Nov 2013 18:46:05 +0200 Subject: [PATCH 083/540] Use md5 digest of readable pg password postgres user --- vagrant/Vagrantfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile index ee2098867..ba46dce8d 100644 --- a/vagrant/Vagrantfile +++ b/vagrant/Vagrantfile @@ -4,9 +4,9 @@ # Vagrantfile API/syntax version. Don't touch unless you know what you're doing! VAGRANTFILE_API_VERSION = '2' - -# TODO: should be result of encryption of readable password -POSTGRES_PASSWORD = 'securepass' +require "digest/md5" +POSTGRES_PASSWORD = 'securepass' +postgres_password_md5 = Digest::MD5.hexdigest(POSTGRES_PASSWORD) # After starting the vagrant machine, the application is accessible via the URL # http://localhost:9999 @@ -36,7 +36,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| # chef.log_level = :debug chef.json = { 'apt' => { 'compiletime' => true }, - 'postgresql' => { 'password' => {'postgres' => POSTGRES_PASSWORD } }, + 'postgresql' => { 'password' => {'postgres' => postgres_password_md5 } }, 'redash' => { 'db' => {'host' => 'localhost', 'user' => 'postgres', 'password' => POSTGRES_PASSWORD }, From a6ab94113f554455ba0e05c8ccbfe15a60e5e961 Mon Sep 17 00:00:00 2001 From: Timor Raiman Date: Thu, 28 Nov 2013 10:55:28 +0200 Subject: [PATCH 084/540] Correct postgresql encrypted vs cleartext passwords --- vagrant/Vagrantfile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile index ba46dce8d..e0b188981 100644 --- a/vagrant/Vagrantfile +++ b/vagrant/Vagrantfile @@ -4,9 +4,13 @@ # Vagrantfile API/syntax version. Don't touch unless you know what you're doing! VAGRANTFILE_API_VERSION = '2' -require "digest/md5" POSTGRES_PASSWORD = 'securepass' -postgres_password_md5 = Digest::MD5.hexdigest(POSTGRES_PASSWORD) + +# Currently, chef postgress cookbook works with cleartext paswords, +# unless the password begins with 'md5' +# See https://github.com/hw-cookbooks/postgresql/issues/95 +require "digest/md5" +postgres_password_md5 = 'md5'+Digest::MD5.hexdigest(POSTGRES_PASSWORD+'postgres') # After starting the vagrant machine, the application is accessible via the URL # http://localhost:9999 @@ -29,6 +33,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.provision :chef_solo do |chef| # run apt-get update before anything else (specifically postgresql).. chef.add_recipe 'apt' + chef.add_recipe 'redash::redis_for_redash' chef.add_recipe 'postgresql::client' chef.add_recipe 'postgresql::server' chef.add_recipe 'redash::redash_pg_schema' From 30c3df0829923afced1f1fb22d70ab0cd3d15c90 Mon Sep 17 00:00:00 2001 From: Timor Raiman Date: Mon, 2 Dec 2013 11:54:56 +0200 Subject: [PATCH 085/540] In Vagrantfile, support skiping tarball download, and graft the current development tree in stead --- vagrant/Vagrantfile | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile index e0b188981..415006f58 100644 --- a/vagrant/Vagrantfile +++ b/vagrant/Vagrantfile @@ -16,6 +16,9 @@ postgres_password_md5 = 'md5'+Digest::MD5.hexdigest(POSTGRES_PASSWORD+'postgres' # http://localhost:9999 HOST_PORT_TO_FORWARD_TO_REDASH = 9999 +# Deploy direcly the code in parent dir; Don't download a release tarball +live_testing_deployment = true + Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.box = 'ubuntu-precise-cloudimg-amd64' config.vm.box_url = 'http://cloud-images.ubuntu.com/vagrant/precise/current/precise-server-cloudimg-amd64-vagrant-disk1.box' @@ -29,6 +32,9 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.network 'forwarded_port', guest: 8888, host: HOST_PORT_TO_FORWARD_TO_REDASH + if live_testing_deployment + config.vm.synced_folder "..", "/opt/redash" + end config.vm.provision :chef_solo do |chef| # run apt-get update before anything else (specifically postgresql).. @@ -45,8 +51,10 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 'redash' => { 'db' => {'host' => 'localhost', 'user' => 'postgres', 'password' => POSTGRES_PASSWORD }, - 'allow' => {'google_app_domain' => 'gmail.com', - 'admins' => ['joe@egmail.com','jack@gmail.com']}} + 'allow' => {'google_app_domain' => 'gmail.com', + 'admins' => ['joe@egmail.com','jack@gmail.com']}, + 'install_tarball' => !live_testing_deployment, + 'user' => live_testing_deployment ? 'vagrant' : 'redash'} } end end From 5db8e66ca6db6c91185ce6c9d07b6641ccd8f902 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Thu, 26 Dec 2013 16:19:10 +0200 Subject: [PATCH 086/540] Move vagrant related files to top level --- .gitignore | 4 ++++ vagrant/Berksfile => Berksfile | 0 vagrant/Vagrantfile => Vagrantfile | 0 vagrant/.gitignore | 2 -- 4 files changed, 4 insertions(+), 2 deletions(-) rename vagrant/Berksfile => Berksfile (100%) rename vagrant/Vagrantfile => Vagrantfile (100%) delete mode 100644 vagrant/.gitignore diff --git a/.gitignore b/.gitignore index 3dd65166f..03cbf8cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ rd_service/settings.py rd_ui/dist .DS_Store + +# Vagrant related +.vagrant +Berksfile.lock diff --git a/vagrant/Berksfile b/Berksfile similarity index 100% rename from vagrant/Berksfile rename to Berksfile diff --git a/vagrant/Vagrantfile b/Vagrantfile similarity index 100% rename from vagrant/Vagrantfile rename to Vagrantfile diff --git a/vagrant/.gitignore b/vagrant/.gitignore deleted file mode 100644 index 43c013aee..000000000 --- a/vagrant/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.vagrant -Berksfile.lock From b4b61f9eb69a45d8d746da84a815ea920968e317 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sat, 4 Jan 2014 10:52:21 +0200 Subject: [PATCH 087/540] Fix cookbook path --- Berksfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Berksfile b/Berksfile index 91560b352..acc59cb36 100644 --- a/Berksfile +++ b/Berksfile @@ -1,4 +1,3 @@ cookbook 'apt' cookbook 'postgresql' -#cookbook 'redash', git: 'git@gitlab.doit9.com:ops/chef-redash.git' -cookbook 'redash', path: '../../chef-redash' +cookbook 'redash', git: 'git@github.com:EverythingMe/chef-redash.git' From efe75c41341de5939eb368a68d6e5383f812f2e0 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sat, 4 Jan 2014 10:55:40 +0200 Subject: [PATCH 088/540] Add BaseAuthenticatedHandler and move authentication logic there. --- rd_service/server.py | 54 +++++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/rd_service/server.py b/rd_service/server.py index 416f7514e..7d9d39599 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -44,10 +44,6 @@ class BaseHandler(tornado.web.RequestHandler): user = self.get_secure_cookie("user") return user - @tornado.web.authenticated - def prepare(self): - pass - def write_json(self, response, encode=True): if encode: response = json.dumps(response, cls=utils.JSONEncoder) @@ -55,6 +51,12 @@ class BaseHandler(tornado.web.RequestHandler): self.write(response) +class BaseAuthenticatedHandler(BaseHandler): + @tornado.web.authenticated + def prepare(self): + pass + + class PingHandler(tornado.web.RequestHandler): def get(self): self.write("PONG") @@ -79,7 +81,7 @@ class GoogleLoginHandler(tornado.web.RequestHandler, self.authenticate_redirect() -class MainHandler(BaseHandler): +class MainHandler(BaseAuthenticatedHandler): def get(self, *args): email_md5 = hashlib.md5(self.current_user.lower()).hexdigest() gravatar_url = "https://www.gravatar.com/avatar/%s?s=40" % email_md5 @@ -93,7 +95,7 @@ class MainHandler(BaseHandler): self.render("index.html", user=json.dumps(user)) -class QueryFormatHandler(BaseHandler): +class QueryFormatHandler(BaseAuthenticatedHandler): def post(self): arguments = json.loads(self.request.body) query = arguments.get("query", "") @@ -101,7 +103,7 @@ class QueryFormatHandler(BaseHandler): self.write(sqlparse.format(query, reindent=True, keyword_case='upper')) -class StatusHandler(BaseHandler): +class StatusHandler(BaseAuthenticatedHandler): def get(self): status = {} info = self.redis_connection.info() @@ -122,7 +124,7 @@ class StatusHandler(BaseHandler): self.write_json(status) -class WidgetsHandler(BaseHandler): +class WidgetsHandler(BaseAuthenticatedHandler): def post(self, widget_id=None): widget_properties = json.loads(self.request.body) widget_properties['options'] = json.dumps(widget_properties['options']) @@ -162,7 +164,7 @@ class WidgetsHandler(BaseHandler): widget.delete() -class DashboardHandler(BaseHandler): +class DashboardHandler(BaseAuthenticatedHandler): def get(self, dashboard_slug=None): if dashboard_slug: dashboard = data.models.Dashboard.objects.prefetch_related('widgets__query__latest_query_data').get(slug=dashboard_slug) @@ -195,7 +197,7 @@ class DashboardHandler(BaseHandler): dashboard.save() -class QueriesHandler(BaseHandler): +class QueriesHandler(BaseAuthenticatedHandler): def post(self, id=None): query_def = json.loads(self.request.body) if 'created_at' in query_def: @@ -226,7 +228,7 @@ class QueriesHandler(BaseHandler): self.write_json([q.to_dict(with_result=False, with_stats=True) for q in data.models.Query.all_queries()]) -class QueryResultsHandler(BaseHandler): +class QueryResultsHandler(BaseAuthenticatedHandler): def get(self, query_result_id): query_result = self.data_manager.get_query_result_by_id(query_result_id) if query_result: @@ -249,21 +251,7 @@ class QueryResultsHandler(BaseHandler): self.write({'job': job.to_dict()}) -class JobsHandler(BaseHandler): - def get(self, job_id=None): - if job_id: - # TODO: if finished, include the query result - job = data.Job.load(self.data_manager.redis_connection, job_id) - self.write({'job': job.to_dict()}) - else: - raise NotImplemented - - def delete(self, job_id): - job = data.Job.load(self.data_manager.redis_connection, job_id) - job.cancel() - - -class CsvQueryResultsHandler(BaseHandler): +class CsvQueryResultsHandler(BaseAuthenticatedHandler): def get(self, query_result_id): query_result = self.data_manager.get_query_result_by_id(query_result_id) if query_result: @@ -286,6 +274,20 @@ class CsvQueryResultsHandler(BaseHandler): self.send_error(404) +class JobsHandler(BaseAuthenticatedHandler): + def get(self, job_id=None): + if job_id: + # TODO: if finished, include the query result + job = data.Job.load(self.data_manager.redis_connection, job_id) + self.write({'job': job.to_dict()}) + else: + raise NotImplemented + + def delete(self, job_id): + job = data.Job.load(self.data_manager.redis_connection, job_id) + job.cancel() + + def get_application(static_path, is_debug, redis_connection, data_manager): return tornado.web.Application([(r"/", MainHandler), (r"/ping", PingHandler), From 52888c47246ec02da6662949fcf282deeef58293 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sat, 4 Jan 2014 11:09:13 +0200 Subject: [PATCH 089/540] Add api_key field to queries. --- rd_service/data/models.py | 10 ++++++++++ rd_service/data/tables.sql | 1 + 2 files changed, 11 insertions(+) diff --git a/rd_service/data/models.py b/rd_service/data/models.py index 9559e8ad6..58c9e9850 100644 --- a/rd_service/data/models.py +++ b/rd_service/data/models.py @@ -1,7 +1,9 @@ """ Django ORM based models to describe the data model of re:dash. """ +import hashlib import json +import time from django.db import models from django.template.defaultfilters import slugify import utils @@ -40,6 +42,7 @@ class Query(models.Model): description = models.CharField(max_length=4096) query = models.TextField() query_hash = models.CharField(max_length=32) + api_key = models.CharField(max_length=40) ttl = models.IntegerField() user = models.CharField(max_length=360) created_at = models.DateTimeField(auto_now_add=True) @@ -58,6 +61,7 @@ class Query(models.Model): 'query_hash': self.query_hash, 'ttl': self.ttl, 'user': self.user, + 'api_key': self.api_key, 'created_at': self.created_at, } @@ -92,8 +96,14 @@ LEFT OUTER JOIN def save(self, *args, **kwargs): self.query_hash = utils.gen_query_hash(self.query) + self._set_api_key() super(Query, self).save(*args, **kwargs) + def _set_api_key(self): + if not self.api_key: + self.api_key = hashlib.sha1( + u''.join([str(time.time()), self.query, self.user, self.name])).hexdigest() + def __unicode__(self): return unicode(self.id) diff --git a/rd_service/data/tables.sql b/rd_service/data/tables.sql index b2b77a692..fa6b941ab 100644 --- a/rd_service/data/tables.sql +++ b/rd_service/data/tables.sql @@ -15,6 +15,7 @@ CREATE TABLE "queries" ( "description" varchar(4096), "query" text NOT NULL, "query_hash" varchar(32) NOT NULL, + "api_key" varchar(40), "ttl" integer NOT NULL, "user" varchar(360) NOT NULL, "created_at" timestamp with time zone NOT NULL From b1f9995ce20fc59f196ef059ea271127029c78e3 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sat, 4 Jan 2014 11:21:52 +0200 Subject: [PATCH 090/540] Allow downloading CSV by query id and not just query_result id. --- rd_service/server.py | 11 ++++++++--- rd_ui/app/scripts/controllers.js | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/rd_service/server.py b/rd_service/server.py index 7d9d39599..ffc1b78b8 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -252,8 +252,13 @@ class QueryResultsHandler(BaseAuthenticatedHandler): class CsvQueryResultsHandler(BaseAuthenticatedHandler): - def get(self, query_result_id): - query_result = self.data_manager.get_query_result_by_id(query_result_id) + def get(self, query_id, result_id=None): + if not result_id: + query = data.models.Query.objects.get(pk=query_id) + if query: + result_id = query.latest_query_data_id + + query_result = result_id and self.data_manager.get_query_result_by_id(result_id) if query_result: self.set_header("Content-Type", "text/csv; charset=UTF-8") s = cStringIO.StringIO() @@ -291,10 +296,10 @@ class JobsHandler(BaseAuthenticatedHandler): def get_application(static_path, is_debug, redis_connection, data_manager): return tornado.web.Application([(r"/", MainHandler), (r"/ping", PingHandler), + (r"/api/queries/([0-9]*)/results(?:/([0-9]*))?.csv", CsvQueryResultsHandler), (r"/api/queries/format", QueryFormatHandler), (r"/api/queries(?:/([0-9]*))?", QueriesHandler), (r"/api/query_results(?:/([0-9]*))?", QueryResultsHandler), - (r"/api/query_results/(.*?).csv", CsvQueryResultsHandler), (r"/api/jobs/(.*)", JobsHandler), (r"/api/widgets(?:/([0-9]*))?", WidgetsHandler), (r"/api/dashboards(?:/(.*))?", DashboardHandler), diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index aca4c7862..ea567d091 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -139,7 +139,7 @@ if ($scope.queryResult.getId() == null) { $scope.dataUri = ""; } else { - $scope.dataUri = '/api/query_results/' + $scope.queryResult.getId() + '.csv'; + $scope.dataUri = '/api/queries/' + $scope.query.id + '/results/' + $scope.queryResult.getId() + '.csv'; $scope.dataFilename = $scope.query.name.replace(" ", "_") + moment($scope.queryResult.getUpdatedAt()).format("_YYYY_MM_DD") + ".csv"; } }); From 36e5df00ab6883a76ece695b6520eda35e50b5db Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sat, 4 Jan 2014 11:53:45 +0200 Subject: [PATCH 091/540] Allow downloading CSV for unauthenticated users with api_key. --- rd_service/server.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/rd_service/server.py b/rd_service/server.py index ffc1b78b8..d74d38dee 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -252,6 +252,17 @@ class QueryResultsHandler(BaseAuthenticatedHandler): class CsvQueryResultsHandler(BaseAuthenticatedHandler): + def get_current_user(self): + user = super(CsvQueryResultsHandler, self).get_current_user() + if not user: + api_key = self.get_argument("api_key", None) + query = data.models.Query.objects.get(pk=self.path_args[0]) + + if query.api_key and query.api_key == api_key: + user = "API-Key=%s" % api_key + + return user + def get(self, query_id, result_id=None): if not result_id: query = data.models.Query.objects.get(pk=query_id) From ba838863bf6912033a5c930b8231c32f3a7f43a1 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sat, 11 Jan 2014 20:04:12 +0200 Subject: [PATCH 092/540] Fix: don't show save warning, when switching tabs. --- rd_ui/app/scripts/controllers.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index ea567d091..2f64e4993 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -40,6 +40,10 @@ } $scope.$on('$locationChangeStart', function(event, next, current) { + if (next.split("#")[0] == current.split("#")[0]) { + return; + } + if($scope.queryChanged && !confirm(leavingPageText + "\n\nAre you sure you want to leave this page?")) { event.preventDefault(); From 5ba32cfdc1d72de365dff868b8ccbd4145433a8a Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sat, 11 Jan 2014 20:04:27 +0200 Subject: [PATCH 093/540] Save query when pressing Cmd+S. --- rd_ui/app/index.html | 2 ++ rd_ui/app/scripts/controllers.js | 7 +++++++ rd_ui/bower.json | 3 ++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index 71b1db3a2..22b3c2deb 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -101,6 +101,8 @@ + + diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 2f64e4993..2014f3678 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -39,6 +39,11 @@ } } + Mousetrap.bindGlobal("meta+s", function(e) { + e.preventDefault(); + $scope.saveQuery(); + }); + $scope.$on('$locationChangeStart', function(event, next, current) { if (next.split("#")[0] == current.split("#")[0]) { return; @@ -48,6 +53,8 @@ !confirm(leavingPageText + "\n\nAre you sure you want to leave this page?")) { event.preventDefault(); } + + Mousetrap.unbind("meta+s"); }); $scope.$parent.pageTitle = "Query Fiddle"; diff --git a/rd_ui/bower.json b/rd_ui/bower.json index 9792acc10..efb967be8 100644 --- a/rd_ui/bower.json +++ b/rd_ui/bower.json @@ -17,7 +17,8 @@ "angular-growl": "0.3.1", "pivottable": "git@github.com:arikfr/pivottable.git#master", "cornelius": "git@github.com:restorando/cornelius.git", - "gridster": "0.2.0" + "gridster": "0.2.0", + "mousetrap": "~1.4.6" }, "devDependencies": { "angular-mocks": "~1.0.7", From 447f1fab1f10c8177688e3828ad6cec18fac3308 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 13 Jan 2014 16:52:35 +0200 Subject: [PATCH 094/540] Add the option to specify analytics code to inject into the template. --- rd_service/server.py | 2 +- rd_service/settings_example.py | 1 + rd_ui/app/index.html | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/rd_service/server.py b/rd_service/server.py index d74d38dee..306fbfa08 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -92,7 +92,7 @@ class MainHandler(BaseAuthenticatedHandler): 'name': self.current_user } - self.render("index.html", user=json.dumps(user)) + self.render("index.html", user=json.dumps(user), analytics=settings.ANALYTICS) class QueryFormatHandler(BaseAuthenticatedHandler): diff --git a/rd_service/settings_example.py b/rd_service/settings_example.py index af8c3d04e..01511ec98 100644 --- a/rd_service/settings_example.py +++ b/rd_service/settings_example.py @@ -22,6 +22,7 @@ WORKERS_COUNT = 2 MAX_CONNECTIONS = 3 COOKIE_SECRET = "c292a0a3aa32397cdb050e233733900f" LOG_LEVEL = "INFO" +ANALYTICS = "" # Configuration of the operational database for the Django models django.conf.settings.configure(DATABASES = { 'default': { diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index 22b3c2deb..d6ffd6f8e 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -119,6 +119,8 @@ From deeb113cdd61c5c537b1257570aa37c2e81fc018 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 14 Jan 2014 09:01:20 +0200 Subject: [PATCH 095/540] Add canEdit function to user object. Checks if currentUser included in the user string the provided object. The reason I check inclusion and not equality, is to support scenario of multiple users. --- rd_ui/app/index.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index d6ffd6f8e..85acc93a6 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -120,6 +120,10 @@ From f0169978d43719bf5ea809027ec6b0ca9a82d752 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 14 Jan 2014 09:01:42 +0200 Subject: [PATCH 096/540] Only save query if the user can edit it. --- rd_ui/app/scripts/controllers.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 2014f3678..bbe54c26f 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -41,7 +41,10 @@ Mousetrap.bindGlobal("meta+s", function(e) { e.preventDefault(); - $scope.saveQuery(); + + if (currentUser.canEdit($scope.query)) { + $scope.saveQuery(); + } }); $scope.$on('$locationChangeStart', function(event, next, current) { From 33fec327a96a2df3c63832fd8e9d859866afdbd5 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 14 Jan 2014 09:02:10 +0200 Subject: [PATCH 097/540] Only unbind save shortcut if we're actually leaving the page. --- rd_ui/app/scripts/controllers.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index bbe54c26f..22efe20b2 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -55,9 +55,9 @@ if($scope.queryChanged && !confirm(leavingPageText + "\n\nAre you sure you want to leave this page?")) { event.preventDefault(); + } else { + Mousetrap.unbind("meta+s"); } - - Mousetrap.unbind("meta+s"); }); $scope.$parent.pageTitle = "Query Fiddle"; From e928a29b9851d99aa36578f3d046cc64ef6a0b46 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 14 Jan 2014 09:03:04 +0200 Subject: [PATCH 098/540] Show warning of leaving the page only if the user can edit the query. --- rd_ui/app/scripts/controllers.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 22efe20b2..bb251baab 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -34,7 +34,7 @@ var pristineQuery = null; $window.onbeforeunload = function(){ - if ($scope.queryChanged) { + if (currentUser.canEdit($scope.query) && $scope.queryChanged) { return leavingPageText; } } @@ -52,6 +52,10 @@ return; } + if (!currentUser.canEdit($scope.query)) { + return; + } + if($scope.queryChanged && !confirm(leavingPageText + "\n\nAre you sure you want to leave this page?")) { event.preventDefault(); From 4102cab43bfbaa49f578c76291642e183e459c44 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 14 Jan 2014 09:11:19 +0200 Subject: [PATCH 099/540] use currentUser.canEdit in more places --- rd_ui/app/index.html | 2 +- rd_ui/app/scripts/services/dashboards.js | 2 +- rd_ui/app/views/queryfiddle.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index 85acc93a6..37cdbaecc 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -121,7 +121,7 @@ var currentUser = {% raw user %}; currentUser.canEdit = function(object) { - return object.user.indexOf(currentUser.name) != -1; + return object.user && (object.user.indexOf(currentUser.name) != -1); } {% raw analytics %} diff --git a/rd_ui/app/scripts/services/dashboards.js b/rd_ui/app/scripts/services/dashboards.js index 890c55c6f..1eb0c08e3 100644 --- a/rd_ui/app/scripts/services/dashboards.js +++ b/rd_ui/app/scripts/services/dashboards.js @@ -2,7 +2,7 @@ var Dashboard = function($resource) { var resource = $resource('/api/dashboards/:slug', {slug: '@slug'}); resource.prototype.canEdit = function() { - return currentUser.is_admin || currentUser.name == this.user; + return currentUser.is_admin || currentUser.canEdit(this); } return resource; } diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 3614ddb1a..87944a35b 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -17,7 +17,7 @@
- From a35df2aed8e9e801e3930f4589c9ed5b69e026eb Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 14 Jan 2014 09:24:17 +0200 Subject: [PATCH 100/540] Fix: homepage was skipping dashboards in other group --- rd_ui/app/scripts/controllers.js | 6 +++--- rd_ui/app/views/index.html | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index bb251baab..f5ebf00e3 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -324,15 +324,15 @@ $scope.reloadDashboards = function() { Dashboard.query(function (dashboards) { $scope.dashboards = _.sortBy(dashboards, "name"); - $scope.groupedDashboards = _.groupBy($scope.dashboards, function(d) { + $scope.allDashboards = _.groupBy($scope.dashboards, function(d) { parts = d.name.split(":"); if (parts.length == 1) { return "Other"; } return parts[0]; }); - $scope.otherDashboards = $scope.groupedDashboards['Other'] || []; - delete $scope.groupedDashboards['Other']; + $scope.otherDashboards = $scope.allDashboards['Other'] || []; + $scope.groupedDashboards = _.omit($scope.allDashboards, 'Other'); }); } diff --git a/rd_ui/app/views/index.html b/rd_ui/app/views/index.html index 2f7342043..8db531646 100644 --- a/rd_ui/app/views/index.html +++ b/rd_ui/app/views/index.html @@ -1,6 +1,6 @@

Dashboards

-
+
{{name}} From c16c0dd454a93c57e04133b15989d2e33777de39 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 14 Jan 2014 09:32:23 +0200 Subject: [PATCH 101/540] Add semicolon --- rd_ui/app/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index 37cdbaecc..e56c886a1 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -122,7 +122,7 @@ currentUser.canEdit = function(object) { return object.user && (object.user.indexOf(currentUser.name) != -1); - } + }; {% raw analytics %} From 7e197d2a574a79c7fb26c195a7c016d1a36254b4 Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Tue, 7 Jan 2014 11:28:13 +0200 Subject: [PATCH 102/540] upgrade: angular v1.2.7 --- .gitignore | 1 + rd_ui/app/index.html | 1 + rd_ui/app/scripts/app.js | 5 +++-- rd_ui/app/scripts/controllers.js | 2 +- rd_ui/bower.json | 5 +++-- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 03cbf8cb6..059ee4bdd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ rd_ui/dist # Vagrant related .vagrant Berksfile.lock +rd_service/dump.rdb diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index e56c886a1..d4877bbbb 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -83,6 +83,7 @@ + diff --git a/rd_ui/app/scripts/app.js b/rd_ui/app/scripts/app.js index 9835d089b..04718e989 100644 --- a/rd_ui/app/scripts/app.js +++ b/rd_ui/app/scripts/app.js @@ -11,11 +11,12 @@ angular.module('redash', [ 'angularMoment', 'ui.bootstrap', 'smartTable.table', - 'ngResource' + 'ngResource', + 'ngRoute' ]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider', function($routeProvider, $locationProvider, $compileProvider, growlProvider) { - $compileProvider.urlSanitizationWhitelist(/^\s*(https?|http|data):/); + $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|http|data):/); $locationProvider.html5Mode(true); growlProvider.globalTimeToLive(2000); diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index f5ebf00e3..4a62a1096 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -316,7 +316,7 @@ } filterQueries(); - }) + }); } var MainCtrl = function ($scope, Dashboard, notifications) { diff --git a/rd_ui/bower.json b/rd_ui/bower.json index efb967be8..321f39913 100644 --- a/rd_ui/bower.json +++ b/rd_ui/bower.json @@ -2,7 +2,7 @@ "name": "rdUi", "version": "0.1.0", "dependencies": { - "angular": "1.0.8", + "angular": "1.2.7", "json3": "3.2.4", "jquery": "1.9.1", "bootstrap": "3.0.0", @@ -15,6 +15,7 @@ "underscore": "1.5.1", "angular-resource": "1.0.7", "angular-growl": "0.3.1", + "angular-route": "1.2.7", "pivottable": "git@github.com:arikfr/pivottable.git#master", "cornelius": "git@github.com:restorando/cornelius.git", "gridster": "0.2.0", @@ -25,7 +26,7 @@ "angular-scenario": "~1.0.7" }, "resolutions": { - "angular": "1.0.8", + "angular": "~1.2.7", "jquery": "~1.9.1" } } From 4a1563365db1938e5bd8ae4b4134ad066ad5fa61 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sat, 11 Jan 2014 14:50:49 +0200 Subject: [PATCH 103/540] Fix: default predicate should be undefined and not empty string --- rd_ui/app/scripts/smart-table.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rd_ui/app/scripts/smart-table.js b/rd_ui/app/scripts/smart-table.js index 33249a5e6..a4706602f 100644 --- a/rd_ui/app/scripts/smart-table.js +++ b/rd_ui/app/scripts/smart-table.js @@ -15,7 +15,7 @@ sortPredicate: '', formatFunction: '', formatParameter: '', - filterPredicate: '', + filterPredicate: undefined, cellTemplateUrl: '', headerClass: '', cellClass: '' @@ -184,7 +184,7 @@ replace: false, link: function (scope, element, attr, ctrl) { - scope.searchValue = ''; + scope.searchValue = undefined; scope.$watch('searchValue', function (value) { //todo perf improvement only filter on blur ? @@ -464,14 +464,13 @@ * @param column */ this.search = function (input, column) { - //update column and global predicate if (column && scope.columns.indexOf(column) !== -1) { predicate.$ = ''; column.filterPredicate = input; } else { for (var j = 0, l = scope.columns.length; j < l; j++) { - scope.columns[j].filterPredicate = ''; + scope.columns[j].filterPredicate = undefined; } predicate.$ = input; } From aa7d066f6ca7162d08066ff893602eba107a5d18 Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Tue, 14 Jan 2014 16:42:12 +0200 Subject: [PATCH 104/540] Add ability to edit query description (FED #22) --- rd_ui/app/scripts/directives.js | 10 ++++++++-- rd_ui/app/styles/redash.css | 19 +++++++++++++------ rd_ui/app/views/queryfiddle.html | 15 ++++++++++++++- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index 9d739b46d..d624abed3 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -162,10 +162,16 @@ directives.directive('editInPlace', function () { value: '=', ignoreBlanks: '=' }, - template: '', + template: function(tElement, tAttrs) { + var elType = tAttrs.editor || 'input'; + var placeholder = tAttrs.placeholder || 'Click to edit'; + return '' + + '' + placeholder + '' + + '<{elType} ng-model="value">'.replace('{elType}', elType); + }, link: function ($scope, element, attrs) { // Let's get a reference to the input element, as we'll want to reference it. - var inputElement = angular.element(element.children()[1]); + var inputElement = angular.element(element.children()[2]); // This directive should have a set class so we can style it. element.addClass('edit-in-place'); diff --git a/rd_ui/app/styles/redash.css b/rd_ui/app/styles/redash.css index e935e6b33..1abdcd40b 100644 --- a/rd_ui/app/styles/redash.css +++ b/rd_ui/app/styles/redash.css @@ -23,19 +23,26 @@ a.navbar-brand { } .edit-in-place span { - cursor: pointer; + cursor: pointer; } -.edit-in-place input { - display: none; +.edit-in-place input, +.edit-in-place textarea { + display: none; +} + +.edit-in-place textarea { + height: 80px; + width: 250px; } .edit-in-place.active span { - display: none; + display: none; } -.edit-in-place.active input { - display: inline-block; +.edit-in-place.active input, +.edit-in-place.active textarea { + display: inline-block; } .delete-button { diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 87944a35b..81a00f617 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -3,7 +3,20 @@

- +
+
+

+ +

+
+
+
+
+

+ +

+
+

From ad97b7402653ab3a442faac23769e19d190a7c14 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 15 Jan 2014 11:15:35 +0200 Subject: [PATCH 105/540] Change look of edit in place text area. --- rd_ui/app/scripts/directives.js | 2 +- rd_ui/app/styles/redash.css | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index d624abed3..80fbf7abd 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -167,7 +167,7 @@ directives.directive('editInPlace', function () { var placeholder = tAttrs.placeholder || 'Click to edit'; return '' + '' + placeholder + '' + - '<{elType} ng-model="value">'.replace('{elType}', elType); + '<{elType} ng-model="value" class="form-control" rows="2">'.replace('{elType}', elType); }, link: function ($scope, element, attrs) { // Let's get a reference to the input element, as we'll want to reference it. diff --git a/rd_ui/app/styles/redash.css b/rd_ui/app/styles/redash.css index 1abdcd40b..82a86f894 100644 --- a/rd_ui/app/styles/redash.css +++ b/rd_ui/app/styles/redash.css @@ -26,16 +26,15 @@ a.navbar-brand { cursor: pointer; } +.edit-in-place span:hover { + background: yellow; +} + .edit-in-place input, .edit-in-place textarea { display: none; } -.edit-in-place textarea { - height: 80px; - width: 250px; -} - .edit-in-place.active span { display: none; } From e028dfe2f9ad8960f52aefe3bcea6efe91ee00d7 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 15 Jan 2014 11:38:30 +0200 Subject: [PATCH 106/540] Show edit-in-place only if the user can edit. --- rd_ui/app/views/queryfiddle.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 81a00f617..e8f1dcf91 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -4,17 +4,17 @@

-
-

- -

+
+

+ + +

-
-

- -

+
+ +

From b8fb6200f0e8f66a0cafb2c0e1d1509e5697fb4e Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 15 Jan 2014 11:38:38 +0200 Subject: [PATCH 107/540] Show description in dashboard. --- rd_ui/app/scripts/controllers.js | 8 ++++++-- rd_ui/app/views/dashboard.html | 22 ++++++++++++---------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 4a62a1096..87e05b3f7 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -5,7 +5,7 @@ }); }; - var WidgetCtrl = function ($scope, $http, Query) { + var WidgetCtrl = function ($scope, $http, $location, Query) { $scope.deleteWidget = function() { if (!confirm('Are you sure you want to remove "' + $scope.widget.query.name + '" from the dashboard?')) { return; @@ -18,6 +18,10 @@ }) }); }); + }; + + $scope.open = function(query) { + $location.path('/queries/' + query.id); } $scope.query = new Query($scope.widget.query); @@ -363,7 +367,7 @@ angular.module('redash.controllers', []) .controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl]) - .controller('WidgetCtrl', ['$scope', '$http', 'Query', WidgetCtrl]) + .controller('WidgetCtrl', ['$scope', '$http', '$location', 'Query', WidgetCtrl]) .controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl]) .controller('QueryFiddleCtrl', ['$scope', '$window', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', QueryFiddleCtrl]) .controller('IndexCtrl', ['$scope', 'Dashboard', IndexCtrl]) diff --git a/rd_ui/app/views/dashboard.html b/rd_ui/app/views/dashboard.html index b16ac3ea3..676ec6d20 100644 --- a/rd_ui/app/views/dashboard.html +++ b/rd_ui/app/views/dashboard.html @@ -21,16 +21,11 @@
-

- - {{query.name}} - - - +

+

+ +

+

@@ -39,6 +34,13 @@
+
From faad6766a8de596b15ff31ae681027dc674e51a4 Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Wed, 15 Jan 2014 13:57:22 +0200 Subject: [PATCH 108/540] edit-in-place: editable attr --- rd_ui/app/scripts/directives.js | 7 ++++--- rd_ui/app/styles/redash.css | 6 +++--- rd_ui/app/views/queryfiddle.html | 20 ++++++-------------- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index 80fbf7abd..c5dc272d6 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -160,13 +160,14 @@ directives.directive('editInPlace', function () { restrict: 'E', scope: { value: '=', - ignoreBlanks: '=' + ignoreBlanks: '=', + editable: '=' }, template: function(tElement, tAttrs) { var elType = tAttrs.editor || 'input'; var placeholder = tAttrs.placeholder || 'Click to edit'; - return '' + - '' + placeholder + '' + + return '' + + '' + placeholder + '' + '<{elType} ng-model="value" class="form-control" rows="2">'.replace('{elType}', elType); }, link: function ($scope, element, attrs) { diff --git a/rd_ui/app/styles/redash.css b/rd_ui/app/styles/redash.css index 82a86f894..e5ba6ace3 100644 --- a/rd_ui/app/styles/redash.css +++ b/rd_ui/app/styles/redash.css @@ -22,12 +22,12 @@ a.navbar-brand { margin-bottom: 5px; } -.edit-in-place span { +.edit-in-place.editable span { cursor: pointer; } -.edit-in-place span:hover { - background: yellow; +.edit-in-place.editable span:hover { + background: #FCFCA2; } .edit-in-place input, diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index e8f1dcf91..f89bcb69f 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -3,21 +3,13 @@

-
-
-

- - -

-
-
-
-
- - -
-
+

+ +

+

+ +

From 9b557657b1740920687fb6375a76f85bfbf6997e Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 15 Jan 2014 14:35:06 +0200 Subject: [PATCH 109/540] Make margin margin after description smaller --- rd_ui/app/styles/redash.css | 4 ++++ rd_ui/app/views/queryfiddle.html | 12 +++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/rd_ui/app/styles/redash.css b/rd_ui/app/styles/redash.css index e5ba6ace3..6f9da253b 100644 --- a/rd_ui/app/styles/redash.css +++ b/rd_ui/app/styles/redash.css @@ -52,6 +52,10 @@ a.navbar-brand { color: white; } +.panel-heading > p:last-child { + margin-bottom: 0px; +} + /* angular-growl */ .growl { position: fixed; diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index f89bcb69f..61b037098 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -4,11 +4,17 @@

- -

+ +

- +

From f7d4c285f5dae4a0c4c45ea523482f66d4db0de6 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 15 Jan 2014 15:05:19 +0200 Subject: [PATCH 110/540] Show link button on dashboard --- rd_ui/app/views/dashboard.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rd_ui/app/views/dashboard.html b/rd_ui/app/views/dashboard.html index 676ec6d20..fa9c5b0c4 100644 --- a/rd_ui/app/views/dashboard.html +++ b/rd_ui/app/views/dashboard.html @@ -39,7 +39,11 @@ tooltip="next update {{nextUpdateTime}} (query runtime: {{queryResult.getRuntime() | durationHumanize}})" tooltip-placement="bottom">Updated: - + + + + +
From 9b6b6a6cd77fbfaf9e76a31a0eebc7f3a36aee2d Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Wed, 15 Jan 2014 16:18:10 +0200 Subject: [PATCH 111/540] edit-in-place: set ng-class inside directive --- rd_ui/app/scripts/directives.js | 4 ++-- rd_ui/app/styles/redash.css | 4 ++-- rd_ui/app/views/queryfiddle.html | 12 +++--------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index c5dc272d6..a49e0476a 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -166,8 +166,8 @@ directives.directive('editInPlace', function () { template: function(tElement, tAttrs) { var elType = tAttrs.editor || 'input'; var placeholder = tAttrs.placeholder || 'Click to edit'; - return '' + - '' + placeholder + '' + + return '' + + '' + placeholder + '' + '<{elType} ng-model="value" class="form-control" rows="2">'.replace('{elType}', elType); }, link: function ($scope, element, attrs) { diff --git a/rd_ui/app/styles/redash.css b/rd_ui/app/styles/redash.css index 6f9da253b..843ccff76 100644 --- a/rd_ui/app/styles/redash.css +++ b/rd_ui/app/styles/redash.css @@ -22,11 +22,11 @@ a.navbar-brand { margin-bottom: 5px; } -.edit-in-place.editable span { +.edit-in-place span.editable { cursor: pointer; } -.edit-in-place.editable span:hover { +.edit-in-place span.editable:hover { background: #FCFCA2; } diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 61b037098..7ab0b32d9 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -4,17 +4,11 @@

- -

+ +

- +

From 01e393cf8c5b821f7f025c44eb676fa3404c8262 Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Thu, 16 Jan 2014 13:44:13 +0200 Subject: [PATCH 112/540] use ngForm in queryFiddle page for detecting pristine and dirty states --- rd_ui/app/scripts/controllers.js | 15 ++---- rd_ui/app/views/queryfiddle.html | 90 ++++++++++++++++---------------- 2 files changed, 50 insertions(+), 55 deletions(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 87e05b3f7..923b6fb85 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -35,10 +35,9 @@ var QueryFiddleCtrl = function ($scope, $window, $routeParams, $http, $location, growl, notifications, Query) { var leavingPageText = "You will lose your changes if you leave"; - var pristineQuery = null; $window.onbeforeunload = function(){ - if (currentUser.canEdit($scope.query) && $scope.queryChanged) { + if (currentUser.canEdit($scope.query) && $scope.queryForm.$dirty) { return leavingPageText; } } @@ -60,7 +59,7 @@ return; } - if($scope.queryChanged && + if($scope.queryForm.$dirty && !confirm(leavingPageText + "\n\nAre you sure you want to leave this page?")) { event.preventDefault(); } else { @@ -92,8 +91,7 @@ } delete $scope.query.latest_query_data; $scope.query.$save(function (q) { - pristineQuery = q.query; - $scope.queryChanged = false; + $scope.queryForm.$setPristine(); if (duplicate) { growl.addInfoMessage("Query duplicated.", {ttl: 2000}); @@ -186,7 +184,7 @@ if ($routeParams.queryId != undefined) { $scope.query = Query.get({id: $routeParams.queryId}, function(q) { - pristineQuery = q.query; + $scope.queryForm.$setPristine(); $scope.queryResult = $scope.query.getQueryResult(); }); } else { @@ -197,11 +195,6 @@ $scope.$watch('query.name', function() { $scope.$parent.pageTitle = $scope.query.name; }); - $scope.$watch('query.query', function(q) { - if (q) { - $scope.queryChanged = (q != pristineQuery); - } - }); $scope.executeQuery = function() { $scope.queryResult = $scope.query.getQueryResult(0); diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 7ab0b32d9..37a5aeaaf 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -1,53 +1,55 @@
-
-
-

+
+
+
+

+

+ +

+

- -

-

-

- -

-
-
- - -
- - Download Data Set - - - -
- - - -
+ +

+
+ -
- -
+
+ + Download Data Set + + -
- Executing query... - -
-
- Query in queue... - -
-
Error running query: {{queryResult.getError()}}
+
+ + + +
+
+ +
+ +
+ +
+ Executing query... + +
+
+ Query in queue... + +
+
Error running query: {{queryResult.getError()}}
+
From 9ce6b81ae0ddfa4f18cb6223d266719b5875b8ce Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Thu, 16 Jan 2014 16:07:51 +0200 Subject: [PATCH 113/540] queryFiddle: reset form state if changes have been reverted to original values (no ngForm) --- rd_ui/app/scripts/controllers.js | 19 ++++++-- rd_ui/app/scripts/services.js | 4 ++ rd_ui/app/views/queryfiddle.html | 82 ++++++++++++++++---------------- 3 files changed, 59 insertions(+), 46 deletions(-) diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 923b6fb85..957f011cb 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -34,10 +34,13 @@ } var QueryFiddleCtrl = function ($scope, $window, $routeParams, $http, $location, growl, notifications, Query) { + var pristineHash = null; + $scope.dirty = undefined; + var leavingPageText = "You will lose your changes if you leave"; $window.onbeforeunload = function(){ - if (currentUser.canEdit($scope.query) && $scope.queryForm.$dirty) { + if (currentUser.canEdit($scope.query) && $scope.dirty) { return leavingPageText; } } @@ -59,7 +62,7 @@ return; } - if($scope.queryForm.$dirty && + if($scope.dirty && !confirm(leavingPageText + "\n\nAre you sure you want to leave this page?")) { event.preventDefault(); } else { @@ -91,7 +94,8 @@ } delete $scope.query.latest_query_data; $scope.query.$save(function (q) { - $scope.queryForm.$setPristine(); + pristineHash = q.getHash(); + $scope.dirty = false; if (duplicate) { growl.addInfoMessage("Query duplicated.", {ttl: 2000}); @@ -184,7 +188,8 @@ if ($routeParams.queryId != undefined) { $scope.query = Query.get({id: $routeParams.queryId}, function(q) { - $scope.queryForm.$setPristine(); + pristineHash = q.getHash(); + $scope.dirty = false; $scope.queryResult = $scope.query.getQueryResult(); }); } else { @@ -196,6 +201,12 @@ $scope.$parent.pageTitle = $scope.query.name; }); + $scope.$watch(function() { + return $scope.query.getHash(); + }, function(newHash) { + $scope.dirty = (newHash !== pristineHash); + }); + $scope.executeQuery = function() { $scope.queryResult = $scope.query.getQueryResult(0); $scope.lockButton(true); diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index ab32673ce..230d2d7e1 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -275,6 +275,10 @@ return queryResult; } + Query.prototype.getHash = function() { + return [this.name, this.description, this.query].join('!#'); + } + return Query; } diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 37a5aeaaf..bcdcec8d4 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -1,55 +1,53 @@
-
-
-
-

-

- -

-

+
+
+

- -

-

-
- + +

+ +

+ +

+
+
+ -
- - Download Data Set - - +
+ + Download Data Set + + -
- - - -
+
+ + +
-
- -
-
- Executing query... -
-
- Query in queue... - + -
Error running query: {{queryResult.getError()}}
- +
+ +
+ Executing query... + +
+
+ Query in queue... + +
+
Error running query: {{queryResult.getError()}}
From 0f5b4887eef121b3cad8d602ea88a998a8b536ec Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 22 Jan 2014 08:56:54 +0200 Subject: [PATCH 114/540] Option to control chart type --- rd_ui/app/scripts/query_fiddle/renderers.js | 7 +++++-- rd_ui/app/views/dashboard.html | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index 36caa05ca..0d92a60fa 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -84,7 +84,9 @@ renderers.directive('chartRenderer', function () { restrict: 'E', scope: { queryResult: '=', - stacking: '&' + stacking: '&', + options: '=?', + type: '&' }, template: "", replace: false, @@ -103,8 +105,9 @@ renderers.directive('chartRenderer', function () { stacking = 'normal'; } + var chart_type = $scope.type() || 'column'; _.each($scope.queryResult.getChartData(), function (s) { - $scope.chartSeries.push(_.extend(s, {'stacking': stacking})); + $scope.chartSeries.push(_.extend(s, {'stacking': stacking, 'type': chart_type}, $scope.options)); }); } }); diff --git a/rd_ui/app/views/dashboard.html b/rd_ui/app/views/dashboard.html index fa9c5b0c4..f19e8dd33 100644 --- a/rd_ui/app/views/dashboard.html +++ b/rd_ui/app/views/dashboard.html @@ -30,7 +30,7 @@
- +
From d4e484fd826c0c07fa6e935e26991f78f87c1fbd Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Wed, 22 Jan 2014 09:27:01 +0200 Subject: [PATCH 115/540] Pass the widget options object as is. --- rd_ui/app/scripts/query_fiddle/renderers.js | 12 ++---------- rd_ui/app/views/dashboard.html | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index 0d92a60fa..bb2fd29f5 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -84,9 +84,7 @@ renderers.directive('chartRenderer', function () { restrict: 'E', scope: { queryResult: '=', - stacking: '&', - options: '=?', - type: '&' + options: '=?' }, template: "", replace: false, @@ -100,14 +98,8 @@ renderers.directive('chartRenderer', function () { } else { $scope.chartSeries.splice(0, $scope.chartSeries.length); - var stacking = null; - if ($scope.stacking() === undefined) { - stacking = 'normal'; - } - - var chart_type = $scope.type() || 'column'; _.each($scope.queryResult.getChartData(), function (s) { - $scope.chartSeries.push(_.extend(s, {'stacking': stacking, 'type': chart_type}, $scope.options)); + $scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}, $scope.options)); }); } }); diff --git a/rd_ui/app/views/dashboard.html b/rd_ui/app/views/dashboard.html index f19e8dd33..6892233c1 100644 --- a/rd_ui/app/views/dashboard.html +++ b/rd_ui/app/views/dashboard.html @@ -30,7 +30,7 @@
- +
From 441f9c677a9339816d1cfa2566ed10bafc8eec32 Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Thu, 23 Jan 2014 14:57:08 +0200 Subject: [PATCH 116/540] QueryFiddle: reset sorting when executing query. fixes #33 --- rd_ui/app/scripts/query_fiddle/renderers.js | 2 +- rd_ui/app/scripts/smart-table.js | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index bb2fd29f5..2fca58a1f 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -123,7 +123,7 @@ renderers.directive('gridRenderer', function () { isPaginationEnabled: true, itemsByPage: $scope.itemsPerPage || 15, maxSize: 8 - } + }; $scope.$watch('queryResult && queryResult.getData()', function (data) { if (!data) { diff --git a/rd_ui/app/scripts/smart-table.js b/rd_ui/app/scripts/smart-table.js index a4706602f..770a64442 100644 --- a/rd_ui/app/scripts/smart-table.js +++ b/rd_ui/app/scripts/smart-table.js @@ -113,8 +113,10 @@ //if item are added or removed into the data model from outside the grid scope.$watch('dataCollection', function (oldValue, newValue) { - if (oldValue !== newValue) { - ctrl.sortBy();//it will trigger the refresh... some hack ? + // evme: + // reset sorting when data updates (executing query again) + if (newValue) { + ctrl.resetSort(); } }); @@ -496,6 +498,12 @@ return scope.isPaginationEnabled ? arrayUtility.fromTo(output, (scope.currentPage - 1) * scope.itemsByPage, scope.itemsByPage) : output; }; + this.resetSort = function() { + lastColumnSort = null; + predicate = {}; + this.sortBy(); + }; + /*//////////// Column API ///////////*/ From 2b13ef1063af8b9faa4f8482e9c2fdd15bd1077a Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Thu, 23 Jan 2014 18:12:44 +0200 Subject: [PATCH 117/540] Dashboard: update layout editor when adding/removing widgets. fixes #9 --- rd_ui/app/scripts/directives.js | 73 ++++++++++++++++++----------- rd_ui/app/views/edit_dashboard.html | 14 ++---- 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index a49e0476a..92cb96060 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -33,35 +33,52 @@ directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Da templateUrl: '/views/edit_dashboard.html', replace: true, link: function($scope, element, attrs) { - $scope.$watch('dashboard.widgets', function() { - if ($scope.dashboard.widgets) { - $scope.layout = []; - _.each($scope.dashboard.widgets, function(row, rowIndex) { - _.each(row, function(widget, colIndex) { - $scope.layout.push({ - id: widget.id, - col: colIndex+1, - row: rowIndex+1, - ySize: 1, - xSize: widget.width, - name: widget.query.name - }) - }) - }); - - $timeout(function () { - $(".gridster ul").gridster({ - widget_margins: [5, 5], - widget_base_dimensions: [260, 100], - min_cols: 2, - max_cols: 2, - serialize_params: function ($w, wgd) { - return { col: wgd.col, row: wgd.row, id: $w.data('widget-id') } - } - }); - }); + var gridster = element.find(".gridster ul").gridster({ + widget_margins: [5, 5], + widget_base_dimensions: [260, 100], + min_cols: 2, + max_cols: 2, + serialize_params: function($w, wgd) { + return { + col: wgd.col, + row: wgd.row, + id: $w.data('widget-id') + } } - }); + }).data('gridster'); + + var gsItemTemplate = '
  • ' + + '
    {name}' + + '
  • '; + + $scope.$watch('dashboard.widgets', function(widgets) { + $timeout(function () { + gridster.remove_all_widgets(); + + if (widgets && widgets.length) { + var layout = []; + + _.each(widgets, function(row, rowIndex) { + _.each(row, function(widget, colIndex) { + layout.push({ + id: widget.id, + col: colIndex+1, + row: rowIndex+1, + ySize: 1, + xSize: widget.width, + name: widget.query.name + }); + }); + }); + + _.each(layout, function(item) { + var el = gsItemTemplate.replace('{id}', item.id).replace('{name}', item.name); + gridster.add_widget(el, item.xSize, item.ySize, item.col, item.row); + + }); + } + }); + }, true); $scope.saveDashboard = function() { $scope.saveInProgress = true; diff --git a/rd_ui/app/views/edit_dashboard.html b/rd_ui/app/views/edit_dashboard.html index f776b0356..77020d99c 100644 --- a/rd_ui/app/views/edit_dashboard.html +++ b/rd_ui/app/views/edit_dashboard.html @@ -10,17 +10,9 @@

    -

    -

    -
      -
    • -
      {{widget.name}}
      -
    • -
    -
    -

    +
    +
      +
      \ No newline at end of file From cab6f9e58d8d2782619061aa5bdb5cede61f42b4 Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Mon, 27 Jan 2014 17:37:41 +0200 Subject: [PATCH 136/540] Visualization models --- rd_service/data/models.py | 30 ++++++++++++++++++++++++++++-- rd_service/data/tables.sql | 11 ++++++++++- rd_service/server.py | 11 ++++++++++- rd_ui/app/scripts/services.js | 10 ++++++++-- 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/rd_service/data/models.py b/rd_service/data/models.py index 58c9e9850..f1b613622 100644 --- a/rd_service/data/models.py +++ b/rd_service/data/models.py @@ -148,10 +148,36 @@ class Dashboard(models.Model): return u"%s=%s" % (self.id, self.name) +class Visualization(models.Model): + id = models.AutoField(primary_key=True) + type = models.CharField(max_length=100) + query = models.ForeignKey(Query, related_name='visualizations') + name = models.CharField(max_length=100) + description = models.CharField(max_length=4096) + options = models.TextField() + + class Meta: + app_label = 'redash' + db_table = 'visualizations' + + def to_dict(self): + return { + 'id': self.id, + 'query': self.query.to_dict(), + 'type': self.type, + 'name': self.name, + 'description': self.description, + 'options': json.loads(self.options), + } + + def __unicode__(self): + return u"%s=>%s" % (self.id, self.query_id) + + class Widget(models.Model): id = models.AutoField(primary_key=True) - query = models.ForeignKey(Query) type = models.CharField(max_length=100) + visualization = models.ForeignKey(Visualization, related_name='widgets') width = models.IntegerField() options = models.TextField() dashboard = models.ForeignKey(Dashboard, related_name='widgets') @@ -163,10 +189,10 @@ class Widget(models.Model): def to_dict(self): return { 'id': self.id, - 'query': self.query.to_dict(), 'type': self.type, 'width': self.width, 'options': json.loads(self.options), + 'visualization_id': self.visualization_id, 'dashboard_id': self.dashboard_id } diff --git a/rd_service/data/tables.sql b/rd_service/data/tables.sql index fa6b941ab..4e1f24ded 100644 --- a/rd_service/data/tables.sql +++ b/rd_service/data/tables.sql @@ -30,12 +30,21 @@ CREATE TABLE "dashboards" ( "is_archived" boolean NOT NULL ) ; +CREATE TABLE "visualizations" ( + "id" serial NOT NULL PRIMARY KEY, + "type" varchar(100) NOT NULL, + "query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED, + "name" varchar(100) NOT NULL, + "description" varchar(4096), + "options" text NOT NULL +) +; CREATE TABLE "widgets" ( "id" serial NOT NULL PRIMARY KEY, - "query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED, "type" varchar(100) NOT NULL, "width" integer NOT NULL, "options" text NOT NULL, + "visualization_id" integer NOT NULL REFERENCES "visualizations" ("id") DEFERRABLE INITIALLY DEFERRED, "dashboard_id" integer NOT NULL REFERENCES "dashboards" ("id") DEFERRABLE INITIALLY DEFERRED ) ; diff --git a/rd_service/server.py b/rd_service/server.py index 306fbfa08..712d29205 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -167,7 +167,7 @@ class WidgetsHandler(BaseAuthenticatedHandler): class DashboardHandler(BaseAuthenticatedHandler): def get(self, dashboard_slug=None): if dashboard_slug: - dashboard = data.models.Dashboard.objects.prefetch_related('widgets__query__latest_query_data').get(slug=dashboard_slug) + dashboard = data.models.Dashboard.objects.prefetch_related('widgets__visualization__query__latest_query_data').get(slug=dashboard_slug) self.write_json(dashboard.to_dict(with_widgets=True)) else: dashboards = [d.to_dict() for d in @@ -251,6 +251,14 @@ class QueryResultsHandler(BaseAuthenticatedHandler): self.write({'job': job.to_dict()}) +class VisualizationHandler(BaseAuthenticatedHandler): + def get(self, id): + pass + + def post(self, id=None): + pass + + class CsvQueryResultsHandler(BaseAuthenticatedHandler): def get_current_user(self): user = super(CsvQueryResultsHandler, self).get_current_user() @@ -312,6 +320,7 @@ def get_application(static_path, is_debug, redis_connection, data_manager): (r"/api/queries(?:/([0-9]*))?", QueriesHandler), (r"/api/query_results(?:/([0-9]*))?", QueryResultsHandler), (r"/api/jobs/(.*)", JobsHandler), + (r"/api/visualizations(?:/([0-9]*))?", VisualizationHandler), (r"/api/widgets(?:/([0-9]*))?", WidgetsHandler), (r"/api/dashboards(?:/(.*))?", DashboardHandler), (r"/admin/(.*)", MainHandler), diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index f0da81acd..90907726e 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -253,7 +253,7 @@ } return QueryResult; - } + }; var Query = function ($resource, QueryResult) { var Query = $resource('/api/queries/:id', {id: '@id'}); @@ -286,10 +286,16 @@ }; return Query; - } + }; + + var Visualization = function($resource) { + var Visualization = $resource('/api/visualizations/:id', {id: '@id'}); + return Visualization; + }; angular.module('redash.services', []) .factory('QueryResult', ['$resource', '$timeout', QueryResult]) .factory('Query', ['$resource', 'QueryResult', Query]) + .factory('Visualization', ['$resource', 'Visualization', Visualization]) })(); From 84df2fb85c3f31420982bea30aa19ad3619ae964 Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Tue, 28 Jan 2014 16:51:19 +0200 Subject: [PATCH 137/540] create Visualization [WIP] --- rd_service/server.py | 17 +++++++++- rd_ui/app/scripts/directives.js | 27 +++++++++------- rd_ui/app/scripts/services.js | 17 +++++++++- rd_ui/app/views/add_visualization.html | 45 +++++++++++++++++--------- rd_ui/app/views/queryfiddle.html | 2 +- 5 files changed, 77 insertions(+), 31 deletions(-) diff --git a/rd_service/server.py b/rd_service/server.py index 712d29205..4d9ab016c 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -256,7 +256,22 @@ class VisualizationHandler(BaseAuthenticatedHandler): pass def post(self, id=None): - pass + kwargs = json.loads(self.request.body) + + if id: + vis = data.models.Visualization(**kwargs) + fields = kwargs.keys() + fields.remove('id') + vis.save(update_fields=fields) + else: + query_id = kwargs.pop('query_id', None) + query = data.models.Query.objects.get(pk=query_id) if query_id else None + kwargs['query'] = query + + vis = data.models.Visualization(**kwargs) + vis.save() + + self.write_json(vis.to_dict(with_result=False)) class CsvQueryResultsHandler(BaseAuthenticatedHandler): diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index ce5cafbd7..24dc2e94d 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -28,31 +28,34 @@ } }]); - directives.directive('addVisulatizationForm', [function() { + directives.directive('addVisulatizationForm', ['Visualization', function(Visualization) { return { restrict: 'E', templateUrl: '/views/add_visualization.html', replace: true, - scope: {}, + scope: { + queryId: "=" + }, link: function(scope) { - scope.visTypes = [ - {type: 'pivot', name: 'Pivot Table'}, - {type: 'cohort', name: 'Cohort'} - ]; + scope.visTypes = _.map(Visualization.prototype.TYPES, function (type) { + return {type: type, name: Visualization.prototype.NAMES[type]}; + }); scope.vis = { - type: 'pivot', - name: null, - config: null + 'query_id': scope.queryId, + 'type': Visualization.prototype.TYPES.PIVOT, + 'name': '', + 'description': '', + 'options': null }; scope.typeChanged = function() { console.log('evme', 'typeChanged'); - scope.vis.config = {}; + scope.vis.options = {}; }; - scope.submit = function() { - alert('Not implemented'); + scope.createVis = function() { + Visualization.save(scope.vis); }; } } diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 90907726e..19dc3925b 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -289,13 +289,28 @@ }; var Visualization = function($resource) { + var PIVOT = 'PIVOT'; + var COHORT = 'COHORT'; + var Visualization = $resource('/api/visualizations/:id', {id: '@id'}); + + Visualization.prototype = { + TYPES: { + 'PIVOT': PIVOT, + 'COHORT': COHORT + }, + NAMES: { + 'PIVOT': 'Pivot Table', + 'COHORT': 'Cohort' + } + }; + return Visualization; }; angular.module('redash.services', []) .factory('QueryResult', ['$resource', '$timeout', QueryResult]) .factory('Query', ['$resource', 'QueryResult', Query]) - .factory('Visualization', ['$resource', 'Visualization', Visualization]) + .factory('Visualization', ['$resource', Visualization]) })(); diff --git a/rd_ui/app/views/add_visualization.html b/rd_ui/app/views/add_visualization.html index 5de8b6ea5..dfb2548ce 100644 --- a/rd_ui/app/views/add_visualization.html +++ b/rd_ui/app/views/add_visualization.html @@ -1,33 +1,46 @@ -
      +
      +
      + + +
      +
      - -
      - -
      -
      - -
      +
      + - -
      - -
      -
      - -
      + +
      +

      + +

      +

      + +

      +
      + +
      +

      + +

      +

      + +

      +
      + +
      - +
      diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 812d81221..b7ef29d5a 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -75,7 +75,7 @@

      Add New Visualization

      - +
      From 8327baa2f677191cfaa22ad54b10213e87ea9b46 Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Wed, 29 Jan 2014 17:41:49 +0200 Subject: [PATCH 138/540] create Visualization cont. --- rd_service/data/models.py | 17 +++++++++++++---- rd_service/server.py | 10 ++++------ rd_ui/app/scripts/controllers.js | 13 ++++++++++--- rd_ui/app/scripts/directives.js | 23 ++++++++++++++--------- rd_ui/app/scripts/services.js | 7 ++++--- rd_ui/app/views/add_visualization.html | 18 +++++++++--------- rd_ui/app/views/queryfiddle.html | 24 ++++++++++++------------ 7 files changed, 66 insertions(+), 46 deletions(-) diff --git a/rd_service/data/models.py b/rd_service/data/models.py index f1b613622..7ca0abfb1 100644 --- a/rd_service/data/models.py +++ b/rd_service/data/models.py @@ -51,7 +51,8 @@ class Query(models.Model): app_label = 'redash' db_table = 'queries' - def to_dict(self, with_result=True, with_stats=False): + def to_dict(self, with_result=True, with_stats=False, + with_visualizations=False): d = { 'id': self.id, 'latest_query_data_id': self.latest_query_data_id, @@ -75,6 +76,10 @@ class Query(models.Model): if with_result and self.latest_query_data_id: d['latest_query_data'] = self.latest_query_data.to_dict() + if with_visualizations: + d['visualizations'] = [vis.to_dict(with_query=False) + for vis in self.visualizations.all()] + return d @classmethod @@ -160,16 +165,20 @@ class Visualization(models.Model): app_label = 'redash' db_table = 'visualizations' - def to_dict(self): - return { + def to_dict(self, with_query=True): + d = { 'id': self.id, - 'query': self.query.to_dict(), 'type': self.type, 'name': self.name, 'description': self.description, 'options': json.loads(self.options), } + if with_query: + d['query'] = self.query.to_dict() + + return d + def __unicode__(self): return u"%s=>%s" % (self.id, self.query_id) diff --git a/rd_service/server.py b/rd_service/server.py index 4d9ab016c..75fcbf16b 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -204,6 +204,7 @@ class QueriesHandler(BaseAuthenticatedHandler): query_def['created_at'] = dateutil.parser.parse(query_def['created_at']) query_def.pop('latest_query_data', None) + query_def.pop('visualizations', None) if id: query = data.models.Query(**query_def) @@ -221,7 +222,7 @@ class QueriesHandler(BaseAuthenticatedHandler): if id: q = data.models.Query.objects.get(pk=id) if q: - self.write_json(q.to_dict()) + self.write_json(q.to_dict(with_visualizations=True)) else: self.send_error(404) else: @@ -257,6 +258,7 @@ class VisualizationHandler(BaseAuthenticatedHandler): def post(self, id=None): kwargs = json.loads(self.request.body) + kwargs['options'] = json.dumps(kwargs['options']) if id: vis = data.models.Visualization(**kwargs) @@ -264,14 +266,10 @@ class VisualizationHandler(BaseAuthenticatedHandler): fields.remove('id') vis.save(update_fields=fields) else: - query_id = kwargs.pop('query_id', None) - query = data.models.Query.objects.get(pk=query_id) if query_id else None - kwargs['query'] = query - vis = data.models.Visualization(**kwargs) vis.save() - self.write_json(vis.to_dict(with_result=False)) + self.write_json(vis.to_dict()) class CsvQueryResultsHandler(BaseAuthenticatedHandler): diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index b00d54071..24540519c 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -72,9 +72,11 @@ $scope.$parent.pageTitle = "Query Fiddle"; - $scope.tabs = [{'key': 'table', 'name': 'Table'}, {'key': 'chart', 'name': 'Chart'}, - {'key': 'pivot', 'name': 'Pivot Table'}, {'key': 'cohort', 'name': 'Cohort'}, - {'key': 'add', 'name': 'Add Visualization'}]; + // more tabs added when query loads + $scope.tabs = [{'key': 'table', 'name': 'Table'}, + {'key': 'pivot', 'name': 'Pivot Table'}, + {'key': 'add', 'name': 'New Visualization'}]; + $scope.lockButton = function (lock) { $scope.queryExecuting = lock; @@ -192,6 +194,11 @@ pristineHash = q.getHash(); $scope.dirty = false; $scope.queryResult = $scope.query.getQueryResult(); + + _.each(q.visualizations, function(vis) { + $scope.tabs.splice(-1, 0, {'key': 'vis' + vis.id, 'name': vis.name}); + }); + }); } else { $scope.query = new Query({query: "", name: "New Query", ttl: -1, user: currentUser.name}); diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index 24dc2e94d..ebe99bdc7 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -28,25 +28,26 @@ } }]); - directives.directive('addVisulatizationForm', ['Visualization', function(Visualization) { + directives.directive('addVisulatizationForm', ['Visualization', 'growl', function(Visualization, growl) { return { restrict: 'E', templateUrl: '/views/add_visualization.html', replace: true, scope: { - queryId: "=" + query: "=" }, link: function(scope) { - scope.visTypes = _.map(Visualization.prototype.TYPES, function (type) { + scope.visTypes = Visualization.prototype.TYPES; + scope.visOptions = _.map(Visualization.prototype.TYPES, function (type) { return {type: type, name: Visualization.prototype.NAMES[type]}; }); scope.vis = { - 'query_id': scope.queryId, - 'type': Visualization.prototype.TYPES.PIVOT, - 'name': '', - 'description': '', - 'options': null + 'query_id': scope.query.id, + 'type': scope.visTypes.CHART, + 'name': scope.query.name, + 'description': scope.query.description, + 'options': {} }; scope.typeChanged = function() { @@ -55,7 +56,11 @@ }; scope.createVis = function() { - Visualization.save(scope.vis); + Visualization.save(scope.vis, function success(result) { + scope.vis = result; + }, function error() { + growl.addErrorMessage("Visualization could not be saved"); + }); }; } } diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 19dc3925b..919804208 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -263,6 +263,7 @@ ttl = this.ttl; } + var queryResult = null; if (this.latest_query_data && ttl != 0) { queryResult = new QueryResult({'query_result': this.latest_query_data}); @@ -289,18 +290,18 @@ }; var Visualization = function($resource) { - var PIVOT = 'PIVOT'; + var CHART = 'CHART'; var COHORT = 'COHORT'; var Visualization = $resource('/api/visualizations/:id', {id: '@id'}); Visualization.prototype = { TYPES: { - 'PIVOT': PIVOT, + 'CHART': CHART, 'COHORT': COHORT }, NAMES: { - 'PIVOT': 'Pivot Table', + 'CHART': 'Chart', 'COHORT': 'Cohort' } }; diff --git a/rd_ui/app/views/add_visualization.html b/rd_ui/app/views/add_visualization.html index dfb2548ce..5c0c27ab9 100644 --- a/rd_ui/app/views/add_visualization.html +++ b/rd_ui/app/views/add_visualization.html @@ -1,34 +1,34 @@
      - +
      - +
      - +
      - -
      + +

      - +

      - +

      -
      +

      @@ -40,7 +40,7 @@
      - +
      diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index b7ef29d5a..6496eaf2f 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -53,9 +53,6 @@
      -
      - -
      @@ -65,18 +62,21 @@
      -
      - +
      +

      {{vis}}

      -
      -
      -

      Add New Visualization

      -
      -
      - -
      +
      +

      +

      + +
      +
      + + +
      +

      From fdd2cfe1d147281bee35bfc639efad9bf57bb06c Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Thu, 30 Jan 2014 17:33:02 +0200 Subject: [PATCH 139/540] edit and create visualizations --- rd_service/server.py | 2 +- rd_ui/app/scripts/controllers.js | 23 +++---- rd_ui/app/scripts/directives.js | 61 +++++++++++++++---- ...alization.html => edit_visualization.html} | 2 +- rd_ui/app/views/queryfiddle.html | 38 ++++++++---- 5 files changed, 86 insertions(+), 40 deletions(-) rename rd_ui/app/views/{add_visualization.html => edit_visualization.html} (96%) diff --git a/rd_service/server.py b/rd_service/server.py index 75fcbf16b..dbeef29ee 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -269,7 +269,7 @@ class VisualizationHandler(BaseAuthenticatedHandler): vis = data.models.Visualization(**kwargs) vis.save() - self.write_json(vis.to_dict()) + self.write_json(vis.to_dict(with_query=False)) class CsvQueryResultsHandler(BaseAuthenticatedHandler): diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 24540519c..725e0bd2a 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -33,12 +33,14 @@ $scope.updateTime = ''; } - var QueryFiddleCtrl = function ($scope, $window, $routeParams, $http, $location, growl, notifications, Query) { + var QueryFiddleCtrl = function ($scope, $window, $location, $routeParams, $http, $location, growl, notifications, Query) { + var DEFAULT_TAB = 'table'; var pristineHash = null; - $scope.dirty = undefined; - var leavingPageText = "You will lose your changes if you leave"; + $scope.dirty = undefined; + $scope.newVisualization = undefined; + $window.onbeforeunload = function(){ if (currentUser.canEdit($scope.query) && $scope.dirty) { return leavingPageText; @@ -72,11 +74,9 @@ $scope.$parent.pageTitle = "Query Fiddle"; - // more tabs added when query loads - $scope.tabs = [{'key': 'table', 'name': 'Table'}, - {'key': 'pivot', 'name': 'Pivot Table'}, - {'key': 'add', 'name': 'New Visualization'}]; - + $scope.$watch(function() {return $location.hash()}, function(hash) { + $scope.selectedTab = hash || DEFAULT_TAB; + }); $scope.lockButton = function (lock) { $scope.queryExecuting = lock; @@ -194,11 +194,6 @@ pristineHash = q.getHash(); $scope.dirty = false; $scope.queryResult = $scope.query.getQueryResult(); - - _.each(q.visualizations, function(vis) { - $scope.tabs.splice(-1, 0, {'key': 'vis' + vis.id, 'name': vis.name}); - }); - }); } else { $scope.query = new Query({query: "", name: "New Query", ttl: -1, user: currentUser.name}); @@ -381,7 +376,7 @@ .controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl]) .controller('WidgetCtrl', ['$scope', '$http', '$location', 'Query', WidgetCtrl]) .controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl]) - .controller('QueryFiddleCtrl', ['$scope', '$window', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', QueryFiddleCtrl]) + .controller('QueryFiddleCtrl', ['$scope', '$window', '$location', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', QueryFiddleCtrl]) .controller('IndexCtrl', ['$scope', 'Dashboard', IndexCtrl]) .controller('MainCtrl', ['$scope', 'Dashboard', 'notifications', MainCtrl]); })(); diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index ebe99bdc7..ca0a955ff 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -3,6 +3,23 @@ var directives = angular.module('redash.directives', []); + directives.directive('rdTab', ['$location', function($location) { + return { + restrict: 'E', + scope: { + 'id': '@', + 'name': '@' + }, + template: '
    • {{name}}
    • ', + replace: true, + link: function(scope) { + scope.$watch(function(){return scope.$parent.selectedTab}, function(tab) { + scope.selectedTab = tab; + }); + } + } + }]); + directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) { return { restrict: 'E', @@ -28,36 +45,56 @@ } }]); - directives.directive('addVisulatizationForm', ['Visualization', 'growl', function(Visualization, growl) { + directives.directive('editVisulatizationForm', ['Visualization', 'growl', function(Visualization, growl) { return { restrict: 'E', - templateUrl: '/views/add_visualization.html', + templateUrl: '/views/edit_visualization.html', replace: true, scope: { - query: "=" + query: '=', + vis: '=?' }, - link: function(scope) { + link: function(scope, element, attrs) { scope.visTypes = Visualization.prototype.TYPES; scope.visOptions = _.map(Visualization.prototype.TYPES, function (type) { return {type: type, name: Visualization.prototype.NAMES[type]}; }); - scope.vis = { - 'query_id': scope.query.id, - 'type': scope.visTypes.CHART, - 'name': scope.query.name, - 'description': scope.query.description, - 'options': {} - }; + if (!scope.vis) { + // create new visualization + // wait for query to load to populate with defaults + var unwatch = scope.$watch('query', function(q) { + if (q.id) { + unwatch(); + scope.vis = { + 'query_id': q.id, + 'type': scope.visTypes.CHART, + 'name': q.name, + 'description': q.description, + 'options': {} + }; + } + }, true); + } scope.typeChanged = function() { console.log('evme', 'typeChanged'); scope.vis.options = {}; }; - scope.createVis = function() { + scope.submit = function() { Visualization.save(scope.vis, function success(result) { + growl.addSuccessMessage("Visualization saved"); + scope.vis = result; + + var visIds = _.pluck(scope.query.visualizations, 'id'); + var index = visIds.indexOf(result.id); + if (index > -1) { + scope.query.visualizations[index] = result; + } else { + scope.query.visualizations.push(result); + } }, function error() { growl.addErrorMessage("Visualization could not be saved"); }); diff --git a/rd_ui/app/views/add_visualization.html b/rd_ui/app/views/edit_visualization.html similarity index 96% rename from rd_ui/app/views/add_visualization.html rename to rd_ui/app/views/edit_visualization.html index 5c0c27ab9..3c85498df 100644 --- a/rd_ui/app/views/add_visualization.html +++ b/rd_ui/app/views/edit_visualization.html @@ -1,4 +1,4 @@ - +
      diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 6496eaf2f..76dc905e6 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -51,30 +51,44 @@
      - + - -
      +
      -
      +
      -
      -

      {{vis}}

      -
      - -
      +

      - +
      +
      + + +
      +

      +
      +
      + +
      +
      +

      - - + +
      +
      + +

      From c83705119d93d95b5f9860f404a34627ddbebb6a Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Sun, 2 Feb 2014 13:16:20 +0200 Subject: [PATCH 140/540] delete visualizations --- rd_service/server.py | 4 ++++ rd_ui/app/scripts/controllers.js | 21 +++++++++++++++++---- rd_ui/app/scripts/directives.js | 3 ++- rd_ui/app/styles/redash.css | 11 +++++++++++ rd_ui/app/views/queryfiddle.html | 6 ++++-- 5 files changed, 38 insertions(+), 7 deletions(-) diff --git a/rd_service/server.py b/rd_service/server.py index dbeef29ee..5b09079eb 100644 --- a/rd_service/server.py +++ b/rd_service/server.py @@ -271,6 +271,10 @@ class VisualizationHandler(BaseAuthenticatedHandler): self.write_json(vis.to_dict(with_query=False)) + def delete(self, id): + vis = data.models.Visualization.objects.get(pk=id) + vis.delete() + class CsvQueryResultsHandler(BaseAuthenticatedHandler): def get_current_user(self): diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index 725e0bd2a..f7c7ff979 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -33,7 +33,7 @@ $scope.updateTime = ''; } - var QueryFiddleCtrl = function ($scope, $window, $location, $routeParams, $http, $location, growl, notifications, Query) { + var QueryFiddleCtrl = function ($scope, $window, $location, $routeParams, $http, $location, growl, notifications, Query, Visualization) { var DEFAULT_TAB = 'table'; var pristineHash = null; var leavingPageText = "You will lose your changes if you leave"; @@ -214,13 +214,26 @@ $scope.queryResult = $scope.query.getQueryResult(0); $scope.lockButton(true); $scope.cancelling = false; - } + }; $scope.cancelExecution = function() { $scope.cancelling = true; $scope.queryResult.cancelExecution(); - } + }; + $scope.deleteVisualization = function($e, vis) { + $e.preventDefault(); + if (confirm('Are you sure you want to delete ' + vis.name + ' ?')) { + Visualization.delete(vis); + if ($scope.selectedTab == vis.id) { + $scope.selectedTab = DEFAULT_TAB; + } + $scope.query.visualizations = + $scope.query.visualizations.filter(function(v) { + return vis.id !== v.id; + }); + } + }; } var QueriesCtrl = function($scope, $http, $location, $filter, Query) { @@ -376,7 +389,7 @@ .controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl]) .controller('WidgetCtrl', ['$scope', '$http', '$location', 'Query', WidgetCtrl]) .controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl]) - .controller('QueryFiddleCtrl', ['$scope', '$window', '$location', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', QueryFiddleCtrl]) + .controller('QueryFiddleCtrl', ['$scope', '$window', '$location', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', 'Visualization', QueryFiddleCtrl]) .controller('IndexCtrl', ['$scope', 'Dashboard', IndexCtrl]) .controller('MainCtrl', ['$scope', 'Dashboard', 'notifications', MainCtrl]); })(); diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index ca0a955ff..d6b621fe3 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -10,7 +10,8 @@ 'id': '@', 'name': '@' }, - template: '
    • {{name}}
    • ', + transclude: true, + template: '
    • {{name}}
    • ', replace: true, link: function(scope) { scope.$watch(function(){return scope.$parent.selectedTab}, function(tab) { diff --git a/rd_ui/app/styles/redash.css b/rd_ui/app/styles/redash.css index b5301a523..3bc3c751f 100644 --- a/rd_ui/app/styles/redash.css +++ b/rd_ui/app/styles/redash.css @@ -197,4 +197,15 @@ to add those CSS styles here. */ -webkit-border-radius: 6px 0 6px 6px; -moz-border-radius: 6px 0 6px 6px; border-radius: 6px 0 6px 6px; +} + +.rd-tab .remove { + cursor: pointer; + color: #A09797; + padding: 0 3px 1px 4px; +} +.rd-tab .remove:hover { + color: white; + background-color: #FF8080; + border-radius: 50%; } \ No newline at end of file diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 76dc905e6..ee00ffc12 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -54,8 +54,10 @@
      From 1cd836ac8d038b0cbc316b6daacee03402881891 Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Sun, 2 Feb 2014 18:20:18 +0200 Subject: [PATCH 141/540] Live visualization config POC (title only) --- rd_ui/app/scripts/directives.js | 33 ++++++- rd_ui/app/scripts/ng-highchart.js | 98 ++++++++++++--------- rd_ui/app/scripts/query_fiddle/renderers.js | 7 +- rd_ui/app/styles/redash.css | 1 + rd_ui/app/views/edit_visualization.html | 20 +---- rd_ui/app/views/queryfiddle.html | 8 +- 6 files changed, 97 insertions(+), 70 deletions(-) diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index d6b621fe3..aae29c38b 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -61,6 +61,15 @@ return {type: type, name: Visualization.prototype.NAMES[type]}; }); + function getEditableOptions() { + return { + title: { + text: "New Chart" + } + }; + } + + if (!scope.vis) { // create new visualization // wait for query to load to populate with defaults @@ -72,15 +81,14 @@ 'type': scope.visTypes.CHART, 'name': q.name, 'description': q.description, - 'options': {} + 'options': getEditableOptions() }; } }, true); } scope.typeChanged = function() { - console.log('evme', 'typeChanged'); - scope.vis.options = {}; + scope.vis.options = getEditableOptions(); }; scope.submit = function() { @@ -324,6 +332,25 @@ }; }); + // http://stackoverflow.com/a/17904092/1559840 + directives.directive('jsonText', function() { + return { + restrict: 'A', + require: 'ngModel', + link: function(scope, element, attr, ngModel) { + function into(input) { + return JSON.parse(input); + } + function out(data) { + return JSON.stringify(data, undefined, 2); + } + ngModel.$parsers.push(into); + ngModel.$formatters.push(out); + + } + }; + }); + directives.directive('rdTimer', ['$timeout', function ($timeout) { return { restrict: 'E', diff --git a/rd_ui/app/scripts/ng-highchart.js b/rd_ui/app/scripts/ng-highchart.js index b4c445a29..ddd8b347d 100644 --- a/rd_ui/app/scripts/ng-highchart.js +++ b/rd_ui/app/scripts/ng-highchart.js @@ -22,60 +22,72 @@ angular.module('highchart', []) } }; - var deepCopy = true; - var newSettings = {}; - $.extend(deepCopy, newSettings, chartsDefaults, scope.options); + // Update when options change + scope.$watch('options', function(newOptions) { + initChart(newOptions); + }, true); + + //Update when charts data changes + scope.$watch(function () { + return (scope.series && scope.series.length) || 0; + }, function (length) { + if (!length || length == 0) { + scope.chart.showLoading(); + } else { + drawChart(); + }; + }, true); + + function initChart(options) { + if (scope.chart) { + scope.chart.destroy(); + } + + var deepCopy = true; + var newSettings = {}; + $.extend(deepCopy, newSettings, chartsDefaults, options); - // Making sure that the DOM is ready before creating the chart element, so it gets proper width. - $timeout(function(){ scope.chart = new Highcharts.Chart(newSettings); + drawChart(); + } - //Update when charts data changes - scope.$watch(function () { - return (scope.series && scope.series.length) || 0; - }, function (length) { - if (!length || length == 0) { - scope.chart.showLoading(); - } else { - while(scope.chart.series.length > 0) { - scope.chart.series[0].remove(true); - } + function drawChart() { + while(scope.chart.series.length > 0) { + scope.chart.series[0].remove(true); + } - if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) { - scope.chart.xAxis[0].update({type: 'category'}); + if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) { + scope.chart.xAxis[0].update({type: 'category'}); - // We need to make sure that for each category, each series has a value. - var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')})); + // We need to make sure that for each category, each series has a value. + var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')})); - _.each(scope.series, function(s) { - // TODO: move this logic to Query#getChartData - var yValues = _.groupBy(s.data, 'x'); + _.each(scope.series, function(s) { + // TODO: move this logic to Query#getChartData + var yValues = _.groupBy(s.data, 'x'); - var newData = _.sortBy(_.map(categories, function(category) { - return { - name: category, - y: yValues[category] && yValues[category][0].y - } - }), 'name'); + var newData = _.sortBy(_.map(categories, function(category) { + return { + name: category, + y: yValues[category] && yValues[category][0].y + } + }), 'name'); - s.data = newData; - }); - } else { - scope.chart.xAxis[0].update({type: 'datetime'}); - } + s.data = newData; + }); + } else { + scope.chart.xAxis[0].update({type: 'datetime'}); + } - scope.chart.counters.color = 0; + scope.chart.counters.color = 0; - _.each(scope.series, function(s) { - scope.chart.addSeries(s); - }) - - scope.chart.redraw(); - scope.chart.hideLoading(); - }; - }, true); - }); + _.each(scope.series, function(s) { + scope.chart.addSeries(s); + }) + scope.chart.redraw(); + scope.chart.hideLoading(); + } } }; diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index 2fca58a1f..9b44ef1d3 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -90,8 +90,13 @@ renderers.directive('chartRenderer', function () { replace: false, controller: ['$scope', function ($scope) { $scope.chartSeries = []; - $scope.chartOptions = defaultChartOptions; + $scope.chartOptions = $.extend(true, {}, defaultChartOptions); + $scope.$watch('options', function(newOptions) { + if (newOptions) { + $.extend(true, $scope.chartOptions, newOptions); + } + }); $scope.$watch('queryResult && queryResult.getData()', function (data) { if (!data || $scope.queryResult.getData() == null) { $scope.chartSeries.splice(0, $scope.chartSeries.length); diff --git a/rd_ui/app/styles/redash.css b/rd_ui/app/styles/redash.css index 3bc3c751f..f7d75aa9b 100644 --- a/rd_ui/app/styles/redash.css +++ b/rd_ui/app/styles/redash.css @@ -203,6 +203,7 @@ to add those CSS styles here. */ cursor: pointer; color: #A09797; padding: 0 3px 1px 4px; + font-size: 11px; } .rd-tab .remove:hover { color: white; diff --git a/rd_ui/app/views/edit_visualization.html b/rd_ui/app/views/edit_visualization.html index 3c85498df..68ed4eded 100644 --- a/rd_ui/app/views/edit_visualization.html +++ b/rd_ui/app/views/edit_visualization.html @@ -17,25 +17,7 @@
      - -
      -

      - -

      -

      - -

      -
      - - -
      -

      - -

      -

      - -

      -
      +
      diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index ee00ffc12..4a24f6d57 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -75,8 +75,8 @@
      - - + +

      @@ -89,8 +89,8 @@
      - - + +

      From 5ce3699a58382eb926e4b47a23f9ac2db1959c7e Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Mon, 3 Feb 2014 14:54:48 +0200 Subject: [PATCH 142/540] QueryFiddle: Live chart type editing --- rd_ui/app/scripts/directives.js | 46 ++-- rd_ui/app/scripts/ng-highchart.js | 246 +++++++++++++------- rd_ui/app/scripts/query_fiddle/renderers.js | 89 +------ rd_ui/app/scripts/services.js | 13 +- rd_ui/app/styles/redash.css | 4 + rd_ui/app/views/dashboard.html | 2 +- rd_ui/app/views/edit_visualization.html | 18 +- 7 files changed, 220 insertions(+), 198 deletions(-) diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index aae29c38b..93a850afd 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -56,19 +56,15 @@ vis: '=?' }, link: function(scope, element, attrs) { - scope.visTypes = Visualization.prototype.TYPES; - scope.visOptions = _.map(Visualization.prototype.TYPES, function (type) { - return {type: type, name: Visualization.prototype.NAMES[type]}; - }); - - function getEditableOptions() { - return { - title: { - text: "New Chart" - } - }; - } - + scope.advancedMode = false; + scope.visTypes = { + 'Chart': Visualization.prototype.TYPES.CHART, + 'Cohort': Visualization.prototype.TYPES.COHORT + }; + scope.seriesTypes = { + 'Line': Visualization.prototype.SERIES_TYPES.LINE, + 'Bar': Visualization.prototype.SERIES_TYPES.BAR + }; if (!scope.vis) { // create new visualization @@ -78,17 +74,35 @@ unwatch(); scope.vis = { 'query_id': q.id, - 'type': scope.visTypes.CHART, + 'type': Visualization.prototype.TYPES.CHART, 'name': q.name, 'description': q.description, - 'options': getEditableOptions() + 'options': newOptions() }; } }, true); } + function newOptions(chartType) { + if (chartType === Visualization.prototype.TYPES.COHORT) { + // empty config at the moment + return {}; + } + + // Chart + return { + 'series': { + 'type': Visualization.prototype.SERIES_TYPES.LINE + } + }; + } + + scope.toggleAdvancedMode = function() { + scope.advancedMode = !scope.advancedMode; + }; + scope.typeChanged = function() { - scope.vis.options = getEditableOptions(); + scope.vis.options = newOptions(); }; scope.submit = function() { diff --git a/rd_ui/app/scripts/ng-highchart.js b/rd_ui/app/scripts/ng-highchart.js index ddd8b347d..701a23137 100644 --- a/rd_ui/app/scripts/ng-highchart.js +++ b/rd_ui/app/scripts/ng-highchart.js @@ -1,95 +1,181 @@ -'use strict'; +(function(){ + 'use strict'; -angular.module('highchart', []) - .directive('chart', ['$timeout', function ($timeout) { - return { - restrict: 'E', - template: '
      ', - scope: { - options: "=options", - series: "=series" - }, - transclude: true, - replace: true, + var defaultOptions = { + title: { + "text": null + }, + tooltip: { + valueDecimals: 2, + formatter: function () { + if (moment.isMoment(this.x)) { + var s = '' + moment(this.x).format("DD/MM/YY HH:mm") + '', + pointsCount = this.points.length; - link: function (scope, element, attrs) { - var chartsDefaults = { - chart: { - renderTo: element[0], - type: attrs.type || null, - height: attrs.height || null, - width: attrs.width || null - } - }; + $.each(this.points, function (i, point) { + s += '
      ' + point.series.name + ': ' + + Highcharts.numberFormat(point.y); - // Update when options change - scope.$watch('options', function(newOptions) { - initChart(newOptions); - }, true); - - //Update when charts data changes - scope.$watch(function () { - return (scope.series && scope.series.length) || 0; - }, function (length) { - if (!length || length == 0) { - scope.chart.showLoading(); - } else { - drawChart(); - }; - }, true); - - function initChart(options) { - if (scope.chart) { - scope.chart.destroy(); - } - - var deepCopy = true; - var newSettings = {}; - $.extend(deepCopy, newSettings, chartsDefaults, options); - - scope.chart = new Highcharts.Chart(newSettings); - drawChart(); + if (pointsCount > 1 && point.percentage) { + s += " (" + Highcharts.numberFormat(point.percentage) + "%)"; + } + }); + } else { + var s = "" + this.points[0].key + ""; + $.each(this.points, function (i, point) { + s+= '
      ' + point.series.name + ': ' + + Highcharts.numberFormat(point.y); + }); } - function drawChart() { - while(scope.chart.series.length > 0) { - scope.chart.series[0].remove(true); + return s; + }, + shared: true + }, + xAxis: { + type: 'datetime' + }, + yAxis: { + title: { + text: null + } + }, + exporting: { + chartOptions: { + title: { + text: '' + } + }, + buttons: { + contextButton: { + menuItems: [ + { + text: 'Toggle % Stacking', + onclick: function () { + var newStacking = "normal"; + if (this.series[0].options.stacking == "normal") { + newStacking = "percent"; + } + + _.each(this.series, function (series) { + series.update({stacking: newStacking}, true); + }); + } + } + ] + } + } + }, + credits: { + enabled: false + }, + plotOptions: { + "column": { + "stacking": "normal", + "pointPadding": 0, + "borderWidth": 1, + "groupPadding": 0, + "shadow": false + } + }, + series: [] + }; + + angular.module('highchart', []) + .directive('chart', ['$timeout', function ($timeout) { + return { + restrict: 'E', + template: '
      ', + scope: { + options: "=options", + series: "=series" + }, + transclude: true, + replace: true, + + link: function (scope, element, attrs) { + var chartsDefaults = { + chart: { + renderTo: element[0], + type: attrs.type || null, + height: attrs.height || null, + width: attrs.width || null + } + }; + + var chartOptions = $.extend(true, {}, defaultOptions, chartsDefaults); + + // Update when options change + scope.$watch('options', function(newOptions) { + initChart(newOptions); + }, true); + + //Update when charts data changes + scope.$watch(function () { + return (scope.series && scope.series.length) || 0; + }, function (length) { + if (!length || length == 0) { + scope.chart.showLoading(); + } else { + drawChart(); + }; + }, true); + + function initChart(options) { + if (scope.chart) { + scope.chart.destroy(); + } + + $.extend(true, chartOptions, options); + + scope.chart = new Highcharts.Chart(chartOptions); + drawChart(); } - if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) { - scope.chart.xAxis[0].update({type: 'category'}); + function drawChart() { + while(scope.chart.series.length > 0) { + scope.chart.series[0].remove(true); + } - // We need to make sure that for each category, each series has a value. - var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')})); + // todo series.type + + if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) { + scope.chart.xAxis[0].update({type: 'category'}); + + // We need to make sure that for each category, each series has a value. + var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')})); + + _.each(scope.series, function(s) { + // TODO: move this logic to Query#getChartData + var yValues = _.groupBy(s.data, 'x'); + + var newData = _.sortBy(_.map(categories, function(category) { + return { + name: category, + y: yValues[category] && yValues[category][0].y + } + }), 'name'); + + s.data = newData; + }); + } else { + scope.chart.xAxis[0].update({type: 'datetime'}); + } + + scope.chart.counters.color = 0; _.each(scope.series, function(s) { - // TODO: move this logic to Query#getChartData - var yValues = _.groupBy(s.data, 'x'); + // here we override the series with the visualization config + var _s = $.extend(true, {}, s, chartOptions['series']); + scope.chart.addSeries(_s); + }) - var newData = _.sortBy(_.map(categories, function(category) { - return { - name: category, - y: yValues[category] && yValues[category][0].y - } - }), 'name'); - - s.data = newData; - }); - } else { - scope.chart.xAxis[0].update({type: 'datetime'}); + scope.chart.redraw(); + scope.chart.hideLoading(); } - scope.chart.counters.color = 0; - - _.each(scope.series, function(s) { - scope.chart.addSeries(s); - }) - - scope.chart.redraw(); - scope.chart.hideLoading(); } + }; - } - }; - - }]); \ No newline at end of file + }]); +})(); \ No newline at end of file diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index 9b44ef1d3..a654f5876 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -1,83 +1,4 @@ var renderers = angular.module('redash.renderers', []); -var defaultChartOptions = { - "title": { - "text": null - }, - "tooltip": { - valueDecimals: 2, - formatter: function () { - if (moment.isMoment(this.x)) { - var s = '' + moment(this.x).format("DD/MM/YY HH:mm") + '', - pointsCount = this.points.length; - - $.each(this.points, function (i, point) { - s += '
      ' + point.series.name + ': ' + - Highcharts.numberFormat(point.y); - - if (pointsCount > 1 && point.percentage) { - s += " (" + Highcharts.numberFormat(point.percentage) + "%)"; - } - }); - } else { - var s = "" + this.points[0].key + ""; - $.each(this.points, function (i, point) { - s+= '
      ' + point.series.name + ': ' + - Highcharts.numberFormat(point.y); - }); - } - - return s; - }, - shared: true - }, - xAxis: { - type: 'datetime' - }, - yAxis: { - title: { - text: null - } - }, - exporting: { - chartOptions: { - title: { - text: this.description - } - }, - buttons: { - contextButton: { - menuItems: [ - { - text: 'Toggle % Stacking', - onclick: function () { - var newStacking = "normal"; - if (this.series[0].options.stacking == "normal") { - newStacking = "percent"; - } - - _.each(this.series, function (series) { - series.update({stacking: newStacking}, true); - }); - } - } - ] - } - } - }, - credits: { - enabled: false - }, - plotOptions: { - "column": { - "stacking": "normal", - "pointPadding": 0, - "borderWidth": 1, - "groupPadding": 0, - "shadow": false - } - }, - "series": [] -}; renderers.directive('chartRenderer', function () { return { @@ -90,11 +11,11 @@ renderers.directive('chartRenderer', function () { replace: false, controller: ['$scope', function ($scope) { $scope.chartSeries = []; - $scope.chartOptions = $.extend(true, {}, defaultChartOptions); + $scope.chartOptions = {}; - $scope.$watch('options', function(newOptions) { - if (newOptions) { - $.extend(true, $scope.chartOptions, newOptions); + $scope.$watch('options', function(chartOptions) { + if (chartOptions) { + $scope.chartOptions = chartOptions; } }); $scope.$watch('queryResult && queryResult.getData()', function (data) { @@ -104,7 +25,7 @@ renderers.directive('chartRenderer', function () { $scope.chartSeries.splice(0, $scope.chartSeries.length); _.each($scope.queryResult.getChartData(), function (s) { - $scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}, $scope.options)); + $scope.chartSeries.push(_.extend(s, {'stacking': 'normal'})); }); } }); diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 919804208..0fc4ac7aa 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -290,19 +290,16 @@ }; var Visualization = function($resource) { - var CHART = 'CHART'; - var COHORT = 'COHORT'; - var Visualization = $resource('/api/visualizations/:id', {id: '@id'}); Visualization.prototype = { TYPES: { - 'CHART': CHART, - 'COHORT': COHORT + 'CHART': 'CHART', + 'COHORT': 'COHORT' }, - NAMES: { - 'CHART': 'Chart', - 'COHORT': 'Cohort' + SERIES_TYPES: { + 'LINE': 'line', + 'BAR': 'bar' } }; diff --git a/rd_ui/app/styles/redash.css b/rd_ui/app/styles/redash.css index f7d75aa9b..2da630180 100644 --- a/rd_ui/app/styles/redash.css +++ b/rd_ui/app/styles/redash.css @@ -2,6 +2,10 @@ body { padding-top: 70px; } +a.link { + cursor: pointer; +} + a.page-title { overflow: hidden; text-overflow: ellipsis; diff --git a/rd_ui/app/views/dashboard.html b/rd_ui/app/views/dashboard.html index 1fa941d30..c148db147 100644 --- a/rd_ui/app/views/dashboard.html +++ b/rd_ui/app/views/dashboard.html @@ -30,7 +30,7 @@
      - +
      diff --git a/rd_ui/app/views/edit_visualization.html b/rd_ui/app/views/edit_visualization.html index 68ed4eded..a70be6775 100644 --- a/rd_ui/app/views/edit_visualization.html +++ b/rd_ui/app/views/edit_visualization.html @@ -10,22 +10,22 @@
      - - + + +
      + +
      + +
      - - - - + Advanced Mode +
      -
      - {{vis}} -
      From a45ba0bf309d66946d7f93ae9101887d59f30f48 Mon Sep 17 00:00:00 2001 From: Amir Nissim Date: Mon, 3 Feb 2014 16:12:29 +0200 Subject: [PATCH 143/540] Dashboard visualizations --- rd_service/data/models.py | 2 +- rd_ui/app/scripts/controllers.js | 4 +- rd_ui/app/scripts/directives.js | 26 ++++++------ rd_ui/app/scripts/query_fiddle/renderers.js | 16 +++++++ rd_ui/app/scripts/services.js | 6 --- rd_ui/app/views/dashboard.html | 7 +-- rd_ui/app/views/edit_visualization.html | 2 +- rd_ui/app/views/new_widget_form.html | 47 +++++---------------- rd_ui/app/views/queryfiddle.html | 10 ++--- 9 files changed, 50 insertions(+), 70 deletions(-) diff --git a/rd_service/data/models.py b/rd_service/data/models.py index 7ca0abfb1..882144448 100644 --- a/rd_service/data/models.py +++ b/rd_service/data/models.py @@ -201,7 +201,7 @@ class Widget(models.Model): 'type': self.type, 'width': self.width, 'options': json.loads(self.options), - 'visualization_id': self.visualization_id, + 'visualization': self.visualization.to_dict(), 'dashboard_id': self.dashboard_id } diff --git a/rd_ui/app/scripts/controllers.js b/rd_ui/app/scripts/controllers.js index f7c7ff979..9ec09e6e7 100644 --- a/rd_ui/app/scripts/controllers.js +++ b/rd_ui/app/scripts/controllers.js @@ -7,7 +7,7 @@ var WidgetCtrl = function ($scope, $http, $location, Query) { $scope.deleteWidget = function() { - if (!confirm('Are you sure you want to remove "' + $scope.widget.query.name + '" from the dashboard?')) { + if (!confirm('Are you sure you want to remove "' + $scope.widget.visualization.name + '" from the dashboard?')) { return; } @@ -24,7 +24,7 @@ $location.path('/queries/' + query.id); } - $scope.query = new Query($scope.widget.query); + $scope.query = new Query($scope.widget.visualization.query); $scope.queryResult = $scope.query.getQueryResult(); $scope.updateTime = (new Date($scope.queryResult.getUpdatedAt())).toISOString(); diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index 93a850afd..d55913e14 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -70,7 +70,7 @@ // create new visualization // wait for query to load to populate with defaults var unwatch = scope.$watch('query', function(q) { - if (q.id) { + if (q && q.id) { unwatch(); scope.vis = { 'query_id': q.id, @@ -168,7 +168,7 @@ row: rowIndex+1, ySize: 1, xSize: widget.width, - name: widget.query.name + name: widget.visualization.name }); }); }); @@ -230,14 +230,14 @@ templateUrl: '/views/new_widget_form.html', replace: true, link: function($scope, element, attrs) { - $scope.visReady = false; - $scope.currentView = 'existing'; $scope.widgetSizes = [{name: 'Regular', value: 1}, {name: 'Double', value: 2}]; var reset = function() { $scope.saveInProgress = false; $scope.widgetSize = 1; $scope.queryId = null; + $scope.selectedVis = null; + } reset(); @@ -247,18 +247,18 @@ }; $scope.loadVisualizations = function() { + if (!$scope.queryId) { + return; + } + Query.get({ id: $scope.queryId }, function(query) { if (query) { - // TODO - $scope.visReady = true; - var visualizations = query.getVisualizations(); - $scope.existing = visualizations = [ - {'name': 'vis1', 'type': 'Cohort'}, - {'name': 'vis2', 'type': 'Pivot Table'} - ]; - $scope.currentView = (visualizations.length) ? 'existing' : 'addNew'; + $scope.query = query; + if(query.visualizations.length) { + $scope.selectedVis = query.visualizations[0]; + } } }); }; @@ -267,7 +267,7 @@ $scope.saveInProgress = true; var widget = { - 'query_id': $scope.queryId, + 'visualization_id': $scope.selectedVis.id, 'dashboard_id': $scope.dashboard.id, 'options': {}, 'width': $scope.widgetSize diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index a654f5876..9ee3d0502 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -1,5 +1,21 @@ var renderers = angular.module('redash.renderers', []); +renderers.directive('visualizationRenderer', function() { + return { + restrict: 'E', + scope: { + visualization: '=', + queryResult: '=' + }, + template: '
      ' + + '' + + '' + + '' + + '
      ', + replace: false + } +}); + renderers.directive('chartRenderer', function () { return { restrict: 'E', diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 0fc4ac7aa..5356e4595 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -280,12 +280,6 @@ return [this.name, this.description, this.query].join('!#'); }; - Query.prototype.getVisualizations = function() { - // TODO - // not implemented - return []; - }; - return Query; }; diff --git a/rd_ui/app/views/dashboard.html b/rd_ui/app/views/dashboard.html index c148db147..c17800441 100644 --- a/rd_ui/app/views/dashboard.html +++ b/rd_ui/app/views/dashboard.html @@ -29,11 +29,8 @@
      -
      - - - -
      + +