Compare commits

..

62 Commits

Author SHA1 Message Date
Arik Fraimovich
eba2ba1918 Merge pull request #260 from EverythingMe/fix_queue_name
Fix: dashboard filters broken after #252
2014-08-07 08:20:01 +03:00
Arik Fraimovich
59d5ba9273 Use promises to create dashboard filters. 2014-08-06 23:39:30 +03:00
Arik Fraimovich
4aba24a976 Add promise support to QueryResult. 2014-08-06 23:39:09 +03:00
Arik Fraimovich
762c331ddf Merge pull request #259 from EverythingMe/fix_queue_name
Fix events import code
2014-08-06 17:58:28 +03:00
Arik Fraimovich
8b7399ddc9 Fix events import code 2014-08-06 09:31:19 +03:00
Arik Fraimovich
f6221da9dc Merge pull request #256 from EverythingMe/fix_queue_name
Fix: series options not showing up when first running the query.
2014-08-05 12:42:43 +03:00
Arik Fraimovich
10c84d2cd0 Fix: series options not showing up when first running the query. 2014-08-05 12:39:35 +03:00
Arik Fraimovich
60d784d7bc Cleanup Query.prototype.getQueryResult and make sure it caches result by id. 2014-08-05 12:38:53 +03:00
Arik Fraimovich
b28e4be8d7 Sort data sources by id. 2014-08-05 12:30:51 +03:00
Arik Fraimovich
e74b36996f Merge pull request #255 from EverythingMe/fix_queue_name
Fix: use correct queue name for scheduled queries
2014-08-04 22:40:16 +03:00
Arik Fraimovich
4c28d11259 Fix: use correct queue name for scheduled queries 2014-08-04 22:31:13 +03:00
Arik Fraimovich
b1e1a32f37 Merge pull request #252 from EverythingMe/perf
perf: HTTP caching headers for /api/query_results [fixes #228]
2014-08-04 16:55:39 +03:00
Amir Nissim
a12b43265d perf: HTTP caching headers for /api/query_results [fixes #228] 2014-08-04 16:50:56 +03:00
Arik Fraimovich
c2d621ae0f Merge pull request #247 from EverythingMe/245-refresh-btn
[#245] Add refresh button to query view page
2014-08-03 14:51:06 +03:00
Amir Nissim
d93e07061b [#245] Add refresh button to query view page 2014-08-03 13:14:17 +03:00
Arik Fraimovich
cb59973b9a Merge pull request #251 from EverythingMe/tests
setup Karma unit tests
2014-08-03 11:27:38 +03:00
Amir Nissim
72e41a94e4 update ci config 2014-08-03 11:15:02 +03:00
Amir Nissim
9013497fc7 rd_ui: fix failing unit test 2014-08-03 11:15:00 +03:00
Amir Nissim
a74ae32122 testing infra: basic QueryViewCtrl tests 2014-07-31 16:11:37 +03:00
Amir Nissim
9cfae349da testing infra: updated Karma and Grunt 2014-07-30 14:28:00 +03:00
Arik Fraimovich
a16718917b Merge pull request #248 from EverythingMe/243-db-requests
#243 dashboards api will not return query results by default
2014-07-29 16:14:08 +03:00
Amir Nissim
e2e365d9ff Query.to_dict never with results 2014-07-29 11:11:40 +03:00
Amir Nissim
5310498d0f [#241] fix textbox widget layout 2014-07-28 17:17:20 +03:00
Amir Nissim
bb1d2f8805 [#243] dashboards api will not return query results by default 2014-07-28 16:52:19 +03:00
Amir Nissim
0d5f001d38 fix migration add_text_to_widgets 2014-07-28 16:27:23 +03:00
Amir Nissim
236f7f9c04 fix add_global_filters_to_dashboard migration script 2014-07-28 12:15:08 +03:00
Amir Nissim
74bf8e5239 ignore celery files 2014-07-28 12:08:59 +03:00
Arik Fraimovich
71e125b4b0 Update Procfile.dev to use celery. 2014-07-20 12:08:08 +03:00
Arik Fraimovich
6a8befc641 Merge pull request #239 from EverythingMe/feature_outdated_queries_monitor
Model and import script for events
2014-07-09 18:55:53 +03:00
Arik Fraimovich
a79aa382d7 command to import events 2014-07-09 18:33:29 +03:00
Arik Fraimovich
5698f9692a Events model 2014-07-09 18:33:21 +03:00
Arik Fraimovich
b2381f6933 Merge pull request #238 from EverythingMe/feature_outdated_queries_monitor
Show outdated queries count and queue size in status
2014-07-08 21:51:13 +03:00
Arik Fraimovich
9a732a4dbf Show outdated queries count and queue size in status 2014-07-08 18:54:25 +03:00
Arik Fraimovich
17eb7e4146 Fix: when updating visualization need to ignore query_id 2014-07-07 16:59:18 +03:00
Arik Fraimovich
16a6c96c22 Use correct instance of queryResult 2014-07-06 18:34:26 +03:00
Arik Fraimovich
bc0a5160ac Fix: view going into infinite loop of calling getQueryResult. 2014-07-06 18:17:23 +03:00
Arik Fraimovich
62ab1fda80 Fix: UI hanging when saving query.
Clone query object, before modifying/sending over the wire.
2014-07-06 14:38:37 +03:00
Arik Fraimovich
b5309833ee Add logging to saveQuery 2014-07-06 13:59:51 +03:00
Arik Fraimovich
7b932507a6 Merge pull request #237 from EverythingMe/feature_column_editor
Feature: chart editor (no more "::x", "::y", "::series") + a lot more
2014-07-05 12:50:18 +03:00
Arik Fraimovich
c9fda5e6f1 Improve layout 2014-07-05 12:19:59 +03:00
Arik Fraimovich
a274bde092 Fix: after saving the column type mapping is empty 2014-07-05 12:19:48 +03:00
Arik Fraimovich
b4024ec880 Settings for chart options. 2014-07-05 12:02:51 +03:00
Arik Fraimovich
6367943d31 Make sure all paths of getQueryResult return same object. 2014-07-05 12:02:51 +03:00
Arik Fraimovich
eaa83556c3 Settings for second y axis. 2014-07-05 12:02:51 +03:00
Arik Fraimovich
7e720bcecd Chart columns type mapping. 2014-07-05 12:02:51 +03:00
Arik Fraimovich
003c285d11 Fix: dashboard view event 2014-07-05 12:02:51 +03:00
Arik Fraimovich
54687e72bd Merge pull request #236 from EverythingMe/fix_234
Fix #234: when converting value to moment, also set the column type
2014-07-05 11:37:00 +03:00
Arik Fraimovich
8c59386dc9 Fix #234: when converting value to moment, also set the column type 2014-07-05 11:35:10 +03:00
Arik Fraimovich
0369c557a4 Merge pull request #235 from shayel/master
Add Emacs (The One True Editor(TM)) backup files to .gitignore
2014-06-30 13:56:08 +03:00
Shay Elkin
1ca95dc497 Add Emacs (The One True Editor(TM)) backup files to .gitignore 2014-06-30 13:53:20 +03:00
Arik Fraimovich
85ea9060b0 Merge pull request #232 from jeremi/feature-bigquery-types
Add support for types in BigQuery
2014-06-27 16:31:29 +03:00
Arik Fraimovich
19b4ec7102 Merge pull request #233 from jeremi/fix-boolean-support-table
when the value is false, display false instead of empty cell
2014-06-27 16:29:46 +03:00
jeremi
b2fea7f2fe Add support for timestamps
Fix the type field
2014-06-27 15:48:52 +08:00
jeremi
d5947669ab when the value is false, display false instead of empty cell 2014-06-27 15:43:30 +08:00
jeremi
4cb97db98e Add support for types in BigQuery 2014-06-25 18:05:34 +08:00
Arik Fraimovich
9b5d43067a Revert "Merge pull request #231 from erans/master"
This introduced some unicode issues. Reverting until resolved.

This reverts commit 8731a8d273, reversing
changes made to 90157157df.
2014-06-24 14:00:21 +03:00
Arik Fraimovich
8731a8d273 Merge pull request #231 from erans/master
Force the use of JSON in Celery
2014-06-24 12:47:19 +03:00
Eran Sandler
08a06b0792 only use json in celery for serialization. pickle is going to be deprecated soon 2014-06-24 12:29:44 +03:00
Arik Fraimovich
90157157df Merge pull request #229 from jeremi/fix-heroku-procfile
fix starting of celery in Heroku
2014-06-24 11:24:54 +03:00
Arik Fraimovich
f5ea1f1559 Merge pull request #230 from jeremi/fix-default-groups
Add default group when user is created
2014-06-24 11:24:20 +03:00
jeremi
cf89e6b184 Make sure when users are created that it is with the default groups and not permissions. 2014-06-24 09:54:22 +08:00
jeremi
5920747122 fix starting of celery in Heroku 2014-06-24 09:46:40 +08:00
40 changed files with 1074 additions and 532 deletions

8
.gitignore vendored
View File

@@ -4,7 +4,10 @@
.coverage
rd_ui/dist
.DS_Store
celerybeat-schedule
celerybeat-schedule*
.#*
\#*#
*~
# Vagrant related
.vagrant
@@ -12,4 +15,5 @@ Berksfile.lock
redash/dump.rdb
.env
.ruby-version
venv
venv

View File

@@ -6,7 +6,7 @@ FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(VERSION).tar.gz
deps:
cd rd_ui && npm install
cd rd_ui && npm install grunt-cli bower
cd rd_ui && npm install -g bower grunt-cli
cd rd_ui && bower install
cd rd_ui && grunt build
@@ -19,3 +19,4 @@ upload:
test:
nosetests --with-coverage --cover-package=redash tests/*.py
cd rd_ui && grunt test

View File

@@ -1,2 +1,2 @@
web: ./manage.py runserver -p $PORT
worker: ./manage.py runworkers
worker: ./bin/run celery worker --app=redash.worker --beat -Qqueries,celery,scheduled_queries

View File

@@ -1,2 +1,2 @@
web: ./manage.py runserver -p $PORT --host 0.0.0.0 -d -r
worker: ./manage.py runworkers
worker: ./bin/run celery worker --app=redash.worker --beat -Qqueries,celery,scheduled_queries

View File

@@ -1,7 +1,7 @@
machine:
node:
version:
0.10.22
0.10.24
python:
version:
2.7.3
@@ -17,7 +17,7 @@ test:
override:
- make test
post:
- make pack
- make pack
deployment:
github:
branch: master

View File

@@ -2,6 +2,7 @@
"""
CLI to manage redash.
"""
import datetime
from flask.ext.script import Manager, prompt_pass
from redash import settings, models, __version__
@@ -39,6 +40,49 @@ def check_settings():
if not callable(item) and not name.startswith("__") and not isinstance(item, ModuleType):
print "{} = {}".format(name, item)
@manager.command
def import_events(events_file):
import json
from collections import Counter
count = Counter()
with open(events_file) as f:
for line in f:
try:
event = json.loads(line)
user = event.pop('user_id')
action = event.pop('action')
object_type = event.pop('object_type')
object_id = event.pop('object_id', None)
if object_id == 'dashboard' and object_type == 'dashboard':
count['bad dashboard id'] += 1
continue
created_at = datetime.datetime.utcfromtimestamp(event.pop('timestamp'))
additional_properties = json.dumps(event)
models.Event.create(user=user, action=action, object_type=object_type, object_id=object_id,
additional_properties=additional_properties, created_at=created_at)
count['imported'] += 1
except Exception as ex:
print "Failed importing line:"
print line
print ex.message
count[ex.message] += 1
count['failed'] += 1
models.db.close_db(None)
for k, v in count.iteritems():
print k
print v
@database_manager.command
def create_tables():
"""Creates the database tables."""
@@ -60,7 +104,7 @@ def drop_tables():
@users_manager.option('--admin', dest='is_admin', action="store_true", default=False, help="set user as admin")
@users_manager.option('--google', dest='google_auth', action="store_true", default=False, help="user uses Google Auth to login")
@users_manager.option('--password', dest='password', default=None, help="Password for users who don't use Google Auth (leave blank for prompt).")
@users_manager.option('--groups', dest='groups', default=models.Group.DEFAULT_PERMISSIONS, help="Comma seperated list of groups (leave blank for default).")
@users_manager.option('--groups', dest='groups', default=models.User.DEFAULT_GROUPS, help="Comma seperated list of groups (leave blank for default).")
def create(email, name, groups, is_admin=False, google_auth=False, password=None):
print "Creating user (%s, %s)..." % (email, name)
print "Admin: %r" % is_admin

View File

@@ -1,6 +1,6 @@
from playhouse.migrate import Migrator
from redash import db
from redash import models
from redash.models import db
if __name__ == '__main__':
@@ -9,4 +9,4 @@ if __name__ == '__main__':
with db.database.transaction():
migrator.add_column(models.Dashboard, models.Dashboard.dashboard_filters_enabled, 'dashboard_filters_enabled')
db.close_db(None)
db.close_db(None)

View File

@@ -1,5 +1,5 @@
from playhouse.migrate import Migrator
from redash import db
from redash.models import db
from redash import models
@@ -10,4 +10,4 @@ if __name__ == '__main__':
migrator.add_column(models.Widget, models.Widget.text, 'text')
migrator.set_nullable(models.Widget, models.Widget.visualization, True)
db.close_db(None)
db.close_db(None)

View File

@@ -0,0 +1,12 @@
from redash.models import db
from redash import models
if __name__ == '__main__':
db.connect_db()
if not models.Event.table_exists():
print "Creating events table..."
models.Event.create_table()
db.close_db(None)

View File

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

View File

@@ -1,10 +1,5 @@
// Generated on 2013-08-25 using generator-angular 0.4.0
// Generated on 2014-07-30 using generator-angular 0.9.2
'use strict';
var LIVERELOAD_PORT = 35729;
var lrSnippet = require('connect-livereload')({ port: LIVERELOAD_PORT });
var mountFolder = function (connect, dir) {
return connect.static(require('path').resolve(dir));
};
// # Globbing
// for performance reasons we're only matching one level down:
@@ -13,48 +8,148 @@ var mountFolder = function (connect, dir) {
// 'test/spec/**/*.js'
module.exports = function (grunt) {
// Load grunt tasks automatically
require('load-grunt-tasks')(grunt);
// Time how long tasks take. Can help when optimizing build times
require('time-grunt')(grunt);
// configurable paths
var yeomanConfig = {
app: 'app',
// Configurable paths for the application
var appConfig = {
app: require('./bower.json').appPath || 'app',
dist: 'dist'
};
try {
yeomanConfig.app = require('./bower.json').appPath || yeomanConfig.app;
} catch (e) {}
// Define the configuration for all the tasks
grunt.initConfig({
yeoman: yeomanConfig,
// Project settings
yeoman: appConfig,
// Watches files for changes and runs tasks based on the changed files
watch: {
coffee: {
files: ['<%= yeoman.app %>/scripts/{,*/}*.coffee'],
tasks: ['coffee:dist']
bower: {
files: ['bower.json'],
tasks: ['wiredep']
},
coffeeTest: {
files: ['test/spec/{,*/}*.coffee'],
tasks: ['coffee:test']
js: {
files: ['<%= yeoman.app %>/scripts/{,*/}*.js'],
tasks: ['newer:jshint:all'],
options: {
livereload: '<%= connect.options.livereload %>'
}
},
jsTest: {
files: ['test/spec/{,*/}*.js'],
tasks: ['newer:jshint:test', 'karma']
},
styles: {
files: ['<%= yeoman.app %>/styles/{,*/}*.css'],
tasks: ['copy:styles', 'autoprefixer']
tasks: ['newer:copy:styles', 'autoprefixer']
},
gruntfile: {
files: ['Gruntfile.js']
},
livereload: {
options: {
livereload: LIVERELOAD_PORT
livereload: '<%= connect.options.livereload %>'
},
files: [
'<%= yeoman.app %>/{,*/}*.html',
'.tmp/styles/{,*/}*.css',
'{.tmp,<%= yeoman.app %>}/scripts/{,*/}*.js',
'<%= yeoman.app %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}'
]
}
},
// The actual grunt server settings
connect: {
options: {
port: 9000,
// Change this to '0.0.0.0' to access the server from outside.
hostname: 'localhost',
livereload: 35729
},
livereload: {
options: {
open: true,
middleware: function (connect) {
return [
connect.static('.tmp'),
connect().use(
'/bower_components',
connect.static('./bower_components')
),
connect.static(appConfig.app)
];
}
}
},
test: {
options: {
port: 9001,
middleware: function (connect) {
return [
connect.static('.tmp'),
connect.static('test'),
connect().use(
'/bower_components',
connect.static('./bower_components')
),
connect.static(appConfig.app)
];
}
}
},
dist: {
options: {
open: true,
base: '<%= yeoman.dist %>'
}
}
},
// Make sure code styles are up to par and there are no obvious mistakes
jshint: {
options: {
jshintrc: '.jshintrc',
reporter: require('jshint-stylish')
},
all: {
src: [
'Gruntfile.js',
'<%= yeoman.app %>/scripts/{,*/}*.js'
]
},
test: {
options: {
jshintrc: 'test/.jshintrc'
},
src: ['test/spec/{,*/}*.js']
}
},
// Empties folders to start fresh
clean: {
dist: {
files: [{
dot: true,
src: [
'.tmp',
'<%= yeoman.dist %>/{,*/}*',
'!<%= yeoman.dist %>/.git*'
]
}]
},
server: '.tmp'
},
// Add vendor prefixed styles
autoprefixer: {
options: ['last 1 version'],
options: {
browsers: ['last 1 version']
},
dist: {
files: [{
expand: true,
@@ -64,134 +159,95 @@ module.exports = function (grunt) {
}]
}
},
connect: {
// Automatically inject Bower components into the app
wiredep: {
options: {
port: 9000,
// Change this to '0.0.0.0' to access the server from outside.
hostname: 'localhost'
cwd: '<%= yeoman.app %>'
},
livereload: {
options: {
middleware: function (connect) {
return [
lrSnippet,
mountFolder(connect, '.tmp'),
mountFolder(connect, yeomanConfig.app)
];
}
}
},
test: {
options: {
middleware: function (connect) {
return [
mountFolder(connect, '.tmp'),
mountFolder(connect, 'test')
];
}
}
},
dist: {
options: {
middleware: function (connect) {
return [
mountFolder(connect, yeomanConfig.dist)
];
}
}
app: {
src: ['<%= yeoman.app %>/index.html'],
ignorePath: /\.\.\//
}
},
open: {
server: {
url: 'http://localhost:<%= connect.options.port %>'
}
},
clean: {
dist: {
files: [{
dot: true,
src: [
'.tmp',
'<%= yeoman.dist %>/*',
'!<%= yeoman.dist %>/.git*'
]
}]
},
server: '.tmp'
},
jshint: {
options: {
jshintrc: '.jshintrc'
},
all: [
'Gruntfile.js',
'<%= yeoman.app %>/scripts/{,*/}*.js'
]
},
coffee: {
options: {
sourceMap: true,
sourceRoot: ''
},
dist: {
files: [{
expand: true,
cwd: '<%= yeoman.app %>/scripts',
src: '{,*/}*.coffee',
dest: '.tmp/scripts',
ext: '.js'
}]
},
test: {
files: [{
expand: true,
cwd: 'test/spec',
src: '{,*/}*.coffee',
dest: '.tmp/spec',
ext: '.js'
}]
}
},
// not used since Uglify task does concat,
// but still available if needed
/*concat: {
dist: {}
},*/
rev: {
dist: {
files: {
src: [
'<%= yeoman.dist %>/scripts/{,*/}*.js',
'<%= yeoman.dist %>/styles/{,*/}*.css',
'<%= yeoman.dist %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}',
'<%= yeoman.dist %>/styles/fonts/*'
]
}
// Renames files for browser caching purposes
filerev: {
dist: {
src: [
'<%= yeoman.dist %>/scripts/{,*/}*.js',
'<%= yeoman.dist %>/styles/{,*/}*.css',
'<%= yeoman.dist %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}',
'<%= yeoman.dist %>/styles/fonts/*'
]
}
},
// Reads HTML for usemin blocks to enable smart builds that automatically
// concat, minify and revision files. Creates configurations in memory so
// additional tasks can operate on them
useminPrepare: {
html: ['<%= yeoman.app %>/index.html', '<%= yeoman.app %>/login.html'],
html: '<%= yeoman.app %>/index.html',
options: {
dest: '<%= yeoman.dist %>'
dest: '<%= yeoman.dist %>',
flow: {
html: {
steps: {
js: ['concat', 'uglifyjs'],
css: ['cssmin']
},
post: {}
}
}
}
},
// Performs rewrites based on filerev and the useminPrepare configuration
usemin: {
html: ['<%= yeoman.dist %>/{,*/}*.html'],
css: ['<%= yeoman.dist %>/styles/{,*/}*.css'],
options: {
dirs: ['<%= yeoman.dist %>']
assetsDirs: ['<%= yeoman.dist %>','<%= yeoman.dist %>/images']
}
},
// The following *-min tasks will produce minified files in the dist folder
// By default, your `index.html`'s <!-- Usemin block --> will take care of
// minification. These next options are pre-configured if you do not wish
// to use the Usemin blocks.
// cssmin: {
// dist: {
// files: {
// '<%= yeoman.dist %>/styles/main.css': [
// '.tmp/styles/{,*/}*.css'
// ]
// }
// }
// },
// uglify: {
// dist: {
// files: {
// '<%= yeoman.dist %>/scripts/scripts.js': [
// '<%= yeoman.dist %>/scripts/scripts.js'
// ]
// }
// }
// },
// concat: {
// dist: {}
// },
imagemin: {
dist: {
files: [{
expand: true,
cwd: '<%= yeoman.app %>/images',
src: '{,*/}*.{png,jpg,jpeg}',
src: '{,*/}*.{png,jpg,jpeg,gif}',
dest: '<%= yeoman.dist %>/images'
}]
}
},
svgmin: {
dist: {
files: [{
@@ -202,41 +258,47 @@ module.exports = function (grunt) {
}]
}
},
cssmin: {
// By default, your `index.html` <!-- Usemin Block --> will take care of
// minification. This option is pre-configured if you do not wish to use
// Usemin blocks.
// dist: {
// files: {
// '<%= yeoman.dist %>/styles/main.css': [
// '.tmp/styles/{,*/}*.css',
// '<%= yeoman.app %>/styles/{,*/}*.css'
// ]
// }
// }
},
htmlmin: {
dist: {
options: {
/*removeCommentsFromCDATA: true,
// https://github.com/yeoman/grunt-usemin/issues/44
//collapseWhitespace: true,
collapseWhitespace: true,
conservativeCollapse: true,
collapseBooleanAttributes: true,
removeAttributeQuotes: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeOptionalTags: true*/
removeCommentsFromCDATA: true,
removeOptionalTags: true
},
files: [{
expand: true,
cwd: '<%= yeoman.app %>',
src: ['*.html', 'views/**/*.html'],
cwd: '<%= yeoman.dist %>',
src: ['*.html', 'views/{,*/}*.html'],
dest: '<%= yeoman.dist %>'
}]
}
},
// Put files not handled in other tasks here
// ngmin tries to make the code safe for minification automatically by
// using the Angular long form for dependency injection. It doesn't work on
// things like resolve or inject so those have to be done manually.
ngmin: {
dist: {
files: [{
expand: true,
cwd: '.tmp/concat/scripts',
src: '*.js',
dest: '.tmp/concat/scripts'
}]
}
},
// Replace Google CDN references
cdnify: {
dist: {
html: ['<%= yeoman.dist %>/*.html']
}
},
// Copies remaining files to places other tasks can use
copy: {
dist: {
files: [{
@@ -247,18 +309,21 @@ module.exports = function (grunt) {
src: [
'*.{ico,png,txt}',
'.htaccess',
'bower_components/**/*',
'images/{,*/}*.{gif,webp}',
'styles/{,*/}*.{png,gif}',
'*.html',
'views/{,*/}*.html',
'images/{,*/}*.{webp}',
'fonts/*'
]
}, {
expand: true,
cwd: '.tmp/images',
dest: '<%= yeoman.dist %>/images',
src: [
'generated/*'
]
src: ['generated/*']
}, {
expand: true,
cwd: 'bower_components/bootstrap/dist',
src: 'fonts/*',
dest: '<%= yeoman.dist %>'
}]
},
styles: {
@@ -268,70 +333,52 @@ module.exports = function (grunt) {
src: '{,*/}*.css'
}
},
// Run some tasks in parallel to speed up the build process
concurrent: {
server: [
'coffee:dist',
'copy:styles'
],
test: [
'coffee',
'copy:styles'
],
dist: [
'coffee',
'copy:styles',
'imagemin',
'svgmin',
'htmlmin'
'svgmin'
]
},
// Test settings
karma: {
unit: {
configFile: 'karma.conf.js',
configFile: 'test/karma.conf.js',
singleRun: true
}
},
cdnify: {
dist: {
html: ['<%= yeoman.dist %>/*.html']
}
},
ngmin: {
dist: {
files: [{
expand: true,
cwd: '<%= yeoman.dist %>/scripts',
src: '*.js',
dest: '<%= yeoman.dist %>/scripts'
}]
}
},
uglify: {
dist: {
files: {
'<%= yeoman.dist %>/scripts/scripts.js': [
'<%= yeoman.dist %>/scripts/scripts.js'
]
}
}
}
});
grunt.registerTask('server', function (target) {
grunt.registerTask('serve', 'Compile then start a connect web server', function (target) {
if (target === 'dist') {
return grunt.task.run(['build', 'open', 'connect:dist:keepalive']);
return grunt.task.run(['build', 'connect:dist:keepalive']);
}
grunt.task.run([
'clean:server',
'wiredep',
'concurrent:server',
'autoprefixer',
'connect:livereload',
'open',
'watch'
]);
});
grunt.registerTask('server', 'DEPRECATED TASK. Use the "serve" task instead', function (target) {
grunt.log.warn('The `server` task has been deprecated. Use `grunt serve` to start a server.');
grunt.task.run(['serve:' + target]);
});
grunt.registerTask('test', [
'clean:server',
'concurrent:test',
@@ -342,21 +389,23 @@ module.exports = function (grunt) {
grunt.registerTask('build', [
'clean:dist',
'wiredep',
'useminPrepare',
'concurrent:dist',
'autoprefixer',
'concat',
'ngmin',
'copy:dist',
'cdnify',
'ngmin',
'cssmin',
'uglify',
'rev',
'usemin'
'filerev',
'usemin',
'htmlmin'
]);
grunt.registerTask('default', [
'jshint',
'newer:jshint',
'test',
'build'
]);

View File

@@ -89,4 +89,4 @@ angular.module('redash', [
}
]);
]);

View File

@@ -1,50 +1,56 @@
(function() {
var DashboardCtrl = function($scope, Events, Widget, $routeParams, $http, $timeout, Dashboard) {
Events.record(currentUser, "view", "dashboard", dashboard.id);
var DashboardCtrl = function($scope, Events, Widget, $routeParams, $http, $timeout, $q, Dashboard) {
$scope.refreshEnabled = false;
$scope.refreshRate = 60;
var loadDashboard = _.throttle(function() {
$scope.dashboard = Dashboard.get({ slug: $routeParams.dashboardSlug }, function (dashboard) {
$scope.$parent.pageTitle = dashboard.name;
var filters = {};
Events.record(currentUser, "view", "dashboard", dashboard.id);
$scope.$parent.pageTitle = dashboard.name;
var promises = [];
$scope.dashboard.widgets = _.map($scope.dashboard.widgets, function (row) {
return _.map(row, function (widget) {
var w = new Widget(widget);
if (w.visualization && dashboard.dashboard_filters_enabled) {
var queryFilters = w.getQuery().getQueryResult().getFilters();
_.each(queryFilters, function (filter) {
if (!_.has(filters, filter.name)) {
// TODO: first object should be a copy, otherwise one of the chart filters behaves different than the others.
filters[filter.name] = filter;
filters[filter.name].originFilters = [];
$scope.$watch(function () {
return filter.current
}, function (value) {
_.each(filter.originFilters, function (originFilter) {
originFilter.current = value;
})
});
}
;
// TODO: merge values.
filters[filter.name].originFilters.push(filter);
});
promises.push(w.getQuery().getQueryResultPromise());
}
return w;
});
});
if (dashboard.dashboard_filters_enabled) {
$scope.filters = _.values(filters);
}
$q.all(promises).then(function(queryResults) {
var filters = {};
_.each(queryResults, function(queryResult) {
var queryFilters = queryResult.getFilters();
_.each(queryFilters, function (filter) {
if (!_.has(filters, filter.name)) {
// TODO: first object should be a copy, otherwise one of the chart filters behaves different than the others.
filters[filter.name] = filter;
filters[filter.name].originFilters = [];
$scope.$watch(function () { return filter.current }, function (value) {
_.each(filter.originFilters, function (originFilter) {
originFilter.current = value;
});
});
};
// TODO: merge values.
filters[filter.name].originFilters.push(filter);
});
});
if (dashboard.dashboard_filters_enabled) {
$scope.filters = _.values(filters);
}
});
}, function () {
// error...
// try again. we wrap loadDashboard with throttle so it doesn't happen too often.\
@@ -131,7 +137,7 @@
};
angular.module('redash.controllers')
.controller('DashboardCtrl', ['$scope', 'Events', 'Widget', '$routeParams', '$http', '$timeout', 'Dashboard', DashboardCtrl])
.controller('DashboardCtrl', ['$scope', 'Events', 'Widget', '$routeParams', '$http', '$timeout', '$q', 'Dashboard', DashboardCtrl])
.controller('WidgetCtrl', ['$scope', 'Events', 'Query', WidgetCtrl])
})();

View File

@@ -32,6 +32,14 @@
$scope.newVisualization = undefined;
// @override
Object.defineProperty($scope, 'showDataset', {
get: function() {
return $scope.queryResult && $scope.queryResult.getStatus() == 'done';
}
});
KeyboardShortcuts.bind(shortcuts);
// @override
@@ -109,4 +117,4 @@
'Events', 'growl', '$controller', '$scope', '$location', 'Query',
'Visualization', 'KeyboardShortcuts', QuerySourceCtrl
]);
})();
})();

View File

@@ -16,6 +16,10 @@
$scope.query.data_source_id = $scope.query.data_source_id || dataSources[0].id;
});
// in view mode, latest dataset is always visible
// source mode changes this behavior
$scope.showDataset = true;
$scope.lockButton = function(lock) {
$scope.queryExecuting = lock;
};
@@ -24,7 +28,7 @@
if (data) {
data.id = $scope.query.id;
} else {
data = $scope.query;
data = _.clone($scope.query);
}
options = _.extend({}, {
@@ -32,8 +36,8 @@
errorMessage: 'Query could not be saved'
}, options);
delete $scope.query.latest_query_data;
delete $scope.query.queryResult;
delete data.latest_query_data;
delete data.queryResult;
return Query.save(data, function() {
growl.addSuccessMessage(options.successMessage);
@@ -91,16 +95,6 @@
$scope.$parent.pageTitle = $scope.query.name;
});
$scope.$watch('queryResult && queryResult.getError()', function(newError, oldError) {
if (newError == undefined) {
return;
}
if (oldError == undefined && newError != undefined) {
$scope.lockButton(false);
}
});
$scope.$watch('queryResult && queryResult.getData()', function(data, oldData) {
if (!data) {
return;
@@ -114,7 +108,7 @@
return;
}
if (status == "done") {
if (status == 'done') {
if ($scope.query.id &&
$scope.query.latest_query_data_id != $scope.queryResult.getId() &&
$scope.query.query_hash == $scope.queryResult.query_result.query_hash) {
@@ -124,9 +118,12 @@
})
}
$scope.query.latest_query_data_id = $scope.queryResult.getId();
$scope.query.queryResult = $scope.queryResult;
notifications.showNotification("re:dash", $scope.query.name + " updated.");
}
if (status === 'done' || status === 'failed') {
$scope.lockButton(false);
}
});
@@ -144,4 +141,4 @@
angular.module('redash.controllers')
.controller('QueryViewCtrl',
['$scope', 'Events', '$route', '$location', 'notifications', 'growl', 'Query', 'DataSource', QueryViewCtrl]);
})();
})();

View File

@@ -13,11 +13,23 @@
xAxis: {
type: 'datetime'
},
yAxis: {
title: {
text: null
yAxis: [
{
title: {
text: null
},
// showEmpty: true // by default
},
{
title: {
text: null
},
opposite: true,
showEmpty: false
}
},
],
tooltip: {
valueDecimals: 2,
formatter: function () {

View File

@@ -1,5 +1,5 @@
(function () {
var QueryResult = function ($resource, $timeout) {
var QueryResult = function ($resource, $timeout, $q) {
var QueryResultResource = $resource('/api/query_results/:id', {id: '@id'}, {'post': {'method': 'POST'}});
var Job = $resource('/api/jobs/:id', {id: '@id'});
@@ -10,13 +10,29 @@
this.filters = undefined;
this.filterFreeze = undefined;
var columnTypes = {};
_.each(this.query_result.data.rows, function (row) {
_.each(row, function (v, k) {
if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}/)) {
if (angular.isNumber(v)) {
columnTypes[k] = 'float';
} else if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}T/)) {
row[k] = moment(v);
columnTypes[k] = 'datetime';
} else if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}/)) {
row[k] = moment(v);
columnTypes[k] = 'date';
}
});
}, this);
}, this);
_.each(this.query_result.data.columns, function(column) {
if (columnTypes[column.name]) {
column.type = columnTypes[column.name];
}
});
this.deferred.resolve(this);
} else if (this.job.status == 3) {
this.status = "processing";
} else {
@@ -25,6 +41,7 @@
}
function QueryResult(props) {
this.deferred = $q.defer();
this.job = {};
this.query_result = {};
this.status = "waiting";
@@ -133,7 +150,7 @@
return this.filteredData;
}
QueryResult.prototype.getChartData = function () {
QueryResult.prototype.getChartData = function (mapping) {
var series = {};
_.each(this.getData(), function (row) {
@@ -143,8 +160,15 @@
var yValues = {};
_.each(row, function (value, definition) {
var type = definition.split("::")[1];
var name = definition.split("::")[0];
var type = definition.split("::")[1];
if (mapping) {
type = mapping[definition];
}
if (type == 'unused') {
return;
}
if (type == 'x') {
xValue = value;
@@ -199,7 +223,7 @@
if (this.columns == undefined && this.query_result.data) {
this.columns = this.query_result.data.columns;
}
return this.columns;
}
@@ -327,6 +351,10 @@
});
return queryResult;
};
QueryResult.prototype.toPromise = function() {
return this.deferred.promise;
}
QueryResult.get = function (data_source_id, query, ttl) {
@@ -367,24 +395,30 @@
ttl = this.ttl;
}
var queryResult = null;
if (this.latest_query_data && ttl != 0) {
if (!this.queryResult) {
this.queryResult = new QueryResult({'query_result': this.latest_query_data});
}
queryResult = this.queryResult;
} else if (this.latest_query_data_id && ttl != 0) {
queryResult = QueryResult.getById(this.latest_query_data_id);
if (!this.queryResult) {
this.queryResult = QueryResult.getById(this.latest_query_data_id);
}
} else if (this.data_source_id) {
queryResult = QueryResult.get(this.data_source_id, this.query, ttl);
this.queryResult = QueryResult.get(this.data_source_id, this.query, ttl);
}
return queryResult;
return this.queryResult;
};
Query.prototype.getQueryResultPromise = function() {
return this.getQueryResult().toPromise();
}
return Query;
};
var DataSource = function ($resource) {
var DataSourceResource = $resource('/api/data_sources/:id', {id: '@id'}, {'get': {'method': 'GET', 'cache': true, 'isArray': true}});
@@ -413,7 +447,7 @@
}
angular.module('redash.services')
.factory('QueryResult', ['$resource', '$timeout', QueryResult])
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
.factory('DataSource', ['$resource', DataSource])
.factory('Widget', ['$resource', 'Query', Widget]);

View File

@@ -111,26 +111,23 @@
scope.editRawOptions = currentUser.hasPermission('edit_raw_chart');
scope.visTypes = Visualization.visualizationTypes;
scope.newVisualization = function (q) {
scope.newVisualization = function () {
return {
'query_id': q.id,
'type': Visualization.defaultVisualization.type,
'name': Visualization.defaultVisualization.name,
'description': q.description || '',
'description': '',
'options': Visualization.defaultVisualization.defaultOptions
};
}
if (!scope.visualization) {
// create new visualization
// wait for query to load to populate with defaults
var unwatch = scope.$watch('query', function (q) {
if (q && q.id) {
var unwatch = scope.$watch('query.id', function (queryId) {
if (queryId) {
unwatch();
scope.visualization = scope.newVisualization(q);
scope.visualization = scope.newVisualization();
}
}, true);
});
}
scope.$watch('visualization.type', function (type, oldType) {
@@ -148,6 +145,8 @@
Events.record(currentUser, "create", "visualization", null, {'type': scope.visualization.type});
}
scope.visualization.query_id = scope.query.id;
Visualization.save(scope.visualization, function success(result) {
growl.addSuccessMessage("Visualization saved");

View File

@@ -6,7 +6,7 @@
var editTemplate = '<chart-editor></chart-editor>';
var defaultOptions = {
'series': {
'type': 'column',
// 'type': 'column',
'stacking': null
}
};
@@ -33,24 +33,45 @@
$scope.chartSeries = [];
$scope.chartOptions = {};
var reloadData = _.throttle(function(data) {
if (!data || ($scope.queryResult && $scope.queryResult.getData()) == null) {
$scope.chartSeries.splice(0, $scope.chartSeries.length);
} else {
$scope.chartSeries.splice(0, $scope.chartSeries.length);
_.each($scope.queryResult.getChartData($scope.options.columnMapping), function (s) {
var additional = {'stacking': 'normal'};
if ($scope.options.seriesOptions && $scope.options.seriesOptions[s.name]) {
additional = $scope.options.seriesOptions[s.name];
if (!additional.name || additional.name == "") {
additional.name = s.name;
}
}
$scope.chartSeries.push(_.extend(s, additional));
});
}
}, 500);
$scope.$watch('options', function (chartOptions) {
if (chartOptions) {
$scope.chartOptions = chartOptions;
}
});
$scope.$watch('queryResult && queryResult.getData()', function (data) {
if (!data || $scope.queryResult.getData() == null) {
$scope.chartSeries.splice(0, $scope.chartSeries.length);
} else {
$scope.chartSeries.splice(0, $scope.chartSeries.length);
_.each($scope.queryResult.getChartData(), function (s) {
$scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}));
});
}
$scope.$watch('options.seriesOptions', function () {
reloadData(true);
}, true);
$scope.$watchCollection('options.columnMapping', function (chartOptions) {
reloadData(true);
});
$scope.$watch('queryResult && queryResult.getData()', function (data) {
reloadData(data);
});
}]
}
};
});
chartVisualization.directive('chartEditor', function () {
@@ -81,10 +102,26 @@
scope.xAxisType = "datetime";
scope.stacking = "none";
var chartOptionsUnwatch = null;
scope.$watch('visualization', function (visualization) {
if (visualization && visualization.type == 'CHART') {
scope.columnTypes = {
"X": "x",
// "X (Date time)": "x",
// "X (Linear)": "x-linear",
// "X (Category)": "x-category",
"Y": "y",
"Series": "series",
"Unused": "unused"
};
scope.series = [];
scope.columnTypeSelection = {};
var chartOptionsUnwatch = null,
columnsWatch = null;
scope.$watch('visualization.type', function (visualizationType) {
if (visualizationType == 'CHART') {
if (scope.visualization.options.series.stacking === null) {
scope.stacking = "none";
} else if (scope.visualization.options.series.stacking === undefined) {
@@ -93,6 +130,72 @@
scope.stacking = scope.visualization.options.series.stacking;
}
var refreshSeries = function() {
scope.series = _.map(scope.queryResult.getChartData(scope.visualization.options.columnMapping), function (s) { return s.name; });
// TODO: remove uneeded ones?
if (scope.visualization.options.seriesOptions == undefined) {
scope.visualization.options.seriesOptions = {};
};
_.each(scope.series, function(s, i) {
if (scope.visualization.options.seriesOptions[s] == undefined) {
scope.visualization.options.seriesOptions[s] = {'type': 'column', 'yAxis': 0};
}
scope.visualization.options.seriesOptions[s].zIndex = i;
});
scope.zIndexes = _.range(scope.series.length);
scope.yAxes = [[0, 'left'], [1, 'right']];
};
var initColumnMapping = function() {
scope.columns = scope.queryResult.getColumns();
if (scope.visualization.options.columnMapping == undefined) {
scope.visualization.options.columnMapping = {};
}
scope.columnTypeSelection = scope.visualization.options.columnMapping;
_.each(scope.columns, function(column) {
var definition = column.name.split("::"),
definedColumns = _.keys(scope.visualization.options.columnMapping);
if (_.indexOf(definedColumns, column.name) != -1) {
// Skip already defined columns.
return;
};
if (definition.length == 1) {
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = 'unused';
} else if (definition == 'multi-filter') {
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = 'series';
} else if (_.indexOf(_.values(scope.columnTypes), definition[1]) != -1) {
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = definition[1];
} else {
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = 'unused';
}
});
};
columnsWatch = scope.$watch('queryResult.getId()', function(id) {
if (!id) {
return;
}
initColumnMapping();
refreshSeries();
});
scope.$watchCollection('columnTypeSelection', function(selections) {
_.each(scope.columnTypeSelection, function(type, name) {
scope.visualization.options.columnMapping[name] = type;
});
refreshSeries();
});
chartOptionsUnwatch = scope.$watch("stacking", function (stacking) {
if (stacking == "none") {
scope.visualization.options.series.stacking = null;
@@ -113,6 +216,11 @@
chartOptionsUnwatch = null;
}
if (columnsWatch) {
columnWatch();
columnWatch = null;
}
if (xAxisUnwatch) {
xAxisUnwatch();
xAxisUnwatch = null;

View File

@@ -63,29 +63,19 @@
var columnType = columns[i].type;
if (!columnType) {
var rawData = $scope.queryResult.getRawData();
if (rawData.length > 0) {
var exampleData = rawData[0][col];
if (angular.isNumber(exampleData)) {
columnType = 'float';
} else if (moment.isMoment(exampleData)) {
if (exampleData._i.match(/^\d{4}-\d{2}-\d{2}T/)) {
columnType = 'datetime';
} else {
columnType = 'date';
}
}
}
}
if (columnType === 'integer') {
columnDefinition.formatFunction = 'number';
columnDefinition.formatParameter = 0;
} else if (columnType === 'float') {
columnDefinition.formatFunction = 'number';
columnDefinition.formatParameter = 2;
} else if (columnType === 'boolean') {
columnDefinition.formatFunction = function (value) {
if (value !== undefined) {
return "" + value;
}
return value;
};
} else if (columnType === 'date') {
columnDefinition.formatFunction = function (value) {
if (value) {

View File

@@ -245,6 +245,9 @@ to add those CSS styles here. */
background-color: #FF8080;
border-radius: 50%;
}
.nav-tabs > li.rd-tab-btn {
float: right;
}
/* light version of bootstrap's form-control */
.rd-form-control {
@@ -268,6 +271,10 @@ pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div
overflow: auto;
}
.rd-widget-textbox p {
margin-bottom: 0;
}
.iframe-container {
height: 100%;
}
@@ -280,4 +287,4 @@ use this class when you need to keep the original display value
.rd-hidden-xs {
display: none !important;
}
}
}

View File

@@ -20,31 +20,21 @@
<span class="badge" am-time-ago="manager.started_at*1000.0"></span>
Started
</li>
<li class="list-group-item">
<span class="badge">{{manager.outdated_queries_count}}</span>
Outdated Queries Count
</li>
<li class="list-group-item" ng-if="flowerUrl">
<a href="/admin/workers">Workers' Status</a>
</li>
</ul>
<ul class="list-group col-lg-4">
<div ng-repeat="worker in workers">
<li class="list-group-item active">Worker {{$index+1}}</li>
<li class="list-group-item">
<span class="badge" am-time-ago="worker.updated_at*1000.0"></span>
Updated
<li class="list-group-item active">Queues</li>
<li class="list-group-item" ng-repeat="(name, value) in manager.queues">
<span class="badge">{{value.size}}</span>
{{name}} ({{value.data_sources}})
</li>
<li class="list-group-item">
<span class="badge" am-time-ago="worker.started_at*1000.0"></span>
Started
</li>
<li class="list-group-item">
<span class="badge">{{worker.jobs_count}}</span>
Jobs Received
</li>
<li class="list-group-item">
<span class="badge">{{worker.done_jobs_count}}</span>
Jobs Done
</li>
</div>
</ul>
</div>
<div class="panel-footer">Next refresh: <span am-time-ago="refresh_time"></span></div>

View File

@@ -53,15 +53,20 @@
</div>
</div>
<div class="panel panel-default" ng-if="type=='textbox'" ng-mouseenter="showControls = true" ng-mouseleave="showControls = false">
<div class="panel panel-default rd-widget-textbox" ng-if="type=='textbox'" ng-mouseenter="showControls = true" ng-mouseleave="showControls = false">
<div class="panel-body">
<p ng-bind-html="widget.text | markdown"></p>
<span class="pull-right" ng-show="showControls">
<button type="button" class="btn btn-default btn-xs" ng-show="dashboard.canEdit()" ng-click="deleteWidget()" title="Remove Widget"><span class="glyphicon glyphicon-trash"></span></button>
</span>
<div class="row">
<div class="col-lg-11">
<p ng-bind-html="widget.text | markdown"></p>
</div>
<div class="col-lg-1">
<span class="pull-right" ng-show="showControls">
<button type="button" class="btn btn-default btn-xs" ng-show="dashboard.canEdit()" ng-click="deleteWidget()" title="Remove Widget"><span class="glyphicon glyphicon-trash"></span></button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -142,7 +142,7 @@
<div class="alert alert-danger" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong></div>
<!-- tabs and data -->
<div ng-show="queryResult.getStatus() == 'done'">
<div ng-show="showDataset">
<div class="row">
<div class="col-lg-12">
<ul class="nav nav-tabs">
@@ -152,6 +152,7 @@
<span class="remove" ng-click="deleteVisualization($event, vis)" ng-show="canEdit"> &times;</span>
</rd-tab>
<rd-tab tab-id="add" name="&plus; New" removeable="true" ng-show="canEdit"></rd-tab>
<li ng-if="!sourceMode" class="rd-tab-btn"><button class="btn btn-sm btn-default" ng-click="executeQuery()" ng-disabled="queryExecuting" title="Refresh Dataset"><span class="glyphicon glyphicon-refresh"></span></button></li>
</ul>
</div>
</div>
@@ -172,7 +173,7 @@
<div ng-show="selectedTab == 'add'">
<visualization-renderer visualization="newVisualization" query-result="queryResult"></visualization-renderer>
<edit-visulatization-form visualization="newVisualization" query="query" ng-show="canEdit" open-editor="true" on-new-success="setVisualizationTab"></edit-visulatization-form>
<edit-visulatization-form visualization="newVisualization" query="query" query-result="queryResult" ng-show="canEdit" open-editor="true" on-new-success="setVisualizationTab"></edit-visulatization-form>
</div>
</div>
</div>

View File

@@ -1,14 +1,91 @@
<div>
<div class="form-group">
<label class="control-label">Chart Type</label>
<select required ng-model="visualization.options.series.type" ng-options="value as key for (key, value) in seriesTypes" class="form-control"></select>
</div>
<div class="form-horizontal">
<div class="panel panel-default">
<div class="panel-body">
<div class="form-group">
<label class="control-label col-sm-2">Stacking</label>
<div class="form-group">
<label class="control-label">Stacking</label>
<select required ng-model="stacking" ng-options="value as key for (key, value) in stackingOptions" class="form-control"></select>
<div class="col-sm-10">
<select required ng-model="stacking"
ng-options="value as key for (key, value) in stackingOptions"
class="form-control"></select>
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-2">X Axis Type</label>
<label class="control-label">X Axis Type</label>
<select required ng-model="xAxisType" ng-options="value as key for (key, value) in xAxisOptions" class="form-control"></select>
</div>
<div class="col-sm-10">
<select required ng-model="xAxisType" ng-options="value as key for (key, value) in xAxisOptions"
class="form-control"></select>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-6">
<div class="list-group">
<div class="list-group-item active">
Columns Mapping
</div>
<div class="list-group-item">
<div class="form-group" ng-repeat="column in columns">
<label class="control-label col-sm-4">{{column.name}}</label>
<div class="col-sm-8">
<select ng-options="value as key for (key, value) in columnTypes" class="form-control"
ng-model="columnTypeSelection[column.name]"></select>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-6" ng-if="series.length > 0">
<div class="list-group" ng-repeat="seriesName in series">
<div class="list-group-item active">
{{seriesName}}
</div>
<div class="list-group-item">
<div class="form-group">
<label class="control-label col-sm-3">Type</label>
<div class="col-sm-9">
<select required ng-model="visualization.options.seriesOptions[seriesName].type"
ng-options="value as key for (key, value) in seriesTypes"
class="form-control"></select>
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-3">zIndex</label>
<div class="col-sm-9">
<select required ng-model="visualization.options.seriesOptions[seriesName].zIndex"
ng-options="o as o for o in zIndexes"
class="form-control"></select>
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-3">y Axis</label>
<div class="col-sm-9">
<select required ng-model="visualization.options.seriesOptions[seriesName].yAxis"
ng-options="o[0] as o[1] for o in yAxes"
class="form-control"></select>
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-3">Name</label>
<div class="col-sm-9">
<input name="seriesName" type="text" class="form-control"
ng-model="visualization.options.seriesOptions[seriesName].name"
placeholder="{{seriesName}}">
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -11,7 +11,7 @@
"moment": "2.1.0",
"angular-ui-bootstrap": "0.5.0",
"angular-ui-codemirror": "0.0.5",
"highcharts": "3.0.1",
"highcharts": "3.0.10",
"underscore": "1.5.1",
"angular-resource": "1.2.15",
"angular-growl": "0.3.1",

View File

@@ -1,58 +0,0 @@
// Karma E2E configuration
// base path, that will be used to resolve files and exclude
basePath = '';
// list of files / patterns to load in the browser
files = [
ANGULAR_SCENARIO,
ANGULAR_SCENARIO_ADAPTER,
'test/e2e/**/*.js'
];
// list of files to exclude
exclude = [];
// test results reporter to use
// possible values: dots || progress || growl
reporters = ['progress'];
// web server port
port = 8080;
// cli runner port
runnerPort = 9100;
// enable / disable colors in the output (reporters and logs)
colors = true;
// level of logging
// possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG
logLevel = LOG_INFO;
// enable / disable watching file and executing tests whenever any file changes
autoWatch = false;
// Start these browsers, currently available:
// - Chrome
// - ChromeCanary
// - Firefox
// - Opera
// - Safari (only Mac)
// - PhantomJS
// - IE (only Windows)
browsers = ['Chrome'];
// If browser does not capture in given timeout [ms], kill it
captureTimeout = 5000;
// Continuous Integration mode
// if true, it capture browsers, run tests and exit
singleRun = false;
// Uncomment the following lines if you are using grunt's server to run the tests
// proxies = {
// '/': 'http://localhost:9000/'
// };
// URL root prevent conflicts with the site root
// urlRoot = '_karma_';

View File

@@ -1,56 +0,0 @@
// Karma configuration
// base path, that will be used to resolve files and exclude
basePath = '';
// list of files / patterns to load in the browser
files = [
JASMINE,
JASMINE_ADAPTER,
'app/bower_components/angular/angular.js',
'app/bower_components/angular-mocks/angular-mocks.js',
'app/scripts/*.js',
'app/scripts/**/*.js',
'test/mock/**/*.js',
'test/spec/**/*.js'
];
// list of files to exclude
exclude = [];
// test results reporter to use
// possible values: dots || progress || growl
reporters = ['progress'];
// web server port
port = 8080;
// cli runner port
runnerPort = 9100;
// enable / disable colors in the output (reporters and logs)
colors = true;
// level of logging
// possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG
logLevel = LOG_INFO;
// enable / disable watching file and executing tests whenever any file changes
autoWatch = false;
// Start these browsers, currently available:
// - Chrome
// - ChromeCanary
// - Firefox
// - Opera
// - Safari (only Mac)
// - PhantomJS
// - IE (only Windows)
browsers = ['Chrome'];
// If browser does not capture in given timeout [ms], kill it
captureTimeout = 5000;
// Continuous Integration mode
// if true, it capture browsers, run tests and exit
singleRun = false;

View File

@@ -1,38 +1,38 @@
{
"name": "rd-ui",
"version": "0.1.0",
"dependencies": {
},
"name": "rdui",
"version": "0.0.0",
"dependencies": {},
"devDependencies": {
"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",
"grunt-contrib-uglify": "~0.2.0",
"grunt-contrib-compass": "~0.5.0",
"grunt-contrib-jshint": "~0.6.0",
"grunt-contrib-cssmin": "~0.6.0",
"grunt-contrib-connect": "~0.3.0",
"grunt-contrib-clean": "~0.5.0",
"grunt-contrib-htmlmin": "~0.1.3",
"grunt-contrib-imagemin": "~0.2.0",
"grunt-contrib-watch": "~0.5.2",
"grunt-autoprefixer": "~0.2.0",
"grunt-usemin": "~0.1.11",
"grunt-svgmin": "~0.2.0",
"grunt-rev": "~0.1.0",
"grunt-open": "~0.2.0",
"grunt-concurrent": "~0.3.0",
"load-grunt-tasks": "~0.1.0",
"connect-livereload": "~0.2.0",
"grunt-google-cdn": "~0.2.0",
"grunt-ngmin": "~0.0.2",
"time-grunt": "~0.1.0",
"bower": "~1.2.7",
"grunt-cli": "~0.1.9"
"grunt": "^0.4.1",
"grunt-autoprefixer": "^0.7.3",
"grunt-concurrent": "^0.5.0",
"grunt-contrib-clean": "^0.5.0",
"grunt-contrib-concat": "^0.4.0",
"grunt-contrib-connect": "^0.7.1",
"grunt-contrib-copy": "^0.5.0",
"grunt-contrib-cssmin": "^0.9.0",
"grunt-contrib-htmlmin": "^0.3.0",
"grunt-contrib-imagemin": "^0.7.0",
"grunt-contrib-jshint": "^0.10.0",
"grunt-contrib-uglify": "^0.4.0",
"grunt-contrib-watch": "^0.6.1",
"grunt-filerev": "^0.2.1",
"grunt-google-cdn": "^0.4.0",
"grunt-newer": "^0.7.0",
"grunt-ngmin": "^0.0.3",
"grunt-svgmin": "^0.4.0",
"grunt-usemin": "^2.1.1",
"grunt-wiredep": "^1.7.0",
"jshint-stylish": "^0.2.0",
"load-grunt-tasks": "^0.4.0",
"time-grunt": "^0.3.1",
"karma-jasmine": "~0.1.5",
"grunt-karma": "~0.8.3",
"karma-phantomjs-launcher": "~0.1.4",
"karma": "~0.12.19"
},
"engines": {
"node": ">=0.8.0"
"node": ">=0.10.0"
},
"scripts": {
"test": "grunt test"

View File

@@ -29,6 +29,7 @@
"expect": false,
"inject": false,
"it": false,
"jasmine": false,
"spyOn": false
}
}

123
rd_ui/test/karma.conf.js Normal file
View File

@@ -0,0 +1,123 @@
// Karma configuration
// http://karma-runner.github.io/0.12/config/configuration-file.html
// Generated on 2014-07-30 using
// generator-karma 0.8.3
module.exports = function(config) {
'use strict';
config.set({
// enable / disable watching file and executing tests whenever any file changes
autoWatch: true,
// base path, that will be used to resolve files and exclude
basePath: '../',
// testing framework to use (jasmine/mocha/qunit/...)
frameworks: ['jasmine'],
// list of files / patterns to load in the browser
files: [
'app/bower_components/jquery/jquery.js',
'app/bower_components/jquery-ui/ui/jquery-ui.js',
'app/bower_components/angular/angular.js',
'app/bower_components/angular-route/angular-route.js',
'app/bower_components/angular-mocks/angular-mocks.js',
'app/bower_components/bootstrap/js/collapse.js',
'app/bower_components/bootstrap/js/modal.js',
'app/bower_components/angular-resource/angular-resource.js',
'app/bower_components/underscore/underscore.js',
'app/bower_components/moment/moment.js',
'app/bower_components/angular-moment/angular-moment.js',
'app/bower_components/codemirror/lib/codemirror.js',
'app/bower_components/codemirror/addon/edit/matchbrackets.js',
'app/bower_components/codemirror/addon/edit/closebrackets.js',
'app/bower_components/codemirror/mode/sql/sql.js',
'app/bower_components/codemirror/mode/javascript/javascript.js',
'app/bower_components/angular-ui-codemirror/ui-codemirror.js',
'app/bower_components/highcharts/highcharts.js',
'app/bower_components/highcharts/modules/exporting.js',
'app/bower_components/gridster/dist/jquery.gridster.js',
'app/bower_components/angular-growl/build/angular-growl.js',
'app/bower_components/pivottable/dist/pivot.js',
'app/bower_components/cornelius/src/cornelius.js',
'app/bower_components/mousetrap/mousetrap.js',
'app/bower_components/mousetrap/plugins/global-bind/mousetrap-global-bind.js',
'app/bower_components/select2/select2.js',
'app/bower_components/angular-ui-select2/src/select2.js',
'app/bower_components/underscore.string/lib/underscore.string.js',
'app/bower_components/marked/lib/marked.js',
'app/scripts/ng_highchart.js',
'app/scripts/ng_smart_table.js',
'app/scripts/ui-bootstrap-tpls-0.5.0.min.js',
'app/bower_components/bucky/bucky.js',
'app/bower_components/pace/pace.js',
'app/scripts/app.js',
'app/scripts/services/services.js',
'app/scripts/services/resources.js',
'app/scripts/services/notifications.js',
'app/scripts/services/dashboards.js',
'app/scripts/controllers/controllers.js',
'app/scripts/controllers/dashboard.js',
'app/scripts/controllers/admin_controllers.js',
'app/scripts/controllers/query_view.js',
'app/scripts/controllers/query_source.js',
'app/scripts/visualizations/base.js',
'app/scripts/visualizations/chart.js',
'app/scripts/visualizations/cohort.js',
'app/scripts/visualizations/table.js',
'app/scripts/visualizations/pivot.js',
'app/scripts/directives/directives.js',
'app/scripts/directives/query_directives.js',
'app/scripts/directives/dashboard_directives.js',
'app/scripts/filters.js',
'test/mocks/*.js',
'test/unit/*.js'
],
// list of files / patterns to exclude
exclude: [],
// web server port
port: 8080,
// Start these browsers, currently available:
// - Chrome
// - ChromeCanary
// - Firefox
// - Opera
// - Safari (only Mac)
// - PhantomJS
// - IE (only Windows)
browsers: [
'PhantomJS'
],
// Which plugins to enable
plugins: [
'karma-phantomjs-launcher',
'karma-jasmine'
],
// Continuous Integration mode
// if true, it capture browsers, run tests and exit
singleRun: false,
colors: true,
// level of logging
// possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG
logLevel: config.LOG_INFO,
// Uncomment the following lines if you are using grunt's server to run the tests
// proxies: {
// '/': 'http://localhost:9000/'
// },
// URL root prevent conflicts with the site root
// urlRoot: '_karma_'
});
};

View File

@@ -0,0 +1,76 @@
featureFlags = [];
currentUser = {
id: 1,
name: 'John Mock',
email: 'john@example.com',
groups: ['default'],
permissions: [],
canEdit: function(object) {
var user_id = object.user_id || (object.user && object.user.id);
return user_id && (user_id == currentUser.id);
},
hasPermission: function(permission) {
return this.permissions.indexOf(permission) != -1;
}
};
angular.module('redashMocks', [])
.value('mockData', {
query: {
"ttl": -1,
"query": "select name from users;",
"id": 1803,
"description": "",
"name": "my test query",
"created_at": "2014-01-07T16:11:31.859528+02:00",
"query_hash": "c89c235bc73e462e9702debc56adc309",
"user": {
"email": "amirn@everything.me",
"id": 48,
"name": "Amir Nissim"
},
"visualizations": [{
"description": "",
"options": {},
"type": "TABLE",
"id": 636,
"name": "Table"
}],
"api_key": "123456789",
"data_source_id": 1,
"latest_query_data_id": 106632,
"latest_query_data": {
"retrieved_at": "2014-07-29T10:49:10.951364+03:00",
"query_hash": "c89c235bc73e462e9702debc56adc309",
"query": "select name from users;",
"runtime": 0.0139260292053223,
"data": {
"rows": [{
"name": "Amir Nissim"
}, {
"name": "Arik Fraimovich"
}],
"columns": [{
"friendly_name": "name",
"type": null,
"name": "name"
}, {
"friendly_name": "mail::filter",
"type": null,
"name": "mail::filter"
}]
},
"id": 106632,
"data_source_id": 1
}
}
});

View File

@@ -0,0 +1,5 @@
describe('example test', function() {
it('should expect the obvious', function() {
expect(0).toBe(0);
});
});

View File

@@ -0,0 +1,34 @@
'use strict';
describe('QueryViewCtrl', function() {
var scope;
var mockData;
beforeEach(module('redash', 'redashMocks'));
beforeEach(inject(function($injector, $controller, $rootScope, Query, _mockData_) {
mockData = _mockData_;
scope = $rootScope.$new();
var route = {
current: {
locals: {
query: new Query(mockData.query)
}
}
};
$controller('QueryViewCtrl', {$scope: scope, $route: route});
}));
it('should have a query', function() {
expect(scope.query).toBeDefined();
});
it('should update the executing state', function() {
expect(scope.queryExecuting).toBe(false);
scope.executeQuery();
expect(scope.queryExecuting).toBe(true);
});
});

View File

@@ -76,7 +76,7 @@ def create_and_login_user(app, user):
user_object.save()
except models.User.DoesNotExist:
logger.debug("Creating user object (%r)", user.name)
user_object = models.User.create(name=user.name, email=user.email, groups = ['default'])
user_object = models.User.create(name=user.name, email=user.email, groups = models.User.DEFAULT_GROUPS)
login_user(user_object, remember=True)

8
redash/cache.py Normal file
View File

@@ -0,0 +1,8 @@
from flask import make_response
from functools import update_wrapper
ONE_YEAR = 60 * 60 * 24 * 365.25
headers = {
'Cache-Control': 'max-age=%d' % ONE_YEAR
}

View File

@@ -26,6 +26,7 @@ from redash.wsgi import app, auth, api
import logging
from tasks import QueryTask
from cache import headers as cache_headers
@app.route('/ping', methods=['GET'])
def ping():
@@ -110,7 +111,21 @@ def status_api():
manager_status = redis_connection.hgetall('redash:status')
status['manager'] = manager_status
status['manager']['queue_size'] = 'Unknown'#redis_connection.zcard('jobs')
status['manager']['queue_size'] = redis_connection.llen('queries') + redis_connection.llen('scheduled_queries')
status['manager']['outdated_queries_count'] = models.Query.outdated_queries().count()
queues = {}
for ds in models.DataSource.select():
for queue in (ds.queue_name, ds.scheduled_queue_name):
queues.setdefault(queue, set())
queues[queue].add(ds.name)
status['manager']['queues'] = {}
for queue, sources in queues.iteritems():
status['manager']['queues'][queue] = {
'data_sources': ', '.join(sources),
'size': redis_connection.llen(queue)
}
return jsonify(status)
@@ -164,7 +179,7 @@ api.add_resource(MetricsAPI, '/api/metrics/v1/send', endpoint='metrics')
class DataSourceListAPI(BaseResource):
def get(self):
data_sources = [ds.to_dict() for ds in models.DataSource.select()]
data_sources = [ds.to_dict() for ds in models.DataSource.all()]
return data_sources
api.add_resource(DataSourceListAPI, '/api/data_sources', endpoint='data_sources')
@@ -280,11 +295,11 @@ class QueryListAPI(BaseResource):
query.create_default_visualizations()
return query.to_dict(with_result=False)
return query.to_dict()
@require_permission('view_query')
def get(self):
return [q.to_dict(with_result=False, with_stats=True) for q in models.Query.all_queries()]
return [q.to_dict(with_stats=True) for q in models.Query.all_queries()]
class QueryAPI(BaseResource):
@@ -304,7 +319,7 @@ class QueryAPI(BaseResource):
query = models.Query.get_by_id(query_id)
return query.to_dict(with_result=False, with_visualizations=True)
return query.to_dict(with_visualizations=True)
@require_permission('view_query')
def get(self, query_id):
@@ -338,6 +353,7 @@ class VisualizationAPI(BaseResource):
if 'options' in kwargs:
kwargs['options'] = json.dumps(kwargs['options'])
kwargs.pop('id', None)
kwargs.pop('query_id', None)
update = models.Visualization.update(**kwargs).where(models.Visualization.id == visualization_id)
update.execute()
@@ -377,7 +393,7 @@ class QueryResultListAPI(BaseResource):
'error': 'Access denied for table(s): %s' % (metadata.used_tables)
}
}
models.ActivityLog(
user=self.current_user,
type=models.ActivityLog.QUERY_EXECUTION,
@@ -402,7 +418,8 @@ class QueryResultAPI(BaseResource):
def get(self, query_result_id):
query_result = models.QueryResult.get_by_id(query_result_id)
if query_result:
return {'query_result': query_result.to_dict()}
data = json.dumps({'query_result': query_result.to_dict()}, cls=utils.JSONEncoder)
return make_response(data, 200, cache_headers)
else:
abort(404)

View File

@@ -1,3 +1,4 @@
import datetime
import httplib2
import json
import logging
@@ -15,6 +16,38 @@ except ImportError:
from redash.utils import JSONEncoder
types_map = {
'INTEGER': 'integer',
'FLOAT': 'float',
'BOOLEAN': 'boolean',
'STRING': 'string',
'TIMESTAMP': 'datetime',
}
def transform_row(row, fields):
column_index = 0
row_data = {}
for cell in row["f"]:
field = fields[column_index]
cell_value = cell['v']
if cell_value is None:
pass
# Otherwise just cast the value
elif field['type'] == 'INTEGER':
cell_value = int(cell_value)
elif field['type'] == 'FLOAT':
cell_value = float(cell_value)
elif field['type'] == 'BOOLEAN':
cell_value = cell_value.lower() == "true"
elif field['type'] == 'TIMESTAMP':
cell_value = datetime.datetime.fromtimestamp(float(cell_value))
row_data[field["name"]] = cell_value
column_index += 1
return row_data
def bigquery(connection_string):
def load_key(filename):
@@ -67,28 +100,21 @@ def bigquery(connection_string):
query_reply = get_query_results(jobs, project_id=project_id,
job_id=insert_response['jobReference']['jobId'], start_index=current_row)
logging.debug("bigquery replied: %s", query_reply)
rows = []
field_names = []
for f in query_reply["schema"]["fields"]:
field_names.append(f["name"])
while ("rows" in query_reply) and current_row < query_reply['totalRows']:
for row in query_reply["rows"]:
row_data = {}
column_index = 0
for cell in row["f"]:
row_data[field_names[column_index]] = cell["v"]
column_index += 1
rows.append(row_data)
rows.append(transform_row(row, query_reply["schema"]["fields"]))
current_row += len(query_reply['rows'])
query_reply = jobs.getQueryResults(projectId=project_id, jobId=query_reply['jobReference']['jobId'],
startIndex=current_row).execute()
columns = [{'name': name,
'friendly_name': name,
'type': None} for name in field_names]
columns = [{'name': f["name"],
'friendly_name': f["name"],
'type': types_map.get(f['type'], "string")} for f in query_reply["schema"]["fields"]]
data = {
"columns": columns,

View File

@@ -78,7 +78,7 @@ class ApiUser(UserMixin):
class Group(BaseModel):
DEFAULT_PERMISSIONS = ['create_dashboard', 'create_query', 'edit_dashboard', 'edit_query',
'view_query', 'view_source', 'execute_query']
id = peewee.PrimaryKeyField()
name = peewee.CharField(max_length=100)
permissions = ArrayField(peewee.CharField, default=DEFAULT_PERMISSIONS)
@@ -102,11 +102,13 @@ class Group(BaseModel):
class User(BaseModel, UserMixin):
DEFAULT_GROUPS = ['default']
id = peewee.PrimaryKeyField()
name = peewee.CharField(max_length=320)
email = peewee.CharField(max_length=320, index=True, unique=True)
password_hash = peewee.CharField(max_length=128, null=True)
groups = ArrayField(peewee.CharField, default=['default'])
groups = ArrayField(peewee.CharField, default=DEFAULT_GROUPS)
class Meta:
db_table = 'users'
@@ -137,6 +139,10 @@ class User(BaseModel, UserMixin):
return self._allowed_tables
@classmethod
def get_by_email(cls, email):
return cls.get(cls.email == email)
def __unicode__(self):
return '%r, %r' % (self.name, self.email)
@@ -149,7 +155,7 @@ class User(BaseModel, UserMixin):
class ActivityLog(BaseModel):
QUERY_EXECUTION = 1
id = peewee.PrimaryKeyField()
user = peewee.ForeignKeyField(User)
type = peewee.IntegerField()
@@ -191,6 +197,10 @@ class DataSource(BaseModel):
'type': self.type
}
@classmethod
def all(cls):
return cls.select().order_by(cls.id.asc())
class QueryResult(BaseModel):
id = peewee.PrimaryKeyField()
@@ -275,7 +285,7 @@ class Query(BaseModel):
type="TABLE", options="{}")
table_visualization.save()
def to_dict(self, with_result=True, with_stats=False, with_visualizations=False, with_user=True):
def to_dict(self, with_stats=False, with_visualizations=False, with_user=True):
d = {
'id': self.id,
'latest_query_data_id': self._data.get('latest_query_data', None),
@@ -305,9 +315,6 @@ class Query(BaseModel):
d['visualizations'] = [vis.to_dict(with_query=False)
for vis in self.visualizations]
if with_result and self.latest_query_data:
d['latest_query_data'] = self.latest_query_data.to_dict()
return d
@classmethod
@@ -337,7 +344,7 @@ class Query(BaseModel):
peewee.SQL("(now() at time zone 'utc')"))
queries = cls.select(cls, DataSource).join(DataSource) \
.where(cls.id << outdated_queries_ids )
.where(cls.id << outdated_queries_ids)
return queries
@@ -381,13 +388,11 @@ class Dashboard(BaseModel):
layout = json.loads(self.layout)
if with_widgets:
widgets = Widget.select(Widget, Visualization, Query, QueryResult, User)\
widgets = Widget.select(Widget, Visualization, Query, User)\
.where(Widget.dashboard == self.id)\
.join(Visualization, join_type=peewee.JOIN_LEFT_OUTER)\
.join(Query, join_type=peewee.JOIN_LEFT_OUTER)\
.join(User, join_type=peewee.JOIN_LEFT_OUTER)\
.switch(Query)\
.join(QueryResult, join_type=peewee.JOIN_LEFT_OUTER)
.join(User, join_type=peewee.JOIN_LEFT_OUTER)
widgets = {w.id: w.to_dict() for w in widgets}
# The following is a workaround for cases when the widget object gets deleted without the dashboard layout
@@ -500,7 +505,23 @@ class Widget(BaseModel):
def __unicode__(self):
return u"%s" % self.id
all_models = (DataSource, User, QueryResult, Query, Dashboard, Visualization, Widget, ActivityLog, Group)
class Event(BaseModel):
user = peewee.ForeignKeyField(User, related_name="events")
action = peewee.CharField()
object_type = peewee.CharField()
object_id = peewee.CharField(null=True)
additional_properties = peewee.TextField(null=True)
created_at = peewee.DateTimeField(default=datetime.datetime.now)
class Meta:
db_table = 'events'
def __unicode__(self):
return u"%s,%s,%s,%s" % (self._data['user'], self.action, self.object_type, self.object_id)
all_models = (DataSource, User, QueryResult, Query, Dashboard, Visualization, Widget, ActivityLog, Group, Event)
def init_db():
@@ -520,4 +541,4 @@ def create_db(create_tables, drop_tables):
if create_tables and not model.table_exists():
model.create_table()
db.close_db(None)
db.close_db(None)

View File

@@ -68,9 +68,9 @@ class QueryTask(object):
pipe.multi()
if scheduled:
queue_name = data_source.queue_name
else:
queue_name = data_source.scheduled_queue_name
else:
queue_name = data_source.queue_name
result = execute_query.apply_async(args=(query, data_source.id), queue=queue_name)
job = cls(async_result=result)
@@ -120,6 +120,7 @@ class QueryTask(object):
def _job_lock_id(query_hash, data_source_id):
return "query_hash_job:%s:%s" % (data_source_id, query_hash)
@celery.task(base=BaseTask)
def refresh_queries():
# self.status['last_refresh_at'] = time.time()
@@ -149,6 +150,7 @@ def refresh_queries():
statsd_client.gauge('manager.seconds_since_refresh', now - float(status.get('last_refresh_at', now)))
@celery.task(bind=True, base=BaseTask, track_started=True)
def execute_query(self, query, data_source_id):
# TODO: maybe this should be a class?