Compare commits
175 Commits
v1.0.0-rc.
...
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
939aae086f | ||
|
|
742e38b08d | ||
|
|
3c7c93fc9f | ||
|
|
53ffff9759 | ||
|
|
2e7fafc4d8 | ||
|
|
c66b09effe | ||
|
|
a087fe4bcd | ||
|
|
1f4946cc04 | ||
|
|
08505a2208 | ||
|
|
e1c186bbf8 | ||
|
|
c83d354eed | ||
|
|
81063731c9 | ||
|
|
f66fe5ff80 | ||
|
|
8425698583 | ||
|
|
8b08b1a563 | ||
|
|
15b228b754 | ||
|
|
1db4157b29 | ||
|
|
079530cf63 | ||
|
|
d2370a94c7 | ||
|
|
903463972b | ||
|
|
2707e24f30 | ||
|
|
3df826692c | ||
|
|
1142a441fc | ||
|
|
53268989c5 | ||
|
|
83ed9fdc51 | ||
|
|
0dc98e87a6 | ||
|
|
0cf4db1137 | ||
|
|
4e27069d07 | ||
|
|
3fcd07bc1c | ||
|
|
3414ff7331 | ||
|
|
04cd798c48 | ||
|
|
50dcf23b1a | ||
|
|
1bb4d6d534 | ||
|
|
66a5e394de | ||
|
|
c4ab0916cc | ||
|
|
73cb6925d3 | ||
|
|
aaf0da4b70 | ||
|
|
c99bd03d99 | ||
|
|
7fbb1b9229 | ||
|
|
ba54d68513 | ||
|
|
f73cbf3b51 | ||
|
|
3f047348e2 | ||
|
|
10fe3c5373 | ||
|
|
9c8755c9ae | ||
|
|
e8908d04bb | ||
|
|
293f9dcaf6 | ||
|
|
ce31b13ff6 | ||
|
|
a033dc4569 | ||
|
|
6ff338964b | ||
|
|
97a7701879 | ||
|
|
7558b391a9 | ||
|
|
b6bed112ee | ||
|
|
9417dcb2c2 | ||
|
|
5f106a1eee | ||
|
|
cda05c73c7 | ||
|
|
95398697cb | ||
|
|
dc019cc37a | ||
|
|
72cb5babe6 | ||
|
|
ebc2e12621 | ||
|
|
f011d3060a | ||
|
|
8c5f71a0a1 | ||
|
|
da00e74491 | ||
|
|
b56ff1357e | ||
|
|
ecd4d659a8 | ||
|
|
fec5565396 | ||
|
|
6ec5ea5c28 | ||
|
|
3f8e32cc1f | ||
|
|
be6426014d | ||
|
|
8b4643d6ac | ||
|
|
d8a0885953 | ||
|
|
83e6b6f50c | ||
|
|
928bd83967 | ||
|
|
230fe15cde | ||
|
|
72ad16a8b3 | ||
|
|
23cc632d5a | ||
|
|
1cf2bb1bb2 | ||
|
|
181031957f | ||
|
|
cfa9a45fc8 | ||
|
|
9bb87e711a | ||
|
|
255a01f786 | ||
|
|
69c26f2c0d | ||
|
|
3650e21458 | ||
|
|
8eefd0e9c4 | ||
|
|
c72a097808 | ||
|
|
2ffda6f5c5 | ||
|
|
ce8ffae152 | ||
|
|
b54dd27959 | ||
|
|
3e807e5b41 | ||
|
|
20f1a60f90 | ||
|
|
9d2619b856 | ||
|
|
a2c7f6df7a | ||
|
|
15a87db5d5 | ||
|
|
2f86466309 | ||
|
|
bccfef533e | ||
|
|
ef020e88e7 | ||
|
|
222a6069cb | ||
|
|
6b6df84bce | ||
|
|
fcfd204ec6 | ||
|
|
57e6c5f05e | ||
|
|
683e369d86 | ||
|
|
f12596a6fc | ||
|
|
09239439ae | ||
|
|
2bb11dffca | ||
|
|
2f019a0897 | ||
|
|
1350555931 | ||
|
|
6d3aa3b53c | ||
|
|
2407b115e4 | ||
|
|
ca3e125da8 | ||
|
|
2d82c4dc98 | ||
|
|
84ca02be09 | ||
|
|
61244dead3 | ||
|
|
907b33b5a0 | ||
|
|
e6fc73f444 | ||
|
|
672347ba8b | ||
|
|
db465ffe58 | ||
|
|
2a447137d4 | ||
|
|
18a157ac84 | ||
|
|
3864f11694 | ||
|
|
8b59815bf2 | ||
|
|
a98df94399 | ||
|
|
2e751b3dad | ||
|
|
b2e747caef | ||
|
|
f9da6ddcdd | ||
|
|
99cef97c89 | ||
|
|
2071ca1bc8 | ||
|
|
5815d635a0 | ||
|
|
517e5bcddb | ||
|
|
eaa2ec877c | ||
|
|
0e68228e6e | ||
|
|
6e5435261b | ||
|
|
1cc9b87ead | ||
|
|
bd9ad3140d | ||
|
|
3e23143910 | ||
|
|
0f95d12e83 | ||
|
|
fd37fd8545 | ||
|
|
742199f05c | ||
|
|
902fb44782 | ||
|
|
10e78bb4e4 | ||
|
|
e4659d0485 | ||
|
|
a25302773d | ||
|
|
2188baca16 | ||
|
|
8940533176 | ||
|
|
1367b63ae1 | ||
|
|
c2a9e2e960 | ||
|
|
8a41328033 | ||
|
|
ce16124d7b | ||
|
|
c547752dc6 | ||
|
|
3981c1c8a7 | ||
|
|
f732f30bf0 | ||
|
|
9c0f0cb044 | ||
|
|
f01399c33d | ||
|
|
253f2e613c | ||
|
|
f2879a1e3d | ||
|
|
af978e966d | ||
|
|
0151360fdf | ||
|
|
d1b0a9580d | ||
|
|
9d796b20df | ||
|
|
574a7573ce | ||
|
|
db9c925cbb | ||
|
|
9ef6836175 | ||
|
|
351cd62189 | ||
|
|
7bbc782e5d | ||
|
|
50d20ff277 | ||
|
|
964926aaab | ||
|
|
b67ecde107 | ||
|
|
6338596710 | ||
|
|
5361a99b22 | ||
|
|
01a8075a67 | ||
|
|
209e714084 | ||
|
|
e3d0b4075e | ||
|
|
ad18128794 | ||
|
|
71fa013970 | ||
|
|
dd6028384d | ||
|
|
9b9a752f78 | ||
|
|
78408e50c5 |
14
.editorconfig
Normal file
@@ -0,0 +1,14 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{js,css,html}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
91
CHANGELOG.md
@@ -1,5 +1,96 @@
|
||||
# Change Log
|
||||
|
||||
## v1.0.2 - 2017-04-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix: favicon wasn't showing up.
|
||||
- Fix: support for unicode in dashboard tags. @deecay
|
||||
- Fix: page freezes when rendering large result set.
|
||||
- Fix: chart embeds were not rendering in PhantomJS.
|
||||
|
||||
## v1.0.1 - 2017-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- Add: bubble charts support.
|
||||
- Add "Refresh Schema" button to the datasource @44px
|
||||
- [Data Sources] Add: ATSD query runner @rmakulov
|
||||
- [Data Sources] Add: SalesForce query runner @msnider
|
||||
- Add: scheduled query backoff in case of errors @washort
|
||||
- Add: use results row count as the value for the counter visualization. @deecay
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved CSV/Excel query results generation code to models. @akiray03
|
||||
- Add support for filtered data in Pivot table visualization @deecay
|
||||
- Friendlier labels for archived state of dashboard/query
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix: optimize queries to avoid N+1 queries.
|
||||
- Fix: percent stacking math was wrong. @spasovski
|
||||
- Fix: set query filter to match value from URL query string. @benmargo
|
||||
- [Clickhouse] Fix: detection of various data types. @denisov-vlad
|
||||
- Fix: user can't edit their own alert.
|
||||
- Fix: angular minification issue in textbox editor and schema browser.
|
||||
- Fixes to better support IE11 (add polyfill for Object.assign and show vertical scrollbar). @deecay
|
||||
- Fix: datetime parameters were not using a date picker.
|
||||
- Fix: Impala schema wasn't loading.
|
||||
- Fix: query embed dialog close button wasn't working @r0fls
|
||||
- Fix: make errors from Presto runner JSON-serializable @washort
|
||||
- Fix: race condition in query task status reporting @washort
|
||||
- Fix: remove $$hashKey from Pivot table
|
||||
- Fix: map visualization had severe performance issue.
|
||||
- Fix: pemrission dialog wasn't rendering.
|
||||
- Fix: word cloud visualization didn't show column names.
|
||||
- Fix: wrong timestamps in admin tasks page.
|
||||
- Fix: page header wasn't updating on dashboards page @MichaelJAndy
|
||||
- Fix: keyboard shortcuts didn't work in parameter inputs
|
||||
|
||||
## v1.0.0-rc.2 - 2017-02-22
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- [#1563](https://github.com/getredash/redash/pull/1563) Send events to webhook as JSON with a schema.
|
||||
- [#1601] [Presto] friendlier error messages. (@aslotnick)
|
||||
- Move the query runner unavailable log message to be DEBUG level instead of WARNING, as it was mainly confusing people.
|
||||
- Remove "Send to Cloud" button from Plotly based visualizations.
|
||||
- Change Plotly's default hover mode to "Compare".
|
||||
- [#1612] Change: Improvements to the dashboards list page.
|
||||
|
||||
### Fixed
|
||||
|
||||
- [#1564] Fix: map visualization column picker wasn't populated. (@janusd)
|
||||
- [#1597] [SQL Server] Fix: schema wasn't loading on case sensitive servers. (@deecay)
|
||||
- Fix: dashbonard owner couldn't edit his dashboard.
|
||||
- Fix: toggle_publish event wasn't logged properly.
|
||||
- Fix: events with API keys were not logged.
|
||||
- Fix: share dashboard dialog was broken after code minification.
|
||||
- Fix: public dashboard endpoint was broken.
|
||||
- Fix: public dashboard page was broken after code minification.
|
||||
- Fix: visualization embed page was broken after code minification.
|
||||
- Fix: schema browser has dark background.
|
||||
- Fix: Google button missing on invite page.
|
||||
- Fix: global parameters don't render on dashboards with text boxes.
|
||||
- Fix: sunburst / Sankey visualizations have bad data.
|
||||
- Fix: extra whitespace created by the filters component.
|
||||
- Fix: query results cleanup task was trying to delete query objects.
|
||||
- Fix: alert subscriptions were not triggered.
|
||||
- [DynamoDB] Fix: count(*) queries were broken. (@kopanitsa)
|
||||
- Fix: Redash is using too many database connections.
|
||||
- Fix: download links were not working in dashboards.
|
||||
- Fix: the first selection in multi filters was broken in dashboards.
|
||||
|
||||
### Other
|
||||
|
||||
- [#1555] Change sourcemaps to generate a sourcemap per module. (@44px)
|
||||
- [#1570] Fix Docker Compose configuration for nginx. (@btmc)
|
||||
- [#1582] Update Dockerfile to build frontend assets and update the folder ownership.
|
||||
- Dockerfile: change the uid of the redash user to match host user uid.
|
||||
- Update npm-shrinkwrap.json file to use http proctocol instead of git. (@deecay)
|
||||
|
||||
## v1.0.0-rc.1 - 2017-01-31
|
||||
|
||||
This version has two big changes behind the scenes:
|
||||
|
||||
@@ -6,5 +6,8 @@ COPY requirements.txt requirements_dev.txt requirements_all_ds.txt ./
|
||||
RUN pip install -r requirements.txt -r requirements_dev.txt -r requirements_all_ds.txt
|
||||
|
||||
COPY . ./
|
||||
RUN npm install && npm run build && rm -rf node_modules
|
||||
RUN chown -R redash /app
|
||||
USER redash
|
||||
|
||||
ENTRYPOINT ["/app/bin/docker-entrypoint"]
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
web: ./manage.py runserver -d -r -p $PORT --host 0.0.0.0
|
||||
worker: celery worker --app=redash.worker -c${REDASH_HEROKU_CELERY_WORKER_COUNT:-2} --beat -Q queries,celery,scheduled_queries
|
||||
@@ -6,7 +6,7 @@ worker() {
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries,celery}
|
||||
|
||||
echo "Starting $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
exec sudo -E -u redash /usr/local/bin/celery worker --app=redash.worker -c$WORKERS_COUNT -Q$QUEUES -linfo --maxtasksperchild=10 -Ofair
|
||||
exec /usr/local/bin/celery worker --app=redash.worker -c$WORKERS_COUNT -Q$QUEUES -linfo --maxtasksperchild=10 -Ofair
|
||||
}
|
||||
|
||||
scheduler() {
|
||||
@@ -15,11 +15,11 @@ scheduler() {
|
||||
|
||||
echo "Starting scheduler and $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
|
||||
exec sudo -E -u redash /usr/local/bin/celery worker --app=redash.worker --beat -c$WORKERS_COUNT -Q$QUEUES -linfo --maxtasksperchild=10 -Ofair
|
||||
exec /usr/local/bin/celery worker --app=redash.worker --beat -c$WORKERS_COUNT -Q$QUEUES -linfo --maxtasksperchild=10 -Ofair
|
||||
}
|
||||
|
||||
server() {
|
||||
exec sudo -E -u redash /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w4 redash.wsgi:app
|
||||
exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w4 redash.wsgi:app
|
||||
}
|
||||
|
||||
help() {
|
||||
@@ -35,11 +35,12 @@ help() {
|
||||
echo "shell -- open shell"
|
||||
echo "dev_server -- start Flask development server with debugger and auto reload"
|
||||
echo "create_db -- create database tables"
|
||||
echo "manage -- CLI to manage redash"
|
||||
}
|
||||
|
||||
tests() {
|
||||
export REDASH_DATABASE_URL="postgresql://postgres@postgres/tests"
|
||||
exec sudo -E -u redash make test
|
||||
exec make test
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
@@ -56,13 +57,17 @@ case "$1" in
|
||||
scheduler
|
||||
;;
|
||||
dev_server)
|
||||
exec sudo -E -u redash /app/manage.py runserver --debugger --reload -h 0.0.0.0
|
||||
exec /app/manage.py runserver --debugger --reload -h 0.0.0.0
|
||||
;;
|
||||
shell)
|
||||
exec sudo -E -u redash /app/manage.py shell
|
||||
exec /app/manage.py shell
|
||||
;;
|
||||
create_db)
|
||||
exec sudo -E -u redash /app/manage.py database create_tables
|
||||
exec /app/manage.py database create_tables
|
||||
;;
|
||||
manage)
|
||||
shift
|
||||
exec /app/manage.py $*
|
||||
;;
|
||||
tests)
|
||||
tests
|
||||
|
||||
11
circle.yml
@@ -5,11 +5,8 @@ machine:
|
||||
node:
|
||||
version:
|
||||
6.9.1
|
||||
python:
|
||||
version:
|
||||
2.7.3
|
||||
dependencies:
|
||||
pre:
|
||||
override:
|
||||
- pip install --upgrade setuptools
|
||||
- pip install -r requirements_dev.txt
|
||||
- pip install -r requirements.txt
|
||||
@@ -28,9 +25,9 @@ deployment:
|
||||
# - make upload
|
||||
#- echo "client/app" >> .dockerignore
|
||||
#- docker pull redash/redash:latest
|
||||
#- docker build -t redash/redash:$(./manage.py version | sed -e "s/\+/./") .
|
||||
#- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS
|
||||
#- docker push redash/redash:$(./manage.py version | sed -e "s/\+/./")
|
||||
- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS
|
||||
- docker build -t redash/redash:$(./manage.py version | sed -e "s/\+/./") .
|
||||
- docker push redash/redash:$(./manage.py version | sed -e "s/\+/./")
|
||||
notify:
|
||||
webhooks:
|
||||
- url: https://webhooks.gitter.im/e/895d09c3165a0913ac2f
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"presets": ["es2015", "stage-2"]
|
||||
"presets": ["es2015", "stage-2"],
|
||||
"plugins": ["transform-object-assign"]
|
||||
}
|
||||
|
||||
@@ -4,12 +4,17 @@ body {
|
||||
|
||||
body.headless {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
body.headless nav.app-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.headless div#footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a[ng-click] {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -415,6 +420,16 @@ counter-renderer counter-name {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.schema-control {
|
||||
display: flex;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.schema-control .form-control {
|
||||
height: 30px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.schema-browser {
|
||||
height: calc(100% - 45px);
|
||||
overflow-y: auto;
|
||||
@@ -597,9 +612,16 @@ div.table-name:hover {
|
||||
|
||||
.collapsing,
|
||||
.collapse.in {
|
||||
background: #222;
|
||||
padding: 5px 10px;
|
||||
transition: all 0.35s ease;
|
||||
padding: 5px 10px;
|
||||
transition: all 0.35s ease;
|
||||
}
|
||||
|
||||
.schema-browser .collapse.in {
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
.navbar .collapse.in {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
/* Fixes for SuperFlat */
|
||||
@@ -667,3 +689,12 @@ div.table-name:hover {
|
||||
.m-2{
|
||||
margin:2px;
|
||||
}
|
||||
|
||||
.dropdown-menu > .disabled{
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* The real magic ;) */
|
||||
.dropdown-menu > .disabled > a{
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -6430,7 +6430,7 @@ a {
|
||||
}
|
||||
html {
|
||||
overflow-x: hidden\0/;
|
||||
-ms-overflow-style: none;
|
||||
-ms-overflow-style: auto;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="row in $ctrl.rows">
|
||||
<tr ng-repeat="row in $ctrl.rowsToDisplay">
|
||||
<td ng-repeat="column in $ctrl.columns" ng-bind-html="$ctrl.sanitize(column.formatFunction(row[column.name]))">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -15,7 +15,7 @@ function DynamicTable($sanitize) {
|
||||
const first = this.count * (this.page - 1);
|
||||
const last = this.count * (this.page);
|
||||
|
||||
this.rows = this.allRows.slice(first, last);
|
||||
this.rowsToDisplay = this.rows.slice(first, last);
|
||||
};
|
||||
|
||||
this.$onChanges = (changes) => {
|
||||
@@ -24,10 +24,10 @@ function DynamicTable($sanitize) {
|
||||
}
|
||||
|
||||
if (changes.rows) {
|
||||
this.allRows = changes.rows.currentValue;
|
||||
this.rows = changes.rows.currentValue;
|
||||
}
|
||||
|
||||
this.rowsCount = this.allRows.length;
|
||||
this.rowsCount = this.rows.length;
|
||||
|
||||
this.pageChanged();
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<div class="container bg-white p-5" ng-show="$ctrl.filters">
|
||||
<div class="container bg-white p-5" ng-show="$ctrl.filters | notEmpty">
|
||||
<div class="row">
|
||||
<div class="col-sm-6 m-t-5" ng-repeat="filter in $ctrl.filters">
|
||||
<ui-select ng-model="filter.current" ng-if="!filter.multiple" on-select="$ctrl.filterChangeListener(filter, $model)">
|
||||
<ui-select ng-model="filter.current" ng-if="!filter.multiple" on-select="$ctrl.filterChangeListener(filter, $model)" on-remove="$ctrl.filterChangeListener(filter, $model)">
|
||||
<ui-select-match placeholder="Select value for {{filter.friendlyName}}...">{{filter.friendlyName}}: {{$select.selected | filterValue:filter}}</ui-select-match>
|
||||
<ui-select-choices repeat="value in filter.values | filter: $select.search">
|
||||
{{value | filterValue:filter }}
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
|
||||
<ui-select ng-model="filter.current" multiple ng-if="filter.multiple" on-select="$ctrl.filterChangeListener(filter, $model)">
|
||||
<ui-select ng-model="filter.current" multiple ng-if="filter.multiple" on-select="$ctrl.filterChangeListener(filter, $model)" on-remove="$ctrl.filterChangeListener(filter, $model)">
|
||||
<ui-select-match placeholder="Select value for {{filter.friendlyName}}...">{{filter.friendlyName}}: {{$item | filterValue:filter}}</ui-select-match>
|
||||
<ui-select-choices repeat="value in filter.values | filter: $select.search">
|
||||
{{value | filterValue:filter }}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<label>{{param.title}}</label>
|
||||
<button class="btn btn-default btn-xs" ng-click="showParameterSettings(param)" ng-if="editable"><i class="zmdi zmdi-settings"></i></button>
|
||||
<span ng-switch="param.type">
|
||||
<input ng-switch-when="datetime-with-seconds" type="datetime-local" step="1" class="form-control" ng-model="param.value">
|
||||
<input ng-switch-when="datetime" type="text" class="form-control" ng-model="param.value">
|
||||
<input ng-switch-when="date" type="text" class="form-control" ng-model="param.value">
|
||||
<input ng-switch-default type="{{param.type}}" class="form-control" ng-model="param.value">
|
||||
<input ng-switch-when="datetime-with-seconds" type="datetime-local" step="1" class="form-control" ng-model="param.ngModel">
|
||||
<input ng-switch-when="datetime-local" type="datetime-local" class="form-control" ng-model="param.ngModel">
|
||||
<input ng-switch-when="date" type="date" class="form-control" ng-model="param.ngModel">
|
||||
<input ng-switch-default type="{{param.type}}" class="form-control" ng-model="param.ngModel">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ const PermissionsEditorComponent = {
|
||||
dismiss: '&',
|
||||
},
|
||||
controller($http, User) {
|
||||
'ngInject';
|
||||
|
||||
this.grantees = [];
|
||||
this.newGrantees = {};
|
||||
this.aclUrl = this.resolve.aclUrl.url;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import moment from 'moment';
|
||||
|
||||
export default function (ngModule) {
|
||||
ngModule.filter('toMilliseconds', () => value => value * 1000.0);
|
||||
|
||||
ngModule.filter('dateTime', clientConfig =>
|
||||
function dateTime(value) {
|
||||
if (!value) {
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<base href="/">
|
||||
<title>Redash</title>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="./assets/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="./assets/images/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="./assets/images/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// This polyfill is needed to support PhantomJS which we use to generate PNGs from embeds.
|
||||
import 'core-js/fn/typed/array-buffer';
|
||||
|
||||
import 'material-design-iconic-font/dist/css/material-design-iconic-font.css';
|
||||
import 'font-awesome/css/font-awesome.css';
|
||||
import 'ui-select/dist/select.css';
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
<td>{{row.query_id}}</td>
|
||||
<td>{{row.query_hash}}</td>
|
||||
<td>{{row.run_time | durationHumanize}}</td>
|
||||
<td>{{row.created_at | dateTime }}</td>
|
||||
<td>{{row.started_at | dateTime }}</td>
|
||||
<td>{{row.updated_at | dateTime }}</td>
|
||||
<td>{{row.created_at | toMilliseconds | dateTime }}</td>
|
||||
<td>{{row.started_at | toMilliseconds | dateTime }}</td>
|
||||
<td>{{row.updated_at | toMilliseconds | dateTime }}</td>
|
||||
<td ng-if="selectedTab === 'in_progress'">
|
||||
<cancel-query-button query-id="dataRow.query_id" task-id="dataRow.task_id"></cancel-query-button>
|
||||
</td>
|
||||
|
||||
@@ -27,8 +27,8 @@ function AlertCtrl($routeParams, $location, $sce, toastr, currentUser, Query, Ev
|
||||
} else {
|
||||
this.alert = Alert.get({ id: this.alertId }, (alert) => {
|
||||
this.onQuerySelected(new Query(alert.query));
|
||||
this.canEdit = currentUser.canEdit(this.alert);
|
||||
});
|
||||
this.canEdit = currentUser.canEdit(this.alert);
|
||||
}
|
||||
|
||||
this.ops = ['greater than', 'less than', 'equals'];
|
||||
|
||||
8
client/app/pages/dashboards/dashboard-list.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/* Prevent text selection on shift+click. */
|
||||
div.tags-list {
|
||||
-webkit-user-select: none; /* webkit (safari, chrome) browsers */
|
||||
-moz-user-select: none; /* mozilla browsers */
|
||||
-khtml-user-select: none; /* webkit (konqueror) browsers */
|
||||
-ms-user-select: none; /* IE10+ */
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
<div class="col-lg-3">
|
||||
<input type='text' class='form-control' placeholder="Search Dashboards..."
|
||||
ng-change="$ctrl.update()" ng-model="$ctrl.searchText"/>
|
||||
<div class='list-group m-t-20'>
|
||||
<h3 class='list-group-item m-0'>Tags</h3>
|
||||
<a ng-repeat='tag in $ctrl.allTags' ng-class='{"active": $ctrl.tagIsSelected(tag)}'
|
||||
class='list-group-item' ng-click='$ctrl.toggleTag(tag)'>
|
||||
<div class='list-group m-t-20 tags-list'>
|
||||
<a ng-repeat='tag in $ctrl.allTags' ng-class='{"active": $ctrl.tagIsSelected(tag)}'
|
||||
class='list-group-item' ng-click='$ctrl.toggleTag($event, tag)'>
|
||||
{{ tag }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -25,7 +24,7 @@
|
||||
<td>
|
||||
<a href="dashboard/{{ dashboard.slug }}">
|
||||
<span class="label label-primary m-2" ng-bind="tag" ng-repeat="tag in dashboard.tags"></span> {{ dashboard.untagged_name }}
|
||||
<span class="label label-warning" ng-if="dashboard.is_draft">Unpublished</span>
|
||||
<span class="label label-default" ng-if="dashboard.is_draft">Unpublished</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ dashboard.created_at | dateTime }}</td>
|
||||
|
||||
@@ -2,9 +2,11 @@ import _ from 'underscore';
|
||||
|
||||
import { Paginator } from '../../utils';
|
||||
import template from './dashboard-list.html';
|
||||
import './dashboard-list.css';
|
||||
|
||||
|
||||
function DashboardListCtrl(Dashboard, $location, clientConfig) {
|
||||
const self = this;
|
||||
const TAGS_REGEX = /(^([\w\s]|[^\u0000-\u007F])+):|(#([\w-]|[^\u0000-\u007F])+)/ig;
|
||||
|
||||
this.logoUrl = clientConfig.logoUrl;
|
||||
const page = parseInt($location.search().page || 1, 10);
|
||||
@@ -17,40 +19,48 @@ function DashboardListCtrl(Dashboard, $location, clientConfig) {
|
||||
|
||||
this.tagIsSelected = tag => this.selectedTags.indexOf(tag) > -1;
|
||||
|
||||
this.toggleTag = (tag) => {
|
||||
this.toggleTag = ($event, tag) => {
|
||||
if (this.tagIsSelected(tag)) {
|
||||
this.selectedTags = this.selectedTags.filter(e => e !== tag);
|
||||
} else {
|
||||
if ($event.shiftKey) {
|
||||
this.selectedTags = this.selectedTags.filter(e => e !== tag);
|
||||
} else {
|
||||
this.selectedTags = [];
|
||||
}
|
||||
} else if ($event.shiftKey) {
|
||||
this.selectedTags.push(tag);
|
||||
} else {
|
||||
this.selectedTags = [tag];
|
||||
}
|
||||
|
||||
this.update();
|
||||
};
|
||||
|
||||
this.allTags = [];
|
||||
this.dashboards.$promise.then((data) => {
|
||||
const out = data.map(dashboard => dashboard.name.match(/(^\w+):|(#\w+)/ig));
|
||||
this.allTags = _.unique(_.flatten(out)).filter(e => e);
|
||||
const out = data.map(dashboard => dashboard.name.match(TAGS_REGEX));
|
||||
this.allTags = _.unique(_.flatten(out)).filter(e => e).map(tag => tag.replace(/:$/, ''));
|
||||
this.allTags.sort();
|
||||
});
|
||||
|
||||
this.paginator = new Paginator([], { page });
|
||||
|
||||
this.update = () => {
|
||||
self.dashboards.$promise.then((data) => {
|
||||
this.dashboards.$promise.then((data) => {
|
||||
const filteredDashboards = data.map((dashboard) => {
|
||||
dashboard.tags = dashboard.name.match(/(^\w+):|(#\w+)/ig);
|
||||
dashboard.untagged_name = dashboard.name.replace(/(\w+):|(#\w+)/ig, '').trim();
|
||||
dashboard.tags = (dashboard.name.match(TAGS_REGEX) || []).map(tag => tag.replace(/:$/, ''));
|
||||
dashboard.untagged_name = dashboard.name.replace(TAGS_REGEX, '').trim();
|
||||
return dashboard;
|
||||
}).filter((value) => {
|
||||
if (self.selectedTags.length) {
|
||||
if (this.selectedTags.length) {
|
||||
const valueTags = new Set(value.tags);
|
||||
const tagMatch = self.selectedTags;
|
||||
const tagMatch = this.selectedTags;
|
||||
const filteredMatch = tagMatch.filter(x => valueTags.has(x));
|
||||
if (tagMatch.length !== filteredMatch.length) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (self.searchText && self.searchText.length) {
|
||||
if (!value.untagged_name.toLowerCase().includes(self.searchText)) {
|
||||
if (this.searchText && this.searchText.length) {
|
||||
if (!value.untagged_name.toLowerCase().includes(this.searchText.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +83,7 @@ export default function (ngModule) {
|
||||
const route = {
|
||||
template: '<page-dashboard-list></page-dashboard-list>',
|
||||
reloadOnSearch: false,
|
||||
title: 'Dashboards',
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<div class="container">
|
||||
<div class="row bg-white p-t-10 p-b-10 m-b-10">
|
||||
<div class="col-sm-9">
|
||||
<h3>{{$ctrl.dashboard.name}} <span class="label label-warning" ng-if="$ctrl.dashboard.is_draft">Unpublished</span></h3>
|
||||
<h3>{{$ctrl.dashboard.name}}
|
||||
<span class="label label-default" ng-if="$ctrl.dashboard.is_draft && !$ctrl.dashboard.is_archived">Unpublished</span>
|
||||
<span class="label label-warning" ng-if="$ctrl.dashboard.is_archived" uib-popover="This dashboard is archived and and won't appear in the dashboards list or search results." popover-placement="right" popover-trigger="'mouseenter'">Archived</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="col-sm-3 text-right">
|
||||
<h3>
|
||||
@@ -33,7 +36,7 @@
|
||||
<span class="zmdi zmdi-share"></span>
|
||||
</button>
|
||||
</span>
|
||||
<div class="btn-group hidden-print" role="group" ng-show="$ctrl.dashboard.canEdit()" uib-dropdown>
|
||||
<div class="btn-group hidden-print" role="group" ng-show="$ctrl.dashboard.canEdit()" uib-dropdown ng-if="!$ctrl.dashboard.is_archived">
|
||||
<button class="btn btn-default btn-sm dropdown-toggle" uib-dropdown-toggle>
|
||||
<span class="zmdi zmdi-more"></span>
|
||||
</button>
|
||||
@@ -50,10 +53,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-12 p-5 m-b-10 bg-orange c-white" ng-if="$ctrl.dashboard.is_archived">
|
||||
This dashboard is archived and won't appear in the dashboards list or search results.
|
||||
</div>
|
||||
|
||||
<div class="m-b-5">
|
||||
<parameters parameters="$ctrl.globalParameters" on-change="$ctrl.onGlobalParametersChange()"></parameters>
|
||||
</div>
|
||||
|
||||
@@ -31,13 +31,15 @@ function DashboardCtrl($rootScope, $routeParams, $location, $timeout, $q, $uibMo
|
||||
let globalParams = {};
|
||||
this.dashboard.widgets.forEach(row =>
|
||||
row.forEach((widget) => {
|
||||
widget.getQuery().getParametersDefs().filter(p => p.global).forEach((param) => {
|
||||
const defaults = {};
|
||||
defaults[param.name] = _.clone(param);
|
||||
defaults[param.name].locals = [];
|
||||
globalParams = _.defaults(globalParams, defaults);
|
||||
globalParams[param.name].locals.push(param);
|
||||
});
|
||||
if (widget.getQuery()) {
|
||||
widget.getQuery().getParametersDefs().filter(p => p.global).forEach((param) => {
|
||||
const defaults = {};
|
||||
defaults[param.name] = _.create(Object.getPrototypeOf(param), param);
|
||||
defaults[param.name].locals = [];
|
||||
globalParams = _.defaults(globalParams, defaults);
|
||||
globalParams[param.name].locals.push(param);
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
this.globalParameters = _.values(globalParams);
|
||||
@@ -82,13 +84,14 @@ function DashboardCtrl($rootScope, $routeParams, $location, $timeout, $q, $uibMo
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasQueryStringValue) {
|
||||
queryFilter.current = $location.search()[queryFilter.name];
|
||||
}
|
||||
|
||||
if (!_.has(filters, queryFilter.name)) {
|
||||
const filter = _.extend({}, queryFilter);
|
||||
filters[filter.name] = filter;
|
||||
filters[filter.name].originFilters = [];
|
||||
if (hasQueryStringValue) {
|
||||
filter.current = $location.search()[filter.name];
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: merge values.
|
||||
@@ -181,7 +184,7 @@ function DashboardCtrl($rootScope, $routeParams, $location, $timeout, $q, $uibMo
|
||||
};
|
||||
|
||||
this.togglePublished = () => {
|
||||
Events.record(currentUser, 'toggle_published', 'dashboard', this.dashboard.id);
|
||||
Events.record('toggle_published', 'dashboard', this.dashboard.id);
|
||||
this.dashboard.is_draft = !this.dashboard.is_draft;
|
||||
this.saveInProgress = true;
|
||||
Dashboard.save({
|
||||
@@ -218,6 +221,8 @@ const ShareDashboardComponent = {
|
||||
dismiss: '&',
|
||||
},
|
||||
controller($http) {
|
||||
'ngInject';
|
||||
|
||||
this.dashboard = this.resolve.dashboard;
|
||||
|
||||
this.toggleSharing = () => {
|
||||
|
||||
@@ -7,6 +7,8 @@ const PublicDashboardPage = {
|
||||
dashboard: '<',
|
||||
},
|
||||
controller($routeParams, Widget) {
|
||||
'ngInject';
|
||||
|
||||
// embed in params == headless
|
||||
this.logoUrl = logoUrl;
|
||||
this.headless = $routeParams.embed;
|
||||
@@ -26,6 +28,8 @@ export default function (ngModule) {
|
||||
ngModule.component('publicDashboardPage', PublicDashboardPage);
|
||||
|
||||
function loadPublicDashboard($http, $route) {
|
||||
'ngInject';
|
||||
|
||||
const token = $route.current.params.token;
|
||||
return $http.get(`/api/dashboards/public/${token}`).then(response =>
|
||||
response.data
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<a data-toggle="dropdown" uib-dropdown-toggle><i class="zmdi zmdi-more"></i></a>
|
||||
|
||||
<ul class="dropdown-menu pull-right" uib-dropdown-menu style="z-index:1000000">
|
||||
<li><a ng-disabled="!$ctrl.queryResult.getData()" query-result-link target="_self">Download as CSV File</a></li>
|
||||
<li><a ng-disabled="!$ctrl.queryResult.getData()" file-type="xlsx" query-result-link target="_self">Download as Excel File</a></li>
|
||||
<li ng-class="{'disabled': $ctrl.queryResult.isEmpty()}"><a ng-href="{{$ctrl.queryResult.getLink($ctrl.query.id, 'csv')}}" download="{{$ctrl.queryResult.getName($ctrl.query.name, 'csv')}}" target="_self">Download as CSV File</a></li>
|
||||
<li ng-class="{'disabled': $ctrl.queryResult.isEmpty()}"><a ng-href="{{$ctrl.queryResult.getLink($ctrl.query.id, 'xlsx')}}" download="{{$ctrl.queryResult.getName($ctrl.query.name, 'xlsx')}}" target="_self">Download as Excel File</a></li>
|
||||
<li><a ng-href="queries/{{$ctrl.query.id}}#{{$ctrl.widget.visualization.id}}" ng-show="$ctrl.canViewQuery">View Query</a></li>
|
||||
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove From Dashboard</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -9,6 +9,8 @@ const EditTextBoxComponent = {
|
||||
dismiss: '&',
|
||||
},
|
||||
controller(toastr) {
|
||||
'ngInject';
|
||||
|
||||
this.saveInProgress = false;
|
||||
this.widget = this.resolve.widget;
|
||||
this.saveWidget = () => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<p class="f-500 m-b-20 c-black">Recent Dashboards</p>
|
||||
<div class="list-group">
|
||||
<a ng-href="dashboard/{{dashboard.slug}}" class="list-group-item" ng-repeat="dashboard in $ctrl.recentDashboards">
|
||||
{{dashboard.name}} <span class="label label-warning" ng-if="dashboard.is_draft">Unpublished</span>
|
||||
{{dashboard.name}} <span class="label label-default" ng-if="dashboard.is_draft">Unpublished</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,7 +24,7 @@
|
||||
<p class="f-500 m-b-20 c-black">Recent Queries</p>
|
||||
<div class="list-group">
|
||||
<a ng-href="queries/{{query.id}}" class="list-group-item"
|
||||
ng-repeat="query in $ctrl.recentQueries">{{query.name}} <span class="label label-warning" ng-if="query.is_draft">Unpublished</span></a>
|
||||
ng-repeat="query in $ctrl.recentQueries">{{query.name}} <span class="label label-default" ng-if="query.is_draft">Unpublished</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="query in $ctrl.paginator.getPageRows()">
|
||||
<td><a href="queries/{{query.id}}">{{query.name}}</a> <span class="label label-warning" ng-if="query.is_draft">Unpublished</span></td>
|
||||
<td><a href="queries/{{query.id}}">{{query.name}}</a> <span class="label label-default" ng-if="query.is_draft">Unpublished</span></td>
|
||||
<td>{{query.user.name}}</td>
|
||||
<td>{{query.created_at | dateTime}}</td>
|
||||
<td>{{query.runtime | durationHumanize}}</td>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" aria-label="Close" ng-click="close()"><span aria-hidden="true">×</span></button>
|
||||
<button type="button" class="close" aria-label="Close" ng-click="$ctrl.close()"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">Embed Code</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="query in $ctrl.paginator.getPageRows()">
|
||||
<td><a href="queries/{{query.id}}">{{query.name}}</a> <span class="label label-warning" ng-if="query.is_draft">Unpublished</span></td>
|
||||
<td><a href="queries/{{query.id}}">{{query.name}}</a> <span class="label label-default" ng-if="query.is_draft">Unpublished</span></td>
|
||||
<td>{{query.user.name}}</td>
|
||||
<td>{{query.created_at | dateTime}}</td>
|
||||
<td>{{query.schedule | scheduleHumanize}}</td>
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'brace/mode/python';
|
||||
import 'brace/mode/sql';
|
||||
import 'brace/mode/json';
|
||||
import 'brace/ext/language_tools';
|
||||
import { each, map } from 'underscore';
|
||||
import { map } from 'underscore';
|
||||
|
||||
// By default Ace will try to load snippet files for the different modes and fail.
|
||||
// We don't need them, so we use these placeholders until we define our own.
|
||||
@@ -25,7 +25,6 @@ function queryEditor(QuerySnippet) {
|
||||
query: '=',
|
||||
schema: '=',
|
||||
syntax: '=',
|
||||
shortcuts: '=',
|
||||
},
|
||||
template: '<div ui-ace="editorOptions" ng-model="query.query"></div>',
|
||||
link: {
|
||||
@@ -47,11 +46,6 @@ function queryEditor(QuerySnippet) {
|
||||
editor.commands.bindKey('Cmd+L', null);
|
||||
editor.commands.bindKey('Ctrl+L', null);
|
||||
|
||||
each($scope.shortcuts, (fn, key) => {
|
||||
key = key.replace('meta', 'Cmd').replace('ctrl', 'Ctrl');
|
||||
editor.commands.bindKey(key, () => fn());
|
||||
});
|
||||
|
||||
QuerySnippet.query((snippets) => {
|
||||
window.ace.acequire(['ace/snippets'], (snippetsModule) => {
|
||||
const snippetManager = snippetsModule.snippetManager;
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
<div class="col-sm-9">
|
||||
<h3>
|
||||
<edit-in-place editable="canEdit" done="saveName" ignore-blanks="true" value="query.name"></edit-in-place>
|
||||
<span class="label label-warning" ng-if="query.is_draft">Unpublished</span>
|
||||
<span class="label label-default" ng-if="query.is_draft && !query.is_archived">Unpublished</span>
|
||||
<span class="label label-warning" ng-if="query.is_archived" uib-popover="This query is archived and can't be used in dashboards, and won't appear in search results." popover-placement="right" popover-trigger="'mouseenter'">Archived</span>
|
||||
</h3>
|
||||
<p>
|
||||
<em>
|
||||
@@ -75,15 +76,16 @@
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="col-lg-12 p-5 bg-orange c-white" ng-if="query.is_archived">
|
||||
This query is archived and can't be used in dashboards, and won't appear in search results.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- editor -->
|
||||
<div class="container">
|
||||
<div class="row bg-white p-b-5" ng-if="sourceMode" resizable r-directions="['bottom']" r-height="300" style="min-height:100px;">
|
||||
<schema-browser schema="schema" class="col-md-3 hidden-sm hidden-xs schema-container" ng-show="hasSchema"></schema-browser>
|
||||
<schema-browser class="col-md-3 hidden-sm hidden-xs schema-container"
|
||||
schema="schema"
|
||||
on-refresh="refreshSchema()"
|
||||
ng-show="hasSchema">
|
||||
</schema-browser>
|
||||
|
||||
<div ng-class="editorSize" style="height:100%;">
|
||||
<div class="p-5">
|
||||
@@ -128,8 +130,7 @@
|
||||
<p style="height:calc(100% - 40px);">
|
||||
<query-editor query="query"
|
||||
schema="schema"
|
||||
syntax="dataSource.syntax"
|
||||
shortcuts="shortcuts"></query-editor>
|
||||
syntax="dataSource.syntax"></query-editor>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<div class="schema-container">
|
||||
<div class="p-t-5 p-b-5">
|
||||
<input type="text" placeholder="Search schema..." class="form-control" ng-model="schemaFilter">
|
||||
<div class="schema-control">
|
||||
<input type="text" placeholder="Search schema..." class="form-control" ng-model="$ctrl.schemaFilter">
|
||||
<button class="btn btn-default"
|
||||
title="Refresh Schema"
|
||||
ng-click="$ctrl.onRefresh()">
|
||||
<span class="zmdi zmdi-refresh"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="schema-browser" vs-repeat vs-size="getSize(table)">
|
||||
<div ng-repeat="table in schema | filter:schemaFilter track by table.name">
|
||||
<div class="table-name" ng-click="showTable(table)">
|
||||
<i class="fa fa-table"></i> <strong><span title="{{table.name}}">{{table.name}}</span>
|
||||
<span ng-if="table.size !== undefined"> ({{table.size}})</span></strong>
|
||||
<div class="schema-browser" vs-repeat vs-size="$ctrl.getSize(table)">
|
||||
<div ng-repeat="table in $ctrl.schema | filter:$ctrl.schemaFilter track by table.name">
|
||||
<div class="table-name" ng-click="$ctrl.showTable(table)">
|
||||
<i class="fa fa-table"></i>
|
||||
<strong>
|
||||
<span title="{{table.name}}">{{table.name}}</span>
|
||||
<span ng-if="table.size !== undefined"> ({{table.size}})</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div uib-collapse="table.collapsed">
|
||||
<div ng-repeat="column in table.columns track by column" style="padding-left:16px;">{{column}}</div>
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import template from './schema-browser.html';
|
||||
|
||||
function schemaBrowser() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
schema: '=',
|
||||
},
|
||||
template,
|
||||
link($scope) {
|
||||
$scope.showTable = (table) => {
|
||||
table.collapsed = !table.collapsed;
|
||||
$scope.$broadcast('vsRepeatTrigger');
|
||||
};
|
||||
function SchemaBrowserCtrl($scope) {
|
||||
'ngInject';
|
||||
|
||||
$scope.getSize = (table) => {
|
||||
let size = 18;
|
||||
this.showTable = (table) => {
|
||||
table.collapsed = !table.collapsed;
|
||||
$scope.$broadcast('vsRepeatTrigger');
|
||||
};
|
||||
|
||||
if (!table.collapsed) {
|
||||
size += 18 * table.columns.length;
|
||||
}
|
||||
this.getSize = (table) => {
|
||||
let size = 18;
|
||||
|
||||
return size;
|
||||
};
|
||||
},
|
||||
if (!table.collapsed) {
|
||||
size += 18 * table.columns.length;
|
||||
}
|
||||
|
||||
return size;
|
||||
};
|
||||
}
|
||||
|
||||
const SchemaBrowser = {
|
||||
bindings: {
|
||||
schema: '<',
|
||||
onRefresh: '&',
|
||||
},
|
||||
controller: SchemaBrowserCtrl,
|
||||
template,
|
||||
};
|
||||
|
||||
export default function (ngModule) {
|
||||
ngModule.directive('schemaBrowser', schemaBrowser);
|
||||
ngModule.component('schemaBrowser', SchemaBrowser);
|
||||
}
|
||||
|
||||
@@ -29,24 +29,19 @@ function QuerySourceCtrl(Events, toastr, $controller, $scope, $location, $http,
|
||||
},
|
||||
});
|
||||
|
||||
$scope.shortcuts = {
|
||||
'meta+s': function save() {
|
||||
const shortcuts = {
|
||||
'mod+s': function save() {
|
||||
if ($scope.canEdit) {
|
||||
$scope.saveQuery();
|
||||
}
|
||||
},
|
||||
'ctrl+s': function save() {
|
||||
if ($scope.canEdit) {
|
||||
$scope.saveQuery();
|
||||
}
|
||||
},
|
||||
// Cmd+Enter for Mac
|
||||
'meta+enter': $scope.executeQuery,
|
||||
// Ctrl+Enter for PC
|
||||
'ctrl+enter': $scope.executeQuery,
|
||||
};
|
||||
|
||||
KeyboardShortcuts.bind($scope.shortcuts);
|
||||
KeyboardShortcuts.bind(shortcuts);
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
KeyboardShortcuts.unbind(shortcuts);
|
||||
});
|
||||
|
||||
// @override
|
||||
$scope.saveQuery = (options, data) => {
|
||||
@@ -106,10 +101,6 @@ function QuerySourceCtrl(Events, toastr, $controller, $scope, $location, $http,
|
||||
$scope.$watch('query.query', (newQueryText) => {
|
||||
$scope.isDirty = (newQueryText !== queryText);
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
KeyboardShortcuts.unbind($scope.shortcuts);
|
||||
});
|
||||
}
|
||||
|
||||
export default function (ngModule) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { pick, any, some, find } from 'underscore';
|
||||
import template from './query.html';
|
||||
|
||||
function QueryViewCtrl($scope, Events, $route, $routeParams, $http, $location, $window, $q,
|
||||
Title, AlertDialog, Notifications, clientConfig, toastr, $uibModal, currentUser,
|
||||
Query, DataSource) {
|
||||
function QueryViewCtrl($scope, Events, $route, $routeParams, $location, $window, $q,
|
||||
KeyboardShortcuts, Title, AlertDialog, Notifications, clientConfig, toastr, $uibModal,
|
||||
currentUser, Query, DataSource) {
|
||||
const DEFAULT_TAB = 'table';
|
||||
|
||||
function getQueryResult(maxAge) {
|
||||
@@ -43,26 +43,36 @@ function QueryViewCtrl($scope, Events, $route, $routeParams, $http, $location, $
|
||||
return dataSourceId;
|
||||
}
|
||||
|
||||
function updateSchema() {
|
||||
$scope.hasSchema = false;
|
||||
$scope.editorSize = 'col-md-12';
|
||||
DataSource.getSchema({ id: $scope.query.data_source_id }, (data) => {
|
||||
if (data && data.length > 0) {
|
||||
function toggleSchemaBrowser(hasSchema) {
|
||||
$scope.hasSchema = hasSchema;
|
||||
$scope.editorSize = hasSchema ? 'col-md-9' : 'col-md-12';
|
||||
}
|
||||
|
||||
function getSchema(refresh = undefined) {
|
||||
DataSource.getSchema({ id: $scope.query.data_source_id, refresh }, (data) => {
|
||||
const hasPrevSchema = refresh ? ($scope.schema && ($scope.schema.length > 0)) : false;
|
||||
const hasSchema = data && (data.length > 0);
|
||||
|
||||
if (hasSchema) {
|
||||
$scope.schema = data;
|
||||
data.forEach((table) => {
|
||||
table.collapsed = true;
|
||||
});
|
||||
|
||||
$scope.editorSize = 'col-md-9';
|
||||
$scope.hasSchema = true;
|
||||
} else {
|
||||
$scope.schema = undefined;
|
||||
$scope.hasSchema = false;
|
||||
$scope.editorSize = 'col-md-12';
|
||||
} else if (hasPrevSchema) {
|
||||
toastr.error('Schema refresh failed. Please try again later.');
|
||||
}
|
||||
|
||||
toggleSchemaBrowser(hasSchema || hasPrevSchema);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSchema() {
|
||||
toggleSchemaBrowser(false);
|
||||
getSchema();
|
||||
}
|
||||
|
||||
$scope.refreshSchema = () => getSchema(true);
|
||||
|
||||
function updateDataSources(dataSources) {
|
||||
// Filter out data sources the user can't query (or used by current query):
|
||||
$scope.dataSources = dataSources.filter(dataSource =>
|
||||
@@ -85,11 +95,38 @@ function QueryViewCtrl($scope, Events, $route, $routeParams, $http, $location, $
|
||||
updateSchema();
|
||||
}
|
||||
|
||||
$scope.executeQuery = () => {
|
||||
if (!$scope.canExecuteQuery()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$scope.query.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
getQueryResult(0);
|
||||
$scope.lockButton(true);
|
||||
$scope.cancelling = false;
|
||||
Events.record('execute', 'query', $scope.query.id);
|
||||
|
||||
Notifications.getPermissions();
|
||||
};
|
||||
|
||||
|
||||
$scope.currentUser = currentUser;
|
||||
$scope.dataSource = {};
|
||||
$scope.query = $route.current.locals.query;
|
||||
$scope.showPermissionsControl = clientConfig.showPermissionsControl;
|
||||
|
||||
const shortcuts = {
|
||||
'mod+enter': $scope.executeQuery,
|
||||
};
|
||||
|
||||
KeyboardShortcuts.bind(shortcuts);
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
KeyboardShortcuts.unbind(shortcuts);
|
||||
});
|
||||
|
||||
Events.record('view', 'query', $scope.query.id);
|
||||
if ($scope.query.hasResult() || $scope.query.paramsRequired()) {
|
||||
@@ -157,7 +194,7 @@ function QueryViewCtrl($scope, Events, $route, $routeParams, $http, $location, $
|
||||
};
|
||||
|
||||
$scope.togglePublished = () => {
|
||||
Events.record(currentUser, 'toggle_published', 'query', $scope.query.id);
|
||||
Events.record('toggle_published', 'query', $scope.query.id);
|
||||
$scope.query.is_draft = !$scope.query.is_draft;
|
||||
$scope.saveQuery(undefined, { is_draft: $scope.query.is_draft });
|
||||
};
|
||||
@@ -172,23 +209,6 @@ function QueryViewCtrl($scope, Events, $route, $routeParams, $http, $location, $
|
||||
$scope.saveQuery(undefined, { name: $scope.query.name });
|
||||
};
|
||||
|
||||
$scope.executeQuery = () => {
|
||||
if (!$scope.canExecuteQuery()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$scope.query.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
getQueryResult(0);
|
||||
$scope.lockButton(true);
|
||||
$scope.cancelling = false;
|
||||
Events.record('execute', 'query', $scope.query.id);
|
||||
|
||||
Notifications.getPermissions();
|
||||
};
|
||||
|
||||
$scope.cancelExecution = () => {
|
||||
$scope.cancelling = true;
|
||||
$scope.queryResult.cancelExecution();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="t-heading p-10">
|
||||
<h3 class="th-title">
|
||||
<p>
|
||||
<img src="{{$ctrl.logoUrl}}" style="height: 24px;"/>
|
||||
<img ng-src="{{$ctrl.logoUrl}}" style="height: 24px;"/>
|
||||
{{$ctrl.query.name}}
|
||||
<small><visualization-name visualization="$ctrl.visualization"/></small>
|
||||
</p>
|
||||
|
||||
@@ -8,6 +8,8 @@ const VisualizationEmbed = {
|
||||
data: '<',
|
||||
},
|
||||
controller($routeParams, Query, QueryResult) {
|
||||
'ngInject';
|
||||
|
||||
document.querySelector('body').classList.add('headless');
|
||||
const visualizationId = parseInt($routeParams.visualizationId, 10);
|
||||
this.showQueryDescription = $routeParams.showDescription;
|
||||
@@ -24,6 +26,8 @@ export default function (ngModule) {
|
||||
ngModule.component('visualizationEmbed', VisualizationEmbed);
|
||||
|
||||
function session($http, $route, Auth) {
|
||||
'ngInject';
|
||||
|
||||
const apiKey = $route.current.params.api_key;
|
||||
Auth.setApiKey(apiKey);
|
||||
return Auth.loadConfig();
|
||||
|
||||
@@ -27,7 +27,10 @@ function Dashboard($resource, $http, currentUser, Widget) {
|
||||
},
|
||||
});
|
||||
|
||||
resource.prototype.canEdit = () => currentUser.canEdit(this) || this.can_edit;
|
||||
resource.prototype.canEdit = function canEdit() {
|
||||
return currentUser.canEdit(this) || this.can_edit;
|
||||
};
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ function DataSource($resource) {
|
||||
get: { method: 'GET', cache: false, isArray: false },
|
||||
query: { method: 'GET', cache: false, isArray: true },
|
||||
test: { method: 'POST', cache: false, isArray: false, url: 'api/data_sources/:id/test' },
|
||||
getSchema: { method: 'GET', cache: true, isArray: true, url: 'api/data_sources/:id/schema' },
|
||||
getSchema: { method: 'GET', cache: false, isArray: true, url: 'api/data_sources/:id/schema' },
|
||||
};
|
||||
|
||||
const DataSourceResource = $resource('api/data_sources/:id', { id: '@id' }, actions);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { each } from 'underscore';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import 'mousetrap/plugins/global-bind/mousetrap-global-bind';
|
||||
|
||||
|
||||
function KeyboardShortcuts() {
|
||||
this.bind = function bind(keymap) {
|
||||
each(keymap, (fn, key) => {
|
||||
Mousetrap.bind(key, (e) => {
|
||||
Mousetrap.bindGlobal(key, (e) => {
|
||||
e.preventDefault();
|
||||
fn();
|
||||
});
|
||||
|
||||
@@ -216,15 +216,20 @@ function QueryResultService($resource, $timeout, $q) {
|
||||
return this.filteredData;
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.getData() === null || this.getData().length === 0;
|
||||
}
|
||||
|
||||
getChartData(mapping) {
|
||||
const series = {};
|
||||
|
||||
this.getData().forEach((row) => {
|
||||
const point = {};
|
||||
let point = {};
|
||||
let seriesName;
|
||||
let xValue = 0;
|
||||
const yValues = {};
|
||||
let eValue = null;
|
||||
let sizeValue = null;
|
||||
|
||||
each(row, (v, definition) => {
|
||||
const name = definition.split('::')[0] || definition.split('__')[0];
|
||||
@@ -258,6 +263,11 @@ function QueryResultService($resource, $timeout, $q) {
|
||||
seriesName = String(value);
|
||||
}
|
||||
|
||||
if (type === 'size') {
|
||||
point[type] = value;
|
||||
sizeValue = value;
|
||||
}
|
||||
|
||||
if (type === 'multiFilter' || type === 'multi-filter') {
|
||||
seriesName = String(value);
|
||||
}
|
||||
@@ -265,11 +275,15 @@ function QueryResultService($resource, $timeout, $q) {
|
||||
|
||||
if (seriesName === undefined) {
|
||||
each(yValues, (yValue, ySeriesName) => {
|
||||
point = { x: xValue, y: yValue };
|
||||
if (eValue !== null) {
|
||||
addPointToSeries({ x: xValue, y: yValue, yError: eValue }, series, ySeriesName);
|
||||
} else {
|
||||
addPointToSeries({ x: xValue, y: yValue }, series, ySeriesName);
|
||||
point.yError = eValue;
|
||||
}
|
||||
|
||||
if (sizeValue !== null) {
|
||||
point.size = sizeValue;
|
||||
}
|
||||
addPointToSeries(point, series, ySeriesName);
|
||||
});
|
||||
} else {
|
||||
addPointToSeries(point, series, seriesName);
|
||||
@@ -339,7 +353,11 @@ function QueryResultService($resource, $timeout, $q) {
|
||||
filters.forEach((filter) => {
|
||||
filter.values.push(row[filter.name]);
|
||||
if (filter.values.length === 1) {
|
||||
filter.current = row[filter.name];
|
||||
if (filter.multiple) {
|
||||
filter.current = [row[filter.name]];
|
||||
} else {
|
||||
filter.current = row[filter.name];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,43 @@ class QueryResultError {
|
||||
}
|
||||
|
||||
|
||||
class Parameter {
|
||||
constructor(parameter) {
|
||||
this.title = parameter.title;
|
||||
this.name = parameter.name;
|
||||
this.type = parameter.type;
|
||||
this.value = parameter.value;
|
||||
this.global = parameter.global;
|
||||
}
|
||||
|
||||
get ngModel() {
|
||||
if (this.type === 'date' || this.type === 'datetime-local' || this.type === 'datetime-with-seconds') {
|
||||
this.$$value = this.$$value || moment(this.value).toDate();
|
||||
return this.$$value;
|
||||
} else if (this.type === 'number') {
|
||||
this.$$value = this.$$value || parseInt(this.value, 10);
|
||||
return this.$$value;
|
||||
}
|
||||
|
||||
return this.value;
|
||||
}
|
||||
|
||||
set ngModel(value) {
|
||||
if (value && this.type === 'date') {
|
||||
this.value = moment(value).format('YYYY-MM-DD');
|
||||
this.$$value = moment(this.value).toDate();
|
||||
} else if (value && this.type === 'datetime-local') {
|
||||
this.value = moment(value).format('YYYY-MM-DD HH:mm');
|
||||
this.$$value = moment(this.value).toDate();
|
||||
} else if (value && this.type === 'datetime-with-seconds') {
|
||||
this.value = moment(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
this.$$value = moment(this.value).toDate();
|
||||
} else {
|
||||
this.value = this.$$value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Parameters {
|
||||
constructor(query, queryString) {
|
||||
this.query = query;
|
||||
@@ -84,7 +121,8 @@ class Parameters {
|
||||
});
|
||||
|
||||
const parameterExists = p => contains(parameterNames, p.name);
|
||||
this.query.options.parameters = this.query.options.parameters.filter(parameterExists);
|
||||
this.query.options.parameters =
|
||||
this.query.options.parameters.filter(parameterExists).map(p => new Parameter(p));
|
||||
}
|
||||
|
||||
initFromQueryString(queryString) {
|
||||
|
||||
@@ -72,6 +72,18 @@
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="showSizeColumnPicker()">
|
||||
<label class="control-label">Bubble size column</label>
|
||||
|
||||
<ui-select name="sizeColumn" ng-model="form.sizeColumn">
|
||||
<ui-select-match allow-clear="true" placeholder="Choose column...">{{$select.selected}}</ui-select-match>
|
||||
<ui-select-choices repeat="column in columnNames | remove:form.yAxisColumns | remove:form.groupby">
|
||||
<span ng-bind-html="column | highlight: $select.search"></span><span> </span>
|
||||
<small class="text-muted" ng-bind="columns[column].type"></small>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="options.globalSeriesType != 'custom'">
|
||||
<label class="control-label">Errors column</label>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { extend, has, partial, intersection, without, contains, isUndefined, sortBy, each, pluck, keys, difference } from 'underscore';
|
||||
import { some, extend, has, partial, intersection, without, contains, isUndefined, sortBy, each, pluck, keys, difference } from 'underscore';
|
||||
import plotly from './plotly';
|
||||
import template from './chart.html';
|
||||
import editorTemplate from './chart-editor.html';
|
||||
@@ -68,6 +68,7 @@ function ChartEditor(ColorPalette, clientConfig) {
|
||||
area: { name: 'Area', icon: 'area-chart' },
|
||||
pie: { name: 'Pie', icon: 'pie-chart' },
|
||||
scatter: { name: 'Scatter', icon: 'circle-o' },
|
||||
bubble: { name: 'Bubble', icon: 'circle-o' },
|
||||
};
|
||||
|
||||
if (clientConfig.allowCustomJSVisualizations) {
|
||||
@@ -83,6 +84,8 @@ function ChartEditor(ColorPalette, clientConfig) {
|
||||
});
|
||||
};
|
||||
|
||||
scope.showSizeColumnPicker = () => some(scope.options.seriesOptions, options => options.type === 'bubble');
|
||||
|
||||
scope.options.customCode = `// Available variables are x, ys, element, and Plotly
|
||||
// Type console.log(x, ys); for more info about x and ys
|
||||
// To plot your graph call Plotly.plot(element, ...)
|
||||
@@ -191,6 +194,15 @@ function ChartEditor(ColorPalette, clientConfig) {
|
||||
}
|
||||
});
|
||||
|
||||
scope.$watch('form.sizeColumn', (value, old) => {
|
||||
if (old !== undefined) {
|
||||
unsetColumn(old);
|
||||
}
|
||||
if (value !== undefined) {
|
||||
setColumnRole('size', value);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
scope.$watch('form.groupby', (value, old) => {
|
||||
if (old !== undefined) {
|
||||
@@ -222,6 +234,8 @@ function ChartEditor(ColorPalette, clientConfig) {
|
||||
scope.form.groupby = key;
|
||||
} else if (value === 'yError') {
|
||||
scope.form.errorColumn = key;
|
||||
} else if (value === 'size') {
|
||||
scope.form.sizeColumn = key;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import histogram from 'plotly.js/lib/histogram';
|
||||
import moment from 'moment';
|
||||
|
||||
Plotly.register([bar, pie, histogram]);
|
||||
Plotly.setPlotConfig({
|
||||
modeBarButtonsToRemove: ['sendDataToCloud'],
|
||||
});
|
||||
|
||||
// The following colors will be used if you pick "Automatic" color.
|
||||
const BaseColors = {
|
||||
@@ -137,7 +140,7 @@ function percentBarStacking(seriesList) {
|
||||
sum += seriesList[j].y[i];
|
||||
}
|
||||
for (let j = 0; j < seriesList.length; j += 1) {
|
||||
const value = seriesList[j].y[i] / (sum * 100);
|
||||
const value = seriesList[j].y[i] / sum * 100;
|
||||
seriesList[j].text.push(`Value: ${seriesList[j].y[i]}<br>Relative: ${value.toFixed(2)}%`);
|
||||
seriesList[j].y[i] = value;
|
||||
}
|
||||
@@ -208,6 +211,8 @@ const PlotlyChart = () => {
|
||||
} else if (type === 'scatter') {
|
||||
series.type = 'scatter';
|
||||
series.mode = 'markers';
|
||||
} else if (type === 'bubble') {
|
||||
series.mode = 'markers';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +335,12 @@ const PlotlyChart = () => {
|
||||
if (!plotlySeries.error_y.length) {
|
||||
delete plotlySeries.error_y.length;
|
||||
}
|
||||
|
||||
if (seriesOptions.type === 'bubble') {
|
||||
plotlySeries.marker = {
|
||||
size: pluck(data, 'size'),
|
||||
};
|
||||
}
|
||||
scope.data.push(plotlySeries);
|
||||
});
|
||||
|
||||
@@ -400,7 +411,11 @@ const PlotlyChart = () => {
|
||||
scope.$watch('series', recalculateOptions);
|
||||
scope.$watch('options', recalculateOptions, true);
|
||||
|
||||
scope.layout = { margin: { l: 50, r: 50, b: bottomMargin, t: 20, pad: 4 }, height: calculateHeight(), autosize: true, hovermode: 'closest' };
|
||||
scope.layout = {
|
||||
margin: { l: 50, r: 50, b: bottomMargin, t: 20, pad: 4 },
|
||||
height: calculateHeight(),
|
||||
autosize: true,
|
||||
};
|
||||
scope.plotlyOptions = { showLink: false, displaylogo: false };
|
||||
scope.data = [];
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Counter Value Column Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.counterColName" class="form-control"></select>
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.counterColName" class="form-control" ng-disabled="visualization.options.countRow"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Counter Value Row Number</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="number" ng-model="visualization.options.rowNumber" min="1" class="form-control">
|
||||
<input type="number" ng-model="visualization.options.rowNumber" min="1" class="form-control" ng-disabled="visualization.options.countRow">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -25,4 +25,10 @@
|
||||
<input type="number" ng-model="visualization.options.targetRowNumber" min="1" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-lg-6">
|
||||
<input type="checkbox" ng-model="visualization.options.countRow">
|
||||
<i class="input-helper"></i> Count Rows
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,10 +14,11 @@ function CounterRenderer() {
|
||||
const counterColName = $scope.visualization.options.counterColName;
|
||||
const targetColName = $scope.visualization.options.targetColName;
|
||||
|
||||
if (counterColName) {
|
||||
if ($scope.visualization.options.countRow) {
|
||||
$scope.counterValue = queryData.length;
|
||||
} else if (counterColName) {
|
||||
$scope.counterValue = queryData[rowNumber][counterColName];
|
||||
}
|
||||
|
||||
if (targetColName) {
|
||||
$scope.targetValue = queryData[targetRowNumber][targetColName];
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ function mapRenderer() {
|
||||
}
|
||||
}
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', render, true);
|
||||
$scope.$watch('queryResult && queryResult.getData()', render);
|
||||
$scope.$watch('visualization.options', render, true);
|
||||
angular.element(window).on('resize', resize);
|
||||
$scope.$watch('visualization.options.height', resize);
|
||||
@@ -218,7 +218,9 @@ function mapEditor() {
|
||||
template: editorTemplate,
|
||||
link($scope) {
|
||||
$scope.currentTab = 'general';
|
||||
$scope.classify_columns = $scope.queryResult.columnNames.concat('none');
|
||||
$scope.columns = $scope.queryResult.getColumns();
|
||||
$scope.columnNames = _.pluck($scope.columns, 'name');
|
||||
$scope.classify_columns = $scope.columnNames.concat('none');
|
||||
$scope.mapTiles = [
|
||||
{
|
||||
name: 'OpenStreetMap',
|
||||
|
||||
@@ -8,20 +8,35 @@
|
||||
<div ng-show="currentTab == 'general'">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Latitude Column Name</label>
|
||||
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.latColName"
|
||||
class="form-control"></select>
|
||||
<ui-select name="form-control" required ng-model="visualization.options.latColName">
|
||||
<ui-select-match placeholder="Choose column...">{{$select.selected}}</ui-select-match>
|
||||
<ui-select-choices repeat="column in columnNames | remove:visualization.options.classify | remove:visualization.options.lonColName">
|
||||
<span ng-bind-html="column | highlight: $select.search"></span><span> </span>
|
||||
<small class="text-muted" ng-bind="columns[column].type"></small>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label">Longitude Column Name</label>
|
||||
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.lonColName"
|
||||
class="form-control"></select>
|
||||
<ui-select name="form-control" required ng-model="visualization.options.lonColName">
|
||||
<ui-select-match placeholder="Choose column...">{{$select.selected}}</ui-select-match>
|
||||
<ui-select-choices repeat="column in columnNames | remove:visualization.options.classify | remove:visualization.options.latColName">
|
||||
<span ng-bind-html="column | highlight: $select.search"></span><span> </span>
|
||||
<small class="text-muted" ng-bind="columns[column].type"></small>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label">Group By</label>
|
||||
<select ng-options="name for name in classify_columns" ng-model="visualization.options.classify"
|
||||
class="form-control"></select>
|
||||
<ui-select name="form-control" required ng-model="visualization.options.classify">
|
||||
<ui-select-match placeholder="Choose column...">{{$select.selected}}</ui-select-match>
|
||||
<ui-select-choices repeat="column in classify_columns | remove:visualization.options.lonColName | remove:visualization.options.latColName">
|
||||
<span ng-bind-html="column | highlight: $select.search"></span><span> </span>
|
||||
<small class="text-muted" ng-bind="columns[column].type"></small>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import angular from 'angular';
|
||||
import $ from 'jquery';
|
||||
import 'pivottable';
|
||||
import 'pivottable/dist/pivot.css';
|
||||
@@ -20,7 +21,7 @@ function pivotTableRenderer() {
|
||||
if ($scope.queryResult.getData() !== null) {
|
||||
// We need to give the pivot table its own copy of the data, because it changes
|
||||
// it which interferes with other visualizations.
|
||||
data = $.extend(true, [], $scope.queryResult.getRawData());
|
||||
data = angular.copy($scope.queryResult.getData());
|
||||
const options = {
|
||||
renderers: $.pivotUtilities.renderers,
|
||||
onRefresh(config) {
|
||||
|
||||
@@ -20,7 +20,8 @@ function graph(data) {
|
||||
const links = {};
|
||||
const nodes = [];
|
||||
|
||||
const keys = _.sortBy(_.without(_.keys(data[0]), 'value'), _.identity);
|
||||
const validKey = key => key !== 'value' && key.indexOf('$$') !== 0;
|
||||
const keys = _.sortBy(_.filter(_.keys(data[0]), validKey), _.identity);
|
||||
|
||||
function normalizeName(name) {
|
||||
if (name) {
|
||||
|
||||
@@ -311,7 +311,8 @@ export default function Sunburst(scope, element) {
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const keys = _.sortBy(_.without(_.keys(raw[0]), 'value'), _.identity);
|
||||
const validKey = key => key !== 'value' && key.indexOf('$$') !== 0;
|
||||
const keys = _.sortBy(_.filter(_.keys(raw[0]), validKey), _.identity);
|
||||
|
||||
values = _.map(raw, (row, sequence) =>
|
||||
({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Word Cloud Column Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.column" class="form-control"></select>
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.column" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
version: '2'
|
||||
services:
|
||||
server:
|
||||
build: .
|
||||
image: redash/redash:latest
|
||||
command: server
|
||||
depends_on:
|
||||
- postgres
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
REDASH_COOKIE_SECRET: veryverysecret
|
||||
worker:
|
||||
build: .
|
||||
image: redash/redash:latest
|
||||
command: scheduler
|
||||
environment:
|
||||
PYTHONUNBUFFERED: 0
|
||||
@@ -31,9 +31,9 @@ services:
|
||||
QUEUES: "queries,scheduled_queries,celery"
|
||||
WORKERS_COUNT: 2
|
||||
redis:
|
||||
image: redis:2.8
|
||||
image: redis:3.0-alpine
|
||||
postgres:
|
||||
image: postgres:9.3
|
||||
image: postgres:9.5.6-alpine
|
||||
# volumes:
|
||||
# - /opt/postgres-data:/var/lib/postgresql/data
|
||||
nginx:
|
||||
@@ -42,3 +42,5 @@ services:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- server
|
||||
links:
|
||||
- server:redash
|
||||
|
||||
@@ -32,9 +32,9 @@ services:
|
||||
QUEUES: "queries,scheduled_queries,celery"
|
||||
WORKERS_COUNT: 2
|
||||
redis:
|
||||
image: redis:2.8
|
||||
image: redis:3.0-alpine
|
||||
postgres:
|
||||
image: postgres:9.3
|
||||
image: postgres:9.5.6-alpine
|
||||
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3
|
||||
# improvement on my personal machine). We should consider moving this into a dedicated Docker Compose configuration for
|
||||
# tests.
|
||||
|
||||
25
migrations/versions/d1eae8b9893e_.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""add Query.schedule_failures
|
||||
|
||||
Revision ID: d1eae8b9893e
|
||||
Revises: 65fc9ede4746
|
||||
Create Date: 2017-02-03 01:45:02.954923
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd1eae8b9893e'
|
||||
down_revision = '65fc9ede4746'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('queries', sa.Column('schedule_failures', sa.Integer(),
|
||||
nullable=False, server_default='0'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('queries', 'schedule_failures')
|
||||
4
npm-shrinkwrap.json
generated
@@ -1960,12 +1960,12 @@
|
||||
"mapbox-gl-shaders": {
|
||||
"version": "1.0.0",
|
||||
"from": "mapbox/mapbox-gl-shaders#de2ab007455aa2587c552694c68583f94c9f2747",
|
||||
"resolved": "git://github.com/mapbox/mapbox-gl-shaders.git#de2ab007455aa2587c552694c68583f94c9f2747"
|
||||
"resolved": "https://github.com/mapbox/mapbox-gl-shaders.git#de2ab007455aa2587c552694c68583f94c9f2747"
|
||||
},
|
||||
"mapbox-gl-style-spec": {
|
||||
"version": "8.8.0",
|
||||
"from": "mapbox/mapbox-gl-style-spec#83b1a3e5837d785af582efd5ed1a212f2df6a4ae",
|
||||
"resolved": "git://github.com/mapbox/mapbox-gl-style-spec.git#83b1a3e5837d785af582efd5ed1a212f2df6a4ae"
|
||||
"resolved": "https://github.com/mapbox/mapbox-gl-style-spec.git#83b1a3e5837d785af582efd5ed1a212f2df6a4ae"
|
||||
},
|
||||
"mapbox-gl-supported": {
|
||||
"version": "1.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "redash-client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"description": "The frontend part of Redash.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -33,6 +33,7 @@
|
||||
"angular-ui-bootstrap": "^2.2.0",
|
||||
"angular-vs-repeat": "^1.1.7",
|
||||
"brace": "^0.9.0",
|
||||
"core-js": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz",
|
||||
"cornelius": "git+https://github.com/restorando/cornelius.git",
|
||||
"d3": "^3.5.17",
|
||||
"d3-cloud": "^1.2.1",
|
||||
@@ -59,6 +60,7 @@
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.18.0",
|
||||
"babel-loader": "^6.2.7",
|
||||
"babel-plugin-transform-object-assign": "^6.22.0",
|
||||
"babel-preset-es2015": "^6.18.0",
|
||||
"babel-preset-stage-2": "^6.18.0",
|
||||
"css-loader": "^0.25.0",
|
||||
|
||||
@@ -16,7 +16,7 @@ from redash.query_runner import import_query_runners
|
||||
from redash.destinations import import_destinations
|
||||
|
||||
|
||||
__version__ = '1.0.0'
|
||||
__version__ = '1.0.2'
|
||||
|
||||
|
||||
def setup_logging():
|
||||
|
||||
@@ -85,10 +85,10 @@ def org_login(org_slug):
|
||||
@blueprint.route('/oauth/google', endpoint="authorize")
|
||||
def login():
|
||||
callback = url_for('.callback', _external=True)
|
||||
next = request.args.get('next', url_for("redash.index", org_slug=session.get('org_slug')))
|
||||
next_path = request.args.get('next', url_for("redash.index", org_slug=session.get('org_slug')))
|
||||
logger.debug("Callback url: %s", callback)
|
||||
logger.debug("Next is: %s", next)
|
||||
return google_remote_app().authorize(callback=callback, state=next)
|
||||
logger.debug("Next is: %s", next_path)
|
||||
return google_remote_app().authorize(callback=callback, state=next_path)
|
||||
|
||||
|
||||
@blueprint.route('/oauth/google_callback', endpoint="callback")
|
||||
@@ -118,6 +118,6 @@ def authorized():
|
||||
|
||||
create_and_login_user(org, profile['name'], profile['email'])
|
||||
|
||||
next = request.args.get('state') or url_for("redash.index", org_slug=org.slug)
|
||||
next_path = request.args.get('state') or url_for("redash.index", org_slug=org.slug)
|
||||
|
||||
return redirect(next)
|
||||
return redirect(next_path)
|
||||
|
||||
@@ -70,7 +70,7 @@ def run_query_sync(data_source, parameter_values, query_text, max_age=0):
|
||||
@routes.route(org_scoped_rule('/embed/query/<query_id>/visualization/<visualization_id>'), methods=['GET'])
|
||||
@login_required
|
||||
def embed(query_id, visualization_id, org_slug=None):
|
||||
record_event(current_org, current_user, {
|
||||
record_event(current_org, current_user._get_current_object(), {
|
||||
'action': 'view',
|
||||
'object_id': visualization_id,
|
||||
'object_type': 'visualization',
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import csv
|
||||
import json
|
||||
import cStringIO
|
||||
import time
|
||||
|
||||
import pystache
|
||||
from flask import make_response, request
|
||||
from flask_login import current_user
|
||||
from flask_restful import abort
|
||||
import xlsxwriter
|
||||
from redash import models, settings, utils
|
||||
from redash.tasks import QueryTask, record_event
|
||||
from redash.permissions import require_permission, not_view_only, has_access, require_access, view_only
|
||||
@@ -189,39 +186,13 @@ class QueryResultResource(BaseResource):
|
||||
|
||||
@staticmethod
|
||||
def make_csv_response(query_result):
|
||||
s = cStringIO.StringIO()
|
||||
|
||||
query_data = json.loads(query_result.data)
|
||||
writer = csv.DictWriter(s, fieldnames=[col['name'] for col in query_data['columns']])
|
||||
writer.writer = utils.UnicodeWriter(s)
|
||||
writer.writeheader()
|
||||
for row in query_data['rows']:
|
||||
writer.writerow(row)
|
||||
|
||||
headers = {'Content-Type': "text/csv; charset=UTF-8"}
|
||||
return make_response(s.getvalue(), 200, headers)
|
||||
return make_response(query_result.make_csv_content(), 200, headers)
|
||||
|
||||
@staticmethod
|
||||
def make_excel_response(query_result):
|
||||
s = cStringIO.StringIO()
|
||||
|
||||
query_data = json.loads(query_result.data)
|
||||
book = xlsxwriter.Workbook(s)
|
||||
sheet = book.add_worksheet("result")
|
||||
|
||||
column_names = []
|
||||
for (c, col) in enumerate(query_data['columns']):
|
||||
sheet.write(0, c, col['name'])
|
||||
column_names.append(col['name'])
|
||||
|
||||
for (r, row) in enumerate(query_data['rows']):
|
||||
for (c, name) in enumerate(column_names):
|
||||
sheet.write(r + 1, c, row.get(name))
|
||||
|
||||
book.close()
|
||||
|
||||
headers = {'Content-Type': "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}
|
||||
return make_response(s.getvalue(), 200, headers)
|
||||
return make_response(query_result.make_excel_content(), 200, headers)
|
||||
|
||||
|
||||
class JobResource(BaseResource):
|
||||
|
||||
100
redash/models.py
@@ -4,6 +4,9 @@ import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import cStringIO
|
||||
import csv
|
||||
import xlsxwriter
|
||||
|
||||
from funcy import project
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
@@ -13,7 +16,7 @@ from sqlalchemy.event import listens_for
|
||||
from sqlalchemy.inspection import inspect
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
from sqlalchemy.ext.mutable import Mutable
|
||||
from sqlalchemy.orm import object_session, backref
|
||||
from sqlalchemy.orm import object_session, backref, joinedload, subqueryload
|
||||
# noinspection PyUnresolvedReferences
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from sqlalchemy import or_
|
||||
@@ -28,7 +31,9 @@ from redash.utils import generate_token, json_dumps
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
from redash.metrics import database
|
||||
|
||||
db = SQLAlchemy()
|
||||
db = SQLAlchemy(session_options={
|
||||
'expire_on_commit': False
|
||||
})
|
||||
Column = functools.partial(db.Column, nullable=False)
|
||||
|
||||
# AccessPermission and Change use a 'generic foreign key' approach to refer to
|
||||
@@ -424,6 +429,9 @@ class DataSource(BelongsToOrgMixin, db.Model):
|
||||
__tablename__ = 'data_sources'
|
||||
__table_args__ = (db.Index('data_sources_org_id_name', 'org_id', 'name'),)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.id == other.id
|
||||
|
||||
def to_dict(self, all=False, with_permissions_for=None):
|
||||
d = {
|
||||
'id': self.id,
|
||||
@@ -641,8 +649,40 @@ class QueryResult(db.Model, BelongsToOrgMixin):
|
||||
def groups(self):
|
||||
return self.data_source.groups
|
||||
|
||||
def make_csv_content(self):
|
||||
s = cStringIO.StringIO()
|
||||
|
||||
def should_schedule_next(previous_iteration, now, schedule):
|
||||
query_data = json.loads(self.data)
|
||||
writer = csv.DictWriter(s, fieldnames=[col['name'] for col in query_data['columns']])
|
||||
writer.writer = utils.UnicodeWriter(s)
|
||||
writer.writeheader()
|
||||
for row in query_data['rows']:
|
||||
writer.writerow(row)
|
||||
|
||||
return s.getvalue()
|
||||
|
||||
def make_excel_content(self):
|
||||
s = cStringIO.StringIO()
|
||||
|
||||
query_data = json.loads(self.data)
|
||||
book = xlsxwriter.Workbook(s)
|
||||
sheet = book.add_worksheet("result")
|
||||
|
||||
column_names = []
|
||||
for (c, col) in enumerate(query_data['columns']):
|
||||
sheet.write(0, c, col['name'])
|
||||
column_names.append(col['name'])
|
||||
|
||||
for (r, row) in enumerate(query_data['rows']):
|
||||
for (c, name) in enumerate(column_names):
|
||||
sheet.write(r + 1, c, row.get(name))
|
||||
|
||||
book.close()
|
||||
|
||||
return s.getvalue()
|
||||
|
||||
|
||||
def should_schedule_next(previous_iteration, now, schedule, failures):
|
||||
if schedule.isdigit():
|
||||
ttl = int(schedule)
|
||||
next_iteration = previous_iteration + datetime.timedelta(seconds=ttl)
|
||||
@@ -659,7 +699,8 @@ def should_schedule_next(previous_iteration, now, schedule):
|
||||
previous_iteration = normalized_previous_iteration - datetime.timedelta(days=1)
|
||||
|
||||
next_iteration = (previous_iteration + datetime.timedelta(days=1)).replace(hour=hour, minute=minute)
|
||||
|
||||
if failures:
|
||||
next_iteration += datetime.timedelta(minutes=2**failures)
|
||||
return now > next_iteration
|
||||
|
||||
|
||||
@@ -685,6 +726,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
||||
is_archived = Column(db.Boolean, default=False, index=True)
|
||||
is_draft = Column(db.Boolean, default=True, index=True)
|
||||
schedule = Column(db.String(10), nullable=True)
|
||||
schedule_failures = Column(db.Integer, default=0)
|
||||
visualizations = db.relationship("Visualization", cascade="all, delete-orphan")
|
||||
options = Column(MutableDict.as_mutable(PseudoJSON), default={})
|
||||
|
||||
@@ -764,12 +806,12 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
||||
|
||||
@classmethod
|
||||
def all_queries(cls, group_ids, user_id=None, drafts=False):
|
||||
q = (cls.query.join(User, Query.user_id == User.id)
|
||||
.outerjoin(QueryResult)
|
||||
q = (cls.query
|
||||
.options(joinedload(Query.user),
|
||||
joinedload(Query.latest_query_data).load_only('runtime', 'retrieved_at'))
|
||||
.join(DataSourceGroup, Query.data_source_id == DataSourceGroup.data_source_id)
|
||||
.filter(Query.is_archived == False)
|
||||
.filter(DataSourceGroup.group_id.in_(group_ids))\
|
||||
.group_by(Query.id, User.id, QueryResult.id, QueryResult.retrieved_at, QueryResult.runtime)
|
||||
.order_by(Query.created_at.desc()))
|
||||
|
||||
if not drafts:
|
||||
@@ -784,15 +826,20 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
||||
@classmethod
|
||||
def outdated_queries(cls):
|
||||
queries = (db.session.query(Query)
|
||||
.join(QueryResult)
|
||||
.join(DataSource)
|
||||
.filter(Query.schedule != None))
|
||||
.options(joinedload(Query.latest_query_data).load_only('retrieved_at'))
|
||||
.filter(Query.schedule != None)
|
||||
.order_by(Query.id))
|
||||
|
||||
now = utils.utcnow()
|
||||
outdated_queries = {}
|
||||
for query in queries:
|
||||
if should_schedule_next(query.latest_query_data.retrieved_at, now, query.schedule):
|
||||
key = "{}:{}".format(query.query_hash, query.data_source.id)
|
||||
if query.latest_query_data:
|
||||
retrieved_at = query.latest_query_data.retrieved_at
|
||||
else:
|
||||
retrieved_at = now
|
||||
|
||||
if should_schedule_next(retrieved_at, now, query.schedule, query.schedule_failures):
|
||||
key = "{}:{}".format(query.query_hash, query.data_source_id)
|
||||
outdated_queries[key] = query
|
||||
|
||||
return outdated_queries.values()
|
||||
@@ -818,12 +865,11 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
||||
Query.data_source_id == DataSourceGroup.data_source_id)
|
||||
.filter(where)).distinct()
|
||||
|
||||
return Query.query.join(User, Query.user_id == User.id).filter(
|
||||
Query.id.in_(query_ids))
|
||||
return Query.query.options(joinedload(Query.user)).filter(Query.id.in_(query_ids))
|
||||
|
||||
@classmethod
|
||||
def recent(cls, group_ids, user_id=None, limit=20):
|
||||
query = (cls.query.join(User, Query.user_id == User.id)
|
||||
query = (cls.query.options(subqueryload(Query.user))
|
||||
.filter(Event.created_at > (db.func.current_date() - 7))
|
||||
.join(Event, Query.id == Event.object_id.cast(db.Integer))
|
||||
.join(DataSourceGroup, Query.data_source_id == DataSourceGroup.data_source_id)
|
||||
@@ -835,7 +881,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
||||
DataSourceGroup.group_id.in_(group_ids),
|
||||
or_(Query.is_draft == False, Query.user_id == user_id),
|
||||
Query.is_archived == False)
|
||||
.group_by(Event.object_id, Query.id, User.id)
|
||||
.group_by(Event.object_id, Query.id)
|
||||
.order_by(db.desc(db.func.count(0))))
|
||||
|
||||
if user_id:
|
||||
@@ -889,6 +935,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
||||
@listens_for(Query.query_text, 'set')
|
||||
def gen_query_hash(target, val, oldval, initiator):
|
||||
target.query_hash = utils.gen_query_hash(val)
|
||||
target.schedule_failures = 0
|
||||
|
||||
|
||||
@listens_for(Query.user_id, 'set')
|
||||
@@ -1024,12 +1071,11 @@ class Alert(TimestampMixin, db.Model):
|
||||
|
||||
@classmethod
|
||||
def all(cls, group_ids):
|
||||
# TODO: there was a join with user here to prevent N+1 queries. need to revisit this.
|
||||
return db.session.query(Alert)\
|
||||
.options(joinedload(Alert.user), joinedload(Alert.query_rel))\
|
||||
.join(Query)\
|
||||
.join(DataSourceGroup, DataSourceGroup.data_source_id==Query.data_source_id)\
|
||||
.filter(DataSourceGroup.group_id.in_(group_ids))\
|
||||
.group_by(Alert)
|
||||
.filter(DataSourceGroup.group_id.in_(group_ids))
|
||||
|
||||
@classmethod
|
||||
def get_by_id_and_org(cls, id, org):
|
||||
@@ -1316,7 +1362,7 @@ class Event(db.Model):
|
||||
action = Column(db.String(255))
|
||||
object_type = Column(db.String(255))
|
||||
object_id = Column(db.String(255), nullable=True)
|
||||
additional_properties = Column(db.Text, nullable=True)
|
||||
additional_properties = Column(MutableDict.as_mutable(PseudoJSON), nullable=True, default={})
|
||||
created_at = Column(db.DateTime(True), default=db.func.now())
|
||||
|
||||
__tablename__ = 'events'
|
||||
@@ -1324,6 +1370,17 @@ class Event(db.Model):
|
||||
def __unicode__(self):
|
||||
return u"%s,%s,%s,%s" % (self.user_id, self.action, self.object_type, self.object_id)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'org_id': self.org_id,
|
||||
'user_id': self.user_id,
|
||||
'action': self.action,
|
||||
'object_type': self.object_type,
|
||||
'object_id': self.object_id,
|
||||
'additional_properties': self.additional_properties,
|
||||
'created_at': self.created_at.isoformat()
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def record(cls, event):
|
||||
org_id = event.pop('org_id')
|
||||
@@ -1333,11 +1390,10 @@ class Event(db.Model):
|
||||
object_id = event.pop('object_id', None)
|
||||
|
||||
created_at = datetime.datetime.utcfromtimestamp(event.pop('timestamp'))
|
||||
additional_properties = json.dumps(event)
|
||||
|
||||
event = cls(org_id=org_id, user_id=user_id, action=action,
|
||||
object_type=object_type, object_id=object_id,
|
||||
additional_properties=additional_properties,
|
||||
additional_properties=event,
|
||||
created_at=created_at)
|
||||
db.session.add(event)
|
||||
return event
|
||||
|
||||
@@ -147,7 +147,7 @@ def register(query_runner_class):
|
||||
logger.debug("Registering %s (%s) query runner.", query_runner_class.name(), query_runner_class.type())
|
||||
query_runners[query_runner_class.type()] = query_runner_class
|
||||
else:
|
||||
logger.warning("%s query runner enabled but not supported, not registering. Either disable or install missing dependencies.", query_runner_class.name())
|
||||
logger.debug("%s query runner enabled but not supported, not registering. Either disable or install missing dependencies.", query_runner_class.name())
|
||||
|
||||
|
||||
def get_query_runner(query_runner_type, configuration):
|
||||
|
||||
201
redash/query_runner/axibase_tsd.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from io import StringIO
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
import csv
|
||||
|
||||
from redash.query_runner import *
|
||||
from redash.utils import JSONEncoder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import atsd_client
|
||||
from atsd_client.exceptions import SQLException
|
||||
from atsd_client.services import SQLService, MetricsService
|
||||
enabled = True
|
||||
except ImportError:
|
||||
enabled = False
|
||||
|
||||
types_map = {
|
||||
'long': TYPE_INTEGER,
|
||||
|
||||
'bigint': TYPE_INTEGER,
|
||||
'integer': TYPE_INTEGER,
|
||||
'smallint': TYPE_INTEGER,
|
||||
|
||||
'float': TYPE_FLOAT,
|
||||
'double': TYPE_FLOAT,
|
||||
'decimal': TYPE_FLOAT,
|
||||
|
||||
'string': TYPE_STRING,
|
||||
'date': TYPE_DATE,
|
||||
'xsd:dateTimeStamp': TYPE_DATETIME
|
||||
}
|
||||
|
||||
|
||||
def resolve_redash_type(type_in_atsd):
|
||||
"""
|
||||
Retrieve corresponding redash type
|
||||
:param type_in_atsd: `str`
|
||||
:return: redash type constant
|
||||
"""
|
||||
if isinstance(type_in_atsd, dict):
|
||||
type_in_redash = types_map.get(type_in_atsd['base'])
|
||||
else:
|
||||
type_in_redash = types_map.get(type_in_atsd)
|
||||
return type_in_redash
|
||||
|
||||
|
||||
def generate_rows_and_columns(csv_response):
|
||||
"""
|
||||
Prepare rows and columns in redash format from ATSD csv response
|
||||
:param csv_response: `str`
|
||||
:return: prepared rows and columns
|
||||
"""
|
||||
meta, data = csv_response.split('\n', 1)
|
||||
meta = meta[1:]
|
||||
|
||||
meta_with_padding = meta + '=' * (4 - len(meta) % 4)
|
||||
meta_decoded = meta_with_padding.decode('base64')
|
||||
meta_json = json.loads(meta_decoded)
|
||||
meta_columns = meta_json['tableSchema']['columns']
|
||||
|
||||
reader = csv.reader(data.splitlines())
|
||||
next(reader)
|
||||
|
||||
columns = [{'friendly_name': i['titles'],
|
||||
'type': resolve_redash_type(i['datatype']),
|
||||
'name': i['name']}
|
||||
for i in meta_columns]
|
||||
column_names = [c['name'] for c in columns]
|
||||
rows = [dict(zip(column_names, row)) for row in reader]
|
||||
return columns, rows
|
||||
|
||||
|
||||
class AxibaseTSD(BaseQueryRunner):
|
||||
noop_query = "SELECT 1"
|
||||
|
||||
@classmethod
|
||||
def enabled(cls):
|
||||
return enabled
|
||||
|
||||
@classmethod
|
||||
def name(cls):
|
||||
return "Axibase Time Series Database"
|
||||
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'protocol': {
|
||||
'type': 'string',
|
||||
'title': 'Protocol',
|
||||
'default': 'http'
|
||||
},
|
||||
'hostname': {
|
||||
'type': 'string',
|
||||
'title': 'Host',
|
||||
'default': 'axibase_tsd_hostname'
|
||||
},
|
||||
'port': {
|
||||
'type': 'number',
|
||||
'title': 'Port',
|
||||
'default': 8088
|
||||
},
|
||||
'username': {
|
||||
'type': 'string'
|
||||
},
|
||||
'password': {
|
||||
'type': 'string',
|
||||
'title': 'Password'
|
||||
},
|
||||
'timeout': {
|
||||
'type': 'number',
|
||||
'default': 600,
|
||||
'title': 'Connection Timeout'
|
||||
},
|
||||
'min_insert_date': {
|
||||
'type': 'string',
|
||||
'title': 'Metric Minimum Insert Date'
|
||||
},
|
||||
'expression': {
|
||||
'type': 'string',
|
||||
'title': 'Metric Filter'
|
||||
},
|
||||
'limit': {
|
||||
'type': 'number',
|
||||
'default': 5000,
|
||||
'title': 'Metric Limit'
|
||||
},
|
||||
'trust_certificate': {
|
||||
'type': 'boolean',
|
||||
'title': 'Trust SSL Certificate'
|
||||
}
|
||||
},
|
||||
'required': ['username', 'password', 'hostname', 'protocol', 'port'],
|
||||
'secret': ['password']
|
||||
}
|
||||
|
||||
def __init__(self, configuration):
|
||||
super(AxibaseTSD, self).__init__(configuration)
|
||||
self.url = '{0}://{1}:{2}'.format(self.configuration.get('protocol', 'http'),
|
||||
self.configuration.get('hostname', 'localhost'),
|
||||
self.configuration.get('port', 8088))
|
||||
|
||||
def run_query(self, query, user):
|
||||
connection = atsd_client.connect_url(self.url,
|
||||
self.configuration.get('username'),
|
||||
self.configuration.get('password'),
|
||||
verify=self.configuration.get('trust_certificate', False),
|
||||
timeout=self.configuration.get('timeout', 600))
|
||||
sql = SQLService(connection)
|
||||
query_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
logger.debug("SQL running query: %s", query)
|
||||
data = sql.query_with_params(query, {'outputFormat': 'csv', 'metadataFormat': 'EMBED',
|
||||
'queryId': query_id})
|
||||
|
||||
columns, rows = generate_rows_and_columns(data)
|
||||
|
||||
data = {'columns': columns, 'rows': rows}
|
||||
json_data = json.dumps(data, cls=JSONEncoder)
|
||||
error = None
|
||||
|
||||
except SQLException as e:
|
||||
json_data = None
|
||||
error = e.content
|
||||
except (KeyboardInterrupt, InterruptException):
|
||||
sql.cancel_query(query_id)
|
||||
error = "Query cancelled by user."
|
||||
json_data = None
|
||||
except Exception:
|
||||
raise sys.exc_info()[1], None, sys.exc_info()[2]
|
||||
|
||||
return json_data, error
|
||||
|
||||
def get_schema(self, get_stats=False):
|
||||
connection = atsd_client.connect_url(self.url,
|
||||
self.configuration.get('username'),
|
||||
self.configuration.get('password'),
|
||||
verify=self.configuration.get('trust_certificate', False),
|
||||
timeout=self.configuration.get('timeout', 600))
|
||||
metrics = MetricsService(connection)
|
||||
ml = metrics.list(expression=self.configuration.get('expression', None),
|
||||
minInsertDate=self.configuration.get('min_insert_date', None),
|
||||
limit=self.configuration.get('limit', 5000))
|
||||
metrics_list = [i.name.encode('utf-8') for i in ml]
|
||||
metrics_list.append('atsd_series')
|
||||
schema = {}
|
||||
default_columns = ['entity', 'datetime', 'time', 'metric', 'value', 'text',
|
||||
'tags', 'entity.tags', 'metric.tags']
|
||||
for table_name in metrics_list:
|
||||
schema[table_name] = {'name': "'{}'".format(table_name),
|
||||
'columns': default_columns}
|
||||
values = schema.values()
|
||||
return values
|
||||
|
||||
register(AxibaseTSD)
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from redash.query_runner import *
|
||||
from redash.utils import JSONEncoder
|
||||
import requests
|
||||
import re
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -74,13 +75,16 @@ class ClickHouse(BaseSQLQueryRunner):
|
||||
@staticmethod
|
||||
def _define_column_type(column):
|
||||
c = column.lower()
|
||||
if 'int' in c:
|
||||
f = re.search(r'^nullable\((.*)\)$', c)
|
||||
if f is not None:
|
||||
c = f.group(1)
|
||||
if c.startswith('int') or c.startswith('uint'):
|
||||
return TYPE_INTEGER
|
||||
elif 'float' in c:
|
||||
elif c.startswith('float'):
|
||||
return TYPE_FLOAT
|
||||
elif 'datetime' == c:
|
||||
elif c == 'datetime':
|
||||
return TYPE_DATETIME
|
||||
elif 'date' == c:
|
||||
elif c == 'date':
|
||||
return TYPE_DATE
|
||||
else:
|
||||
return TYPE_STRING
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
from redash.query_runner import *
|
||||
from redash.utils import JSONEncoder
|
||||
|
||||
@@ -98,12 +97,17 @@ class DynamoDBSQL(BaseSQLQueryRunner):
|
||||
try:
|
||||
engine = self._connect()
|
||||
|
||||
res_dict = engine.execute(query if str(query).endswith(';') else str(query)+';')
|
||||
result = engine.execute(query if str(query).endswith(';') else str(query)+';')
|
||||
|
||||
columns = []
|
||||
rows = []
|
||||
for item in res_dict:
|
||||
|
||||
# When running a count query it returns the value as a string, in which case
|
||||
# we transform it into a dictionary to be the same as regular queries.
|
||||
if isinstance(result, basestring):
|
||||
result = [{"value": result}]
|
||||
|
||||
for item in result:
|
||||
if not columns:
|
||||
for k, v in item.iteritems():
|
||||
columns.append({
|
||||
|
||||
@@ -82,11 +82,11 @@ class Impala(BaseSQLQueryRunner):
|
||||
def _get_tables(self, schema_dict):
|
||||
schemas_query = "show schemas;"
|
||||
tables_query = "show tables in %s;"
|
||||
columns_query = "show column stats %s;"
|
||||
columns_query = "show column stats %s.%s;"
|
||||
|
||||
for schema_name in map(lambda a: a['name'], self._run_query_internal(schemas_query)):
|
||||
for table_name in map(lambda a: a['name'], self._run_query_internal(tables_query % schema_name)):
|
||||
columns = map(lambda a: a['Column'], self._run_query_internal(columns_query % table_name))
|
||||
for schema_name in map(lambda a: unicode(a['name']), self._run_query_internal(schemas_query)):
|
||||
for table_name in map(lambda a: unicode(a['name']), self._run_query_internal(tables_query % schema_name)):
|
||||
columns = map(lambda a: unicode(a['Column']), self._run_query_internal(columns_query % (schema_name, table_name)))
|
||||
|
||||
if schema_name != 'default':
|
||||
table_name = '{}.{}'.format(schema_name, table_name)
|
||||
|
||||
@@ -94,7 +94,7 @@ class SqlServer(BaseSQLQueryRunner):
|
||||
def _get_tables(self, schema):
|
||||
query = """
|
||||
SELECT table_schema, table_name, column_name
|
||||
FROM information_schema.columns
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_schema NOT IN ('guest','INFORMATION_SCHEMA','sys','db_owner','db_accessadmin'
|
||||
,'db_securityadmin','db_ddladmin','db_backupoperator','db_datareader'
|
||||
,'db_datawriter','db_denydatareader','db_denydatawriter'
|
||||
|
||||
@@ -10,6 +10,7 @@ from collections import defaultdict
|
||||
|
||||
try:
|
||||
from pyhive import presto
|
||||
from pyhive.exc import DatabaseError
|
||||
enabled = True
|
||||
|
||||
except ImportError:
|
||||
@@ -112,9 +113,16 @@ class Presto(BaseQueryRunner):
|
||||
data = {'columns': columns, 'rows': rows}
|
||||
json_data = json.dumps(data, cls=JSONEncoder)
|
||||
error = None
|
||||
except DatabaseError, db:
|
||||
json_data = None
|
||||
default_message = 'Unspecified DatabaseError: {0}'.format(db.message)
|
||||
message = db.message.get('failureInfo', {'message', None}).get('message')
|
||||
error = default_message if message is None else message
|
||||
except Exception, ex:
|
||||
json_data = None
|
||||
error = ex.message
|
||||
if not isinstance(error, basestring):
|
||||
error = unicode(error)
|
||||
|
||||
return json_data, error
|
||||
|
||||
|
||||
181
redash/query_runner/salesforce.py
Normal file
@@ -0,0 +1,181 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import re
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from redash.query_runner import BaseQueryRunner, register
|
||||
from redash.query_runner import TYPE_STRING, TYPE_DATE, TYPE_DATETIME, TYPE_INTEGER, TYPE_FLOAT, TYPE_BOOLEAN
|
||||
from redash.utils import json_dumps
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from simple_salesforce import Salesforce as SimpleSalesforce
|
||||
from simple_salesforce.api import SalesforceError
|
||||
enabled = True
|
||||
except ImportError as e:
|
||||
enabled = False
|
||||
|
||||
# See https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/field_types.htm
|
||||
TYPES_MAP = dict(
|
||||
id=TYPE_STRING,
|
||||
string=TYPE_STRING,
|
||||
currency=TYPE_FLOAT,
|
||||
reference=TYPE_STRING,
|
||||
double=TYPE_FLOAT,
|
||||
picklist=TYPE_STRING,
|
||||
date=TYPE_DATE,
|
||||
url=TYPE_STRING,
|
||||
phone=TYPE_STRING,
|
||||
textarea=TYPE_STRING,
|
||||
int=TYPE_INTEGER,
|
||||
datetime=TYPE_DATETIME,
|
||||
boolean=TYPE_BOOLEAN,
|
||||
percent=TYPE_FLOAT,
|
||||
multipicklist=TYPE_STRING,
|
||||
masterrecord=TYPE_STRING,
|
||||
location=TYPE_STRING,
|
||||
JunctionIdList=TYPE_STRING,
|
||||
encryptedstring=TYPE_STRING,
|
||||
email=TYPE_STRING,
|
||||
DataCategoryGroupReference=TYPE_STRING,
|
||||
combobox=TYPE_STRING,
|
||||
calculated=TYPE_STRING,
|
||||
anyType=TYPE_STRING,
|
||||
address=TYPE_STRING
|
||||
)
|
||||
|
||||
# Query Runner for Salesforce SOQL Queries
|
||||
# For example queries, see:
|
||||
# https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_examples.htm
|
||||
|
||||
|
||||
class Salesforce(BaseQueryRunner):
|
||||
|
||||
@classmethod
|
||||
def enabled(cls):
|
||||
return enabled
|
||||
|
||||
@classmethod
|
||||
def annotate_query(cls):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"title": "Security Token"
|
||||
},
|
||||
"sandbox": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["username", "password", "token"],
|
||||
"secret": ["password", "token"]
|
||||
}
|
||||
|
||||
def test_connection(self):
|
||||
response = self._get_sf().describe()
|
||||
if response is None:
|
||||
raise Exception("Failed describing objects.")
|
||||
pass
|
||||
|
||||
def _get_sf(self):
|
||||
sf = SimpleSalesforce(username=self.configuration['username'],
|
||||
password=self.configuration['password'],
|
||||
security_token=self.configuration['token'],
|
||||
sandbox=self.configuration['sandbox'],
|
||||
client_id='Redash')
|
||||
return sf
|
||||
|
||||
def _clean_value(self, value):
|
||||
if isinstance(value, OrderedDict) and 'records' in value:
|
||||
value = value['records']
|
||||
for row in value:
|
||||
row.pop('attributes', None)
|
||||
return value
|
||||
|
||||
def _get_value(self, dct, dots):
|
||||
for key in dots.split('.'):
|
||||
dct = dct.get(key)
|
||||
return dct
|
||||
|
||||
def _get_column_name(self, key, parents=[]):
|
||||
return '.'.join(parents + [key])
|
||||
|
||||
def _build_columns(self, sf, child, parents=[]):
|
||||
child_type = child['attributes']['type']
|
||||
child_desc = sf.__getattr__(child_type).describe()
|
||||
child_type_map = dict((f['name'], f['type'])for f in child_desc['fields'])
|
||||
columns = []
|
||||
for key in child.keys():
|
||||
if key != 'attributes':
|
||||
if isinstance(child[key], OrderedDict) and 'attributes' in child[key]:
|
||||
columns.extend(self._build_columns(sf, child[key], parents + [key]))
|
||||
else:
|
||||
column_name = self._get_column_name(key, parents)
|
||||
key_type = child_type_map.get(key, 'string')
|
||||
column_type = TYPES_MAP.get(key_type, TYPE_STRING)
|
||||
columns.append((column_name, column_type))
|
||||
return columns
|
||||
|
||||
def _build_rows(self, columns, records):
|
||||
rows = []
|
||||
for record in records:
|
||||
record.pop('attributes', None)
|
||||
row = dict()
|
||||
for column in columns:
|
||||
key = column[0]
|
||||
value = self._get_value(record, key)
|
||||
row[key] = self._clean_value(value)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def run_query(self, query, user):
|
||||
logger.debug("Salesforce is about to execute query: %s", query)
|
||||
query = re.sub(r"/\*(.|\n)*?\*/", "", query).strip()
|
||||
try:
|
||||
columns = []
|
||||
rows = []
|
||||
sf = self._get_sf()
|
||||
response = sf.query_all(query)
|
||||
records = response['records']
|
||||
if response['totalSize'] > 0 and len(records) == 0:
|
||||
columns = self.fetch_columns([('Count', TYPE_INTEGER)])
|
||||
rows = [{'Count': response['totalSize']}]
|
||||
elif len(records) > 0:
|
||||
cols = self._build_columns(sf, records[0])
|
||||
rows = self._build_rows(cols, records)
|
||||
columns = self.fetch_columns(cols)
|
||||
error = None
|
||||
data = {'columns': columns, 'rows': rows}
|
||||
json_data = json_dumps(data)
|
||||
except SalesforceError as err:
|
||||
error = err.message
|
||||
json_data = None
|
||||
return json_data, error
|
||||
|
||||
def get_schema(self, get_stats=False):
|
||||
sf = self._get_sf()
|
||||
response = sf.describe()
|
||||
if response is None:
|
||||
raise Exception("Failed describing objects.")
|
||||
|
||||
schema = {}
|
||||
for sobject in response['sobjects']:
|
||||
table_name = sobject['name']
|
||||
if sobject['queryable'] is True and table_name not in schema:
|
||||
desc = sf.__getattr__(sobject['name']).describe()
|
||||
fields = desc['fields']
|
||||
schema[table_name] = {'name': table_name, 'columns': [f['name'] for f in fields]}
|
||||
return schema.values()
|
||||
|
||||
register(Salesforce)
|
||||
@@ -19,7 +19,7 @@ def public_widget(widget):
|
||||
}
|
||||
|
||||
if widget.visualization and widget.visualization.id:
|
||||
query_data = models.QueryResult.query.get(widget.visualization.query.latest_query_data_id).to_dict()
|
||||
query_data = models.QueryResult.query.get(widget.visualization.query_rel.latest_query_data_id).to_dict()
|
||||
res['visualization'] = {
|
||||
'type': widget.visualization.type,
|
||||
'name': widget.visualization.name,
|
||||
@@ -29,8 +29,8 @@ def public_widget(widget):
|
||||
'created_at': widget.visualization.created_at,
|
||||
'query': {
|
||||
'query': ' ', # workaround, as otherwise the query data won't be loaded.
|
||||
'name': widget.visualization.query.name,
|
||||
'description': widget.visualization.query.description,
|
||||
'name': widget.visualization.query_rel.name,
|
||||
'description': widget.visualization.query_rel.description,
|
||||
'options': {},
|
||||
'latest_query_data': query_data
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@ default_query_runners = [
|
||||
'redash.query_runner.mssql',
|
||||
'redash.query_runner.jql',
|
||||
'redash.query_runner.google_analytics',
|
||||
'redash.query_runner.snowflake'
|
||||
'redash.query_runner.snowflake',
|
||||
'redash.query_runner.axibase_tsd',
|
||||
'redash.query_runner.salesforce'
|
||||
]
|
||||
|
||||
enabled_query_runners = array_from_string(os.environ.get("REDASH_ENABLED_QUERY_RUNNERS", ",".join(default_query_runners)))
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../../frontend/app/assets/images/favicon-16x16.png
|
||||
|
Before Width: | Height: | Size: 53 B After Width: | Height: | Size: 1.3 KiB |
BIN
redash/static/images/favicon-16x16.png
Executable file
|
Before Width: | Height: | Size: 53 B After Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
../../../frontend/app/assets/images/favicon-32x32.png
|
||||
|
Before Width: | Height: | Size: 53 B After Width: | Height: | Size: 2.0 KiB |
BIN
redash/static/images/favicon-32x32.png
Executable file
|
Before Width: | Height: | Size: 53 B After Width: | Height: | Size: 2.0 KiB |
@@ -1 +0,0 @@
|
||||
../../../frontend/app/assets/images/favicon-96x96.png
|
||||
|
Before Width: | Height: | Size: 53 B After Width: | Height: | Size: 3.8 KiB |
BIN
redash/static/images/favicon-96x96.png
Executable file
|
Before Width: | Height: | Size: 53 B After Width: | Height: | Size: 3.8 KiB |
@@ -4,7 +4,6 @@ import datetime
|
||||
from redash.worker import celery
|
||||
from redash import utils
|
||||
from redash import models, settings
|
||||
from .base import BaseTask
|
||||
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
@@ -21,7 +20,7 @@ def notify_subscriptions(alert, new_state):
|
||||
host = base_url(alert.query_rel.org)
|
||||
for subscription in alert.subscriptions:
|
||||
try:
|
||||
subscription.notify(alert, alert.query, subscription.user, new_state, current_app, host)
|
||||
subscription.notify(alert, alert.query_rel, subscription.user, new_state, current_app, host)
|
||||
except Exception as e:
|
||||
logger.exception("Error with processing destination")
|
||||
|
||||
@@ -34,7 +33,7 @@ def should_notify(alert, new_state):
|
||||
return new_state != alert.state or (alert.state == models.Alert.TRIGGERED_STATE and passed_rearm_threshold)
|
||||
|
||||
|
||||
@celery.task(name="redash.tasks.check_alerts_for_query", base=BaseTask)
|
||||
@celery.task(name="redash.tasks.check_alerts_for_query")
|
||||
def check_alerts_for_query(query_id):
|
||||
logger.debug("Checking query %d for alerts", query_id)
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
from celery import Task
|
||||
from redash import create_app
|
||||
from flask import has_app_context, current_app
|
||||
|
||||
|
||||
class BaseTask(Task):
|
||||
abstract = True
|
||||
|
||||
def after_return(self, *args, **kwargs):
|
||||
if hasattr(self, 'app_ctx'):
|
||||
self.app_ctx.pop()
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
if not has_app_context():
|
||||
flask_app = current_app or create_app()
|
||||
self.app_ctx = flask_app.app_context()
|
||||
self.app_ctx.push()
|
||||
return super(BaseTask, self).__call__(*args, **kwargs)
|
||||
@@ -4,27 +4,30 @@ from flask_mail import Message
|
||||
from redash.worker import celery
|
||||
from redash.version_check import run_version_check
|
||||
from redash import models, mail, settings
|
||||
from .base import BaseTask
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
@celery.task(name="redash.tasks.record_event", base=BaseTask)
|
||||
def record_event(event):
|
||||
original_event = event.copy()
|
||||
models.Event.record(event)
|
||||
@celery.task(name="redash.tasks.record_event")
|
||||
def record_event(raw_event):
|
||||
event = models.Event.record(raw_event)
|
||||
models.db.session.commit()
|
||||
|
||||
for hook in settings.EVENT_REPORTING_WEBHOOKS:
|
||||
logger.debug("Forwarding event to: %s", hook)
|
||||
try:
|
||||
response = requests.post(hook, original_event)
|
||||
data = {
|
||||
"schema": "iglu:io.redash.webhooks/event/jsonschema/1-0-0",
|
||||
"data": event.to_dict()
|
||||
}
|
||||
response = requests.post(hook, json=data)
|
||||
if response.status_code != 200:
|
||||
logger.error("Failed posting to %s: %s", hook, response.content)
|
||||
except Exception:
|
||||
logger.exception("Failed posting to %s", hook)
|
||||
|
||||
|
||||
@celery.task(name="redash.tasks.version_check", base=BaseTask)
|
||||
@celery.task(name="redash.tasks.version_check")
|
||||
def version_check():
|
||||
run_version_check()
|
||||
|
||||
@@ -42,7 +45,7 @@ def subscribe(form):
|
||||
requests.post('https://beacon.redash.io/subscribe', json=data)
|
||||
|
||||
|
||||
@celery.task(name="redash.tasks.send_mail", base=BaseTask)
|
||||
@celery.task(name="redash.tasks.send_mail")
|
||||
def send_mail(to, subject, html, text):
|
||||
from redash.wsgi import app
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from redash import redis_connection, models, statsd_client, settings, utils
|
||||
from redash.utils import gen_query_hash
|
||||
from redash.worker import celery
|
||||
from redash.query_runner import InterruptException
|
||||
from .base import BaseTask
|
||||
from .alerts import check_alerts_for_query
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
@@ -155,23 +154,25 @@ class QueryTask(object):
|
||||
return self._async_result.id
|
||||
|
||||
def to_dict(self):
|
||||
if self._async_result.status == 'STARTED':
|
||||
updated_at = self._async_result.result.get('start_time', 0)
|
||||
task_info = self._async_result._get_task_meta()
|
||||
result, task_status = task_info['result'], task_info['status']
|
||||
if task_status == 'STARTED':
|
||||
updated_at = result.get('start_time', 0)
|
||||
else:
|
||||
updated_at = 0
|
||||
|
||||
status = self.STATUSES[self._async_result.status]
|
||||
status = self.STATUSES[task_status]
|
||||
|
||||
if isinstance(self._async_result.result, Exception):
|
||||
error = self._async_result.result.message
|
||||
if isinstance(result, Exception):
|
||||
error = result.message
|
||||
status = 4
|
||||
elif self._async_result.status == 'REVOKED':
|
||||
elif task_status == 'REVOKED':
|
||||
error = 'Query execution cancelled.'
|
||||
else:
|
||||
error = ''
|
||||
|
||||
if self._async_result.successful() and not error:
|
||||
query_result_id = self._async_result.result
|
||||
if task_status == 'SUCCESS' and not error:
|
||||
query_result_id = result
|
||||
else:
|
||||
query_result_id = None
|
||||
|
||||
@@ -198,7 +199,7 @@ class QueryTask(object):
|
||||
return self._async_result.revoke(terminate=True, signal='SIGINT')
|
||||
|
||||
|
||||
def enqueue_query(query, data_source, user_id, scheduled=False, metadata={}):
|
||||
def enqueue_query(query, data_source, user_id, scheduled_query=None, metadata={}):
|
||||
query_hash = gen_query_hash(query)
|
||||
logging.info("Inserting job for %s with metadata=%s", query_hash, metadata)
|
||||
try_count = 0
|
||||
@@ -224,14 +225,21 @@ def enqueue_query(query, data_source, user_id, scheduled=False, metadata={}):
|
||||
if not job:
|
||||
pipe.multi()
|
||||
|
||||
if scheduled:
|
||||
if scheduled_query:
|
||||
queue_name = data_source.scheduled_queue_name
|
||||
scheduled_query_id = scheduled_query.id
|
||||
else:
|
||||
queue_name = data_source.queue_name
|
||||
scheduled_query_id = None
|
||||
|
||||
result = execute_query.apply_async(args=(query, data_source.id, metadata, user_id), queue=queue_name)
|
||||
result = execute_query.apply_async(args=(
|
||||
query, data_source.id, metadata, user_id,
|
||||
scheduled_query_id),
|
||||
queue=queue_name)
|
||||
job = QueryTask(async_result=result)
|
||||
tracker = QueryTaskTracker.create(result.id, 'created', query_hash, data_source.id, scheduled, metadata)
|
||||
tracker = QueryTaskTracker.create(
|
||||
result.id, 'created', query_hash, data_source.id,
|
||||
scheduled_query is not None, metadata)
|
||||
tracker.save(connection=pipe)
|
||||
|
||||
logging.info("[%s] Created new job: %s", query_hash, job.id)
|
||||
@@ -248,7 +256,7 @@ def enqueue_query(query, data_source, user_id, scheduled=False, metadata={}):
|
||||
return job
|
||||
|
||||
|
||||
@celery.task(name="redash.tasks.refresh_queries", base=BaseTask)
|
||||
@celery.task(name="redash.tasks.refresh_queries")
|
||||
def refresh_queries():
|
||||
logger.info("Refreshing queries...")
|
||||
|
||||
@@ -263,7 +271,7 @@ def refresh_queries():
|
||||
logging.info("Skipping refresh of %s because datasource - %s is paused (%s).", query.id, query.data_source.name, query.data_source.pause_reason)
|
||||
else:
|
||||
enqueue_query(query.query_text, query.data_source, query.user_id,
|
||||
scheduled=True,
|
||||
scheduled_query=query,
|
||||
metadata={'Query ID': query.id, 'Username': 'Scheduled'})
|
||||
|
||||
query_ids.append(query.id)
|
||||
@@ -285,7 +293,7 @@ def refresh_queries():
|
||||
statsd_client.gauge('manager.seconds_since_refresh', now - float(status.get('last_refresh_at', now)))
|
||||
|
||||
|
||||
@celery.task(name="redash.tasks.cleanup_tasks", base=BaseTask)
|
||||
@celery.task(name="redash.tasks.cleanup_tasks")
|
||||
def cleanup_tasks():
|
||||
in_progress = QueryTaskTracker.all(QueryTaskTracker.IN_PROGRESS_LIST)
|
||||
for tracker in in_progress:
|
||||
@@ -317,7 +325,7 @@ def cleanup_tasks():
|
||||
QueryTaskTracker.prune(QueryTaskTracker.DONE_LIST, 1000)
|
||||
|
||||
|
||||
@celery.task(name="redash.tasks.cleanup_query_results", base=BaseTask)
|
||||
@celery.task(name="redash.tasks.cleanup_query_results")
|
||||
def cleanup_query_results():
|
||||
"""
|
||||
Job to cleanup unused query results -- such that no query links to them anymore, and older than
|
||||
@@ -331,15 +339,14 @@ def cleanup_query_results():
|
||||
settings.QUERY_RESULTS_CLEANUP_COUNT, settings.QUERY_RESULTS_CLEANUP_MAX_AGE)
|
||||
|
||||
unused_query_results = models.QueryResult.unused(settings.QUERY_RESULTS_CLEANUP_MAX_AGE).limit(settings.QUERY_RESULTS_CLEANUP_COUNT)
|
||||
total_unused_query_results = models.QueryResult.unused().count()
|
||||
deleted_count = models.Query.query.filter(
|
||||
models.Query.id.in_(unused_query_results.subquery())
|
||||
deleted_count = models.QueryResult.query.filter(
|
||||
models.QueryResult.id.in_(unused_query_results.subquery())
|
||||
).delete(synchronize_session=False)
|
||||
models.db.session.commit()
|
||||
logger.info("Deleted %d unused query results out of total of %d." % (deleted_count, total_unused_query_results))
|
||||
logger.info("Deleted %d unused query results.", deleted_count)
|
||||
|
||||
|
||||
@celery.task(name="redash.tasks.refresh_schemas", base=BaseTask)
|
||||
@celery.task(name="redash.tasks.refresh_schemas")
|
||||
def refresh_schemas():
|
||||
"""
|
||||
Refreshes the data sources schemas.
|
||||
@@ -380,7 +387,8 @@ class QueryExecutionError(Exception):
|
||||
# We could have created this as a celery.Task derived class, and act as the task itself. But this might result in weird
|
||||
# issues as the task class created once per process, so decided to have a plain object instead.
|
||||
class QueryExecutor(object):
|
||||
def __init__(self, task, query, data_source_id, user_id, metadata):
|
||||
def __init__(self, task, query, data_source_id, user_id, metadata,
|
||||
scheduled_query):
|
||||
self.task = task
|
||||
self.query = query
|
||||
self.data_source_id = data_source_id
|
||||
@@ -391,6 +399,7 @@ class QueryExecutor(object):
|
||||
else:
|
||||
self.user = None
|
||||
self.query_hash = gen_query_hash(self.query)
|
||||
self.scheduled_query = scheduled_query
|
||||
# Load existing tracker or create a new one if the job was created before code update:
|
||||
self.tracker = QueryTaskTracker.get_by_task_id(task.request.id) or QueryTaskTracker.create(task.request.id,
|
||||
'created',
|
||||
@@ -425,7 +434,14 @@ class QueryExecutor(object):
|
||||
if error:
|
||||
self.tracker.update(state='failed')
|
||||
result = QueryExecutionError(error)
|
||||
if self.scheduled_query:
|
||||
self.scheduled_query.schedule_failures += 1
|
||||
models.db.session.add(self.scheduled_query)
|
||||
else:
|
||||
if (self.scheduled_query and
|
||||
self.scheduled_query.schedule_failures > 0):
|
||||
self.scheduled_query.schedule_failures = 0
|
||||
models.db.session.add(self.scheduled_query)
|
||||
query_result, updated_query_ids = models.QueryResult.store_result(
|
||||
self.data_source.org, self.data_source,
|
||||
self.query_hash, self.query, data,
|
||||
@@ -452,10 +468,14 @@ class QueryExecutor(object):
|
||||
return annotated_query
|
||||
|
||||
def _log_progress(self, state):
|
||||
logger.info(u"task=execute_query state=%s query_hash=%s type=%s ds_id=%d task_id=%s queue=%s query_id=%s username=%s",
|
||||
state,
|
||||
self.query_hash, self.data_source.type, self.data_source.id, self.task.request.id, self.task.request.delivery_info['routing_key'],
|
||||
self.metadata.get('Query ID', 'unknown'), self.metadata.get('Username', 'unknown'))
|
||||
logger.info(
|
||||
u"task=execute_query state=%s query_hash=%s type=%s ds_id=%d "
|
||||
"task_id=%s queue=%s query_id=%s username=%s",
|
||||
state, self.query_hash, self.data_source.type, self.data_source.id,
|
||||
self.task.request.id,
|
||||
self.task.request.delivery_info['routing_key'],
|
||||
self.metadata.get('Query ID', 'unknown'),
|
||||
self.metadata.get('Username', 'unknown'))
|
||||
self.tracker.update(state=state)
|
||||
|
||||
def _load_data_source(self):
|
||||
@@ -465,6 +485,12 @@ class QueryExecutor(object):
|
||||
|
||||
# user_id is added last as a keyword argument for backward compatability -- to support executing previously submitted
|
||||
# jobs before the upgrade to this version.
|
||||
@celery.task(name="redash.tasks.execute_query", bind=True, base=BaseTask, track_started=True)
|
||||
def execute_query(self, query, data_source_id, metadata, user_id=None):
|
||||
return QueryExecutor(self, query, data_source_id, user_id, metadata).run()
|
||||
@celery.task(name="redash.tasks.execute_query", bind=True, track_started=True)
|
||||
def execute_query(self, query, data_source_id, metadata, user_id=None,
|
||||
scheduled_query_id=None):
|
||||
if scheduled_query_id is not None:
|
||||
scheduled_query = models.Query.query.get(scheduled_query_id)
|
||||
else:
|
||||
scheduled_query = None
|
||||
return QueryExecutor(self, query, data_source_id, user_id, metadata,
|
||||
scheduled_query).run()
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
{% endwith %}
|
||||
{% if google_auth_url %}
|
||||
<div class="row">
|
||||
<a href="{{ google_auth_url }}"><img src="/google_login.png" class="login-button"/></a>
|
||||
<a href="{{ google_auth_url }}"><img src="/images/google_login.png" class="login-button"/></a>
|
||||
</div>
|
||||
<div class="login-or">
|
||||
<hr class="hr-or">
|
||||
|
||||
@@ -2,10 +2,12 @@ from __future__ import absolute_import
|
||||
|
||||
from random import randint
|
||||
from celery import Celery
|
||||
from flask import current_app
|
||||
from datetime import timedelta
|
||||
from celery.schedules import crontab
|
||||
from redash import settings, __version__
|
||||
from redash.metrics import celery
|
||||
from celery.signals import worker_process_init
|
||||
from redash import settings, __version__, create_app
|
||||
from redash.metrics import celery as celery_metrics
|
||||
|
||||
|
||||
celery = Celery('redash',
|
||||
@@ -48,9 +50,29 @@ celery.conf.update(CELERY_RESULT_BACKEND=settings.CELERY_BACKEND,
|
||||
|
||||
if settings.SENTRY_DSN:
|
||||
from raven import Client
|
||||
from raven.contrib.celery import register_signal, register_logger_signal
|
||||
from raven.contrib.celery import register_signal
|
||||
|
||||
client = Client(settings.SENTRY_DSN, release=__version__)
|
||||
register_signal(client)
|
||||
|
||||
|
||||
# Create a new Task base class, that pushes a new Flask app context to allow DB connections if needed.
|
||||
TaskBase = celery.Task
|
||||
|
||||
|
||||
class ContextTask(TaskBase):
|
||||
abstract = True
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
with current_app.app_context():
|
||||
return TaskBase.__call__(self, *args, **kwargs)
|
||||
|
||||
celery.Task = ContextTask
|
||||
|
||||
|
||||
# Create Flask app after forking a new worker, to make sure no resources are shared between processes.
|
||||
@worker_process_init.connect
|
||||
def init_celery_flask_app(**kwargs):
|
||||
app = create_app()
|
||||
app.app_context().push()
|
||||
|
||||
|
||||
@@ -18,5 +18,7 @@ thrift>=0.8.0
|
||||
thrift_sasl>=0.1.0
|
||||
cassandra-driver==3.1.1
|
||||
snowflake_connector_python==1.3.7
|
||||
atsd_client==2.0.12
|
||||
simple_salesforce==0.72.2
|
||||
# certifi is needed to support MongoDB and SSL:
|
||||
certifi
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
# DEPRECATED
|
||||
(left for reference purposes only)
|
||||
|
||||
Bootstrap script for Amazon Linux AMI. *Not supported*, we recommend to use the Docker images instead.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Files used for the Docker image creation.
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/bash
|
||||
# This script assumes you're using docker-compose, with at least two images: redash for the redash instance
|
||||
# and postgres for the postgres instance.
|
||||
#
|
||||
# This script is not idempotent and should be run once.
|
||||
|
||||
run_redash="docker-compose run --rm redash"
|
||||
|
||||
$run_redash /opt/redash/current/manage.py database create_tables
|
||||
|
||||
# Create default admin user
|
||||
$run_redash /opt/redash/current/manage.py users create --admin --password admin "Admin" "admin"
|
||||
|
||||
# This is a hack to get the Postgres IP and PORT from the instance itself.
|
||||
temp_env_file=$(mktemp /tmp/pg_env.XXXXXX || exit 3)
|
||||
docker-compose run --rm postgres env > "$temp_env_file"
|
||||
source "$temp_env_file"
|
||||
|
||||
run_psql="docker-compose run --rm postgres psql -h $POSTGRES_PORT_5432_TCP_ADDR -p $POSTGRES_PORT_5432_TCP_PORT -U postgres"
|
||||
|
||||
# Create redash_reader user. We don't use a strong password, as the instance supposed to be accesible only from the redash host.
|
||||
$run_psql -c "CREATE ROLE redash_reader WITH PASSWORD 'redash_reader' NOCREATEROLE NOCREATEDB NOSUPERUSER LOGIN"
|
||||
$run_psql -c "grant select(id,name,type) ON data_sources to redash_reader;"
|
||||
$run_psql -c "grant select(id,name) ON users to redash_reader;"
|
||||
$run_psql -c "grant select on events, queries, dashboards, widgets, visualizations, query_results to redash_reader;"
|
||||
|
||||
$run_redash /opt/redash/current/manage.py ds new "Redash Metadata" --type "pg" --options "{\"user\": \"redash_reader\", \"password\": \"redash_reader\", \"host\": \"postgres\", \"dbname\": \"postgres\"}"
|
||||
@@ -1,8 +0,0 @@
|
||||
FROM nginx
|
||||
MAINTAINER Di Wu <diwu@yelp.com>
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
RUN mkdir -p /var/log/nginx/log && \
|
||||
touch /var/log/nginx/log/access.log && \
|
||||
touch /var/log/nginx/log/error.log
|
||||
@@ -1,30 +0,0 @@
|
||||
events {
|
||||
worker_connections 4096; # Default: 1024
|
||||
}
|
||||
|
||||
http {
|
||||
server_tokens off;
|
||||
|
||||
upstream redashapp {
|
||||
server redash:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
access_log /var/log/nginx/log/access.log;
|
||||
error_log /var/log/nginx/log/error.log;
|
||||
|
||||
gzip on;
|
||||
gzip_types *;
|
||||
gzip_proxied any;
|
||||
|
||||
location / {
|
||||
proxy_pass http://redashapp;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,32 @@
|
||||
},
|
||||
"builders": [
|
||||
{
|
||||
"name": "redash-eu-west-1",
|
||||
"name": "redash-us-east-1",
|
||||
"type": "amazon-ebs",
|
||||
"access_key": "{{user `aws_access_key`}}",
|
||||
"secret_key": "{{user `aws_secret_key`}}",
|
||||
"region": "eu-west-1",
|
||||
"source_ami": "ami-6177f712",
|
||||
"region": "us-east-1",
|
||||
"source_ami": "ami-4dd2575b",
|
||||
"instance_type": "t2.micro",
|
||||
"ssh_username": "ubuntu",
|
||||
"ami_name": "redash-{{user `image_version`}}-eu-west-1"
|
||||
"ami_name": "redash-{{user `image_version`}}-us-east-1"
|
||||
},
|
||||
{
|
||||
"type": "googlecompute",
|
||||
"account_file": "account.json",
|
||||
"project_id": "redash-bird-123",
|
||||
"source_image_family": "ubuntu-1604-lts",
|
||||
"zone": "us-central1-a",
|
||||
"ssh_username": "arik"
|
||||
}
|
||||
],
|
||||
"provisioners": [
|
||||
{
|
||||
"type": "shell",
|
||||
"inline": [
|
||||
"sleep 30"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"script": "ubuntu/bootstrap.sh",
|
||||
@@ -33,5 +47,15 @@
|
||||
"type": "shell",
|
||||
"inline": "sudo rm /home/ubuntu/.ssh/authorized_keys || true"
|
||||
}
|
||||
],
|
||||
"post-processors": [
|
||||
{
|
||||
"type": "googlecompute-export",
|
||||
"only": ["googlecompute"],
|
||||
"paths": [
|
||||
"gs://redash-images/redash.{{user `redash_version`}}.tar.gz"
|
||||
],
|
||||
"keep_input_artifact": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
Bootstrap scripts for Ubuntu (tested on Ubuntu 14.04, although should work with 12.04).
|
||||
Bootstrap scripts for Ubuntu 16.04.
|
||||
|
||||
@@ -1,195 +1,110 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# This script setups Redash along with supervisor, nginx, PostgreSQL and Redis. It was written to be used on
|
||||
# Ubuntu 16.04. Technically it can work with other Ubuntu versions, but you might get non compatible versions
|
||||
# of PostgreSQL, Redis and maybe some other dependencies.
|
||||
#
|
||||
# This script is not idempotent and if it stops in the middle, you can't just run it again. You should either
|
||||
# understand what parts of it to exclude or just start over on a new VM (assuming you're using a VM).
|
||||
|
||||
set -eu
|
||||
|
||||
REDASH_BASE_PATH=/opt/redash
|
||||
|
||||
# Default branch/version to master if not specified in REDASH_BRANCH env var
|
||||
REDASH_BRANCH="${REDASH_BRANCH:-master}"
|
||||
|
||||
# Install latest version if not specified in REDASH_VERSION env var
|
||||
REDASH_VERSION=${REDASH_VERSION-0.12.0.b2449}
|
||||
LATEST_URL="https://github.com/getredash/redash/releases/download/v${REDASH_VERSION}/redash.${REDASH_VERSION}.tar.gz"
|
||||
REDASH_BRANCH="${REDASH_BRANCH:-master}" # Default branch/version to master if not specified in REDASH_BRANCH env var
|
||||
REDASH_VERSION=${REDASH_VERSION-1.0.1.b2833} # Install latest version if not specified in REDASH_VERSION env var
|
||||
LATEST_URL="https://s3.amazonaws.com/redash-releases/redash.${REDASH_VERSION}.tar.gz"
|
||||
VERSION_DIR="/opt/redash/redash.${REDASH_VERSION}"
|
||||
REDASH_TARBALL=/tmp/redash.tar.gz
|
||||
FILES_BASE_URL=https://raw.githubusercontent.com/getredash/redash/${REDASH_BRANCH}/setup/ubuntu/files
|
||||
|
||||
FILES_BASE_URL=https://raw.githubusercontent.com/getredash/redash/${REDASH_BRANCH}/setup/ubuntu/files/
|
||||
cd /tmp/
|
||||
|
||||
# Verify running as root:
|
||||
if [ "$(id -u)" != "0" ]; then
|
||||
if [ $# -ne 0 ]; then
|
||||
echo "Failed running with sudo. Exiting." 1>&2
|
||||
exit 1
|
||||
verify_root() {
|
||||
# Verify running as root:
|
||||
if [ "$(id -u)" != "0" ]; then
|
||||
if [ $# -ne 0 ]; then
|
||||
echo "Failed running with sudo. Exiting." 1>&2
|
||||
exit 1
|
||||
fi
|
||||
echo "This script must be run as root. Trying to run with sudo."
|
||||
sudo bash "$0" --with-sudo
|
||||
exit 0
|
||||
fi
|
||||
echo "This script must be run as root. Trying to run with sudo."
|
||||
sudo bash "$0" --with-sudo
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Base packages
|
||||
apt-get -y update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade
|
||||
apt-get install -y python-pip python-dev nginx curl build-essential pwgen
|
||||
# BigQuery dependencies:
|
||||
apt-get install -y libffi-dev libssl-dev
|
||||
# MySQL dependencies:
|
||||
apt-get install -y libmysqlclient-dev
|
||||
# Microsoft SQL Server dependencies:
|
||||
apt-get install -y freetds-dev
|
||||
# Hive dependencies:
|
||||
apt-get install -y libsasl2-dev
|
||||
#Saml dependency
|
||||
apt-get install -y xmlsec1
|
||||
|
||||
# Upgrade pip if host is Ubuntu 16.04
|
||||
if [[ $(lsb_release -d) = *Ubuntu* ]] && [[ $(lsb_release -rs) = *16.04* ]]; then
|
||||
pip install --upgrade pip
|
||||
fi
|
||||
pip install -U setuptools==23.1.0
|
||||
|
||||
# redash user
|
||||
# TODO: check user doesn't exist yet?
|
||||
adduser --system --no-create-home --disabled-login --gecos "" redash
|
||||
|
||||
# PostgreSQL
|
||||
pg_available=0
|
||||
psql --version || pg_available=$?
|
||||
if [ $pg_available -ne 0 ]; then
|
||||
wget $FILES_BASE_URL"postgres_apt.sh" -O /tmp/postgres_apt.sh
|
||||
bash /tmp/postgres_apt.sh
|
||||
apt-get update
|
||||
apt-get -y install postgresql-9.3 postgresql-server-dev-9.3
|
||||
fi
|
||||
|
||||
add_service() {
|
||||
service_name=$1
|
||||
service_command="/etc/init.d/$service_name"
|
||||
|
||||
echo "Adding service: $service_name (/etc/init.d/$service_name)."
|
||||
chmod +x "$service_command"
|
||||
|
||||
if command -v chkconfig >/dev/null 2>&1; then
|
||||
# we're chkconfig, so lets add to chkconfig and put in runlevel 345
|
||||
chkconfig --add "$service_name" && echo "Successfully added to chkconfig!"
|
||||
chkconfig --level 345 "$service_name" on && echo "Successfully added to runlevels 345!"
|
||||
elif command -v update-rc.d >/dev/null 2>&1; then
|
||||
#if we're not a chkconfig box assume we're able to use update-rc.d
|
||||
update-rc.d "$service_name" defaults && echo "Success!"
|
||||
else
|
||||
echo "No supported init tool found."
|
||||
fi
|
||||
|
||||
$service_command start
|
||||
}
|
||||
|
||||
# Redis
|
||||
redis_available=0
|
||||
redis-cli --version || redis_available=$?
|
||||
if [ $redis_available -ne 0 ]; then
|
||||
wget http://download.redis.io/releases/redis-2.8.17.tar.gz
|
||||
tar xzf redis-2.8.17.tar.gz
|
||||
rm redis-2.8.17.tar.gz
|
||||
(cd redis-2.8.17
|
||||
make
|
||||
make install
|
||||
create_redash_user() {
|
||||
adduser --system --no-create-home --disabled-login --gecos "" redash
|
||||
}
|
||||
|
||||
# Setup process init & configuration
|
||||
install_system_packages() {
|
||||
apt-get -y update
|
||||
# Base packages
|
||||
apt install -y python-pip python-dev nginx curl build-essential pwgen
|
||||
# Data sources dependencies:
|
||||
apt install -y libffi-dev libssl-dev libmysqlclient-dev libpq-dev freetds-dev libsasl2-dev
|
||||
# SAML dependency
|
||||
apt install -y xmlsec1
|
||||
# Storage servers
|
||||
apt install -y postgresql redis-server
|
||||
apt install -y supervisor
|
||||
}
|
||||
|
||||
REDIS_PORT=6379
|
||||
REDIS_CONFIG_FILE="/etc/redis/$REDIS_PORT.conf"
|
||||
REDIS_LOG_FILE="/var/log/redis_$REDIS_PORT.log"
|
||||
REDIS_DATA_DIR="/var/lib/redis/$REDIS_PORT"
|
||||
create_directories() {
|
||||
mkdir /opt/redash
|
||||
chown redash /opt/redash
|
||||
|
||||
# Default config file
|
||||
if [ ! -f "/opt/redash/.env" ]; then
|
||||
sudo -u redash wget "$FILES_BASE_URL/env" -O /opt/redash/.env
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$REDIS_CONFIG_FILE")" || die "Could not create redis config directory"
|
||||
mkdir -p "$(dirname "$REDIS_LOG_FILE")" || die "Could not create redis log dir"
|
||||
mkdir -p "$REDIS_DATA_DIR" || die "Could not create redis data directory"
|
||||
COOKIE_SECRET=$(pwgen -1s 32)
|
||||
echo "export REDASH_COOKIE_SECRET=$COOKIE_SECRET" >> /opt/redash/.env
|
||||
}
|
||||
|
||||
wget -O /etc/init.d/redis_6379 $FILES_BASE_URL"redis_init"
|
||||
wget -O $REDIS_CONFIG_FILE $FILES_BASE_URL"redis.conf"
|
||||
|
||||
add_service "redis_$REDIS_PORT"
|
||||
)
|
||||
rm -rf redis-2.8.17
|
||||
fi
|
||||
|
||||
# Directories
|
||||
if [ ! -d "$REDASH_BASE_PATH" ]; then
|
||||
sudo mkdir /opt/redash
|
||||
sudo chown redash /opt/redash
|
||||
sudo -u redash mkdir /opt/redash/logs
|
||||
fi
|
||||
|
||||
# Default config file
|
||||
if [ ! -f "/opt/redash/.env" ]; then
|
||||
sudo -u redash wget $FILES_BASE_URL"env" -O /opt/redash/.env
|
||||
echo 'export REDASH_STATIC_ASSETS_PATH="../rd_ui/dist/"' >> /opt/redash/.env
|
||||
fi
|
||||
|
||||
if [ ! -d "$VERSION_DIR" ]; then
|
||||
extract_redash_sources() {
|
||||
sudo -u redash wget "$LATEST_URL" -O "$REDASH_TARBALL"
|
||||
sudo -u redash mkdir "$VERSION_DIR"
|
||||
sudo -u redash tar -C "$VERSION_DIR" -xvf "$REDASH_TARBALL"
|
||||
ln -nfs "$VERSION_DIR" /opt/redash/current
|
||||
ln -nfs /opt/redash/.env /opt/redash/current/.env
|
||||
}
|
||||
|
||||
cd /opt/redash/current
|
||||
|
||||
install_python_packages() {
|
||||
pip install --upgrade pip
|
||||
# TODO: venv?
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
pip install setproctitle # setproctitle is used by Celery for "pretty" process titles
|
||||
pip install -r /opt/redash/current/requirements.txt
|
||||
pip install -r /opt/redash/current/requirements_all_ds.txt
|
||||
}
|
||||
|
||||
# Create database / tables
|
||||
pg_user_exists=0
|
||||
sudo -u postgres psql postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='redash'" | grep -q 1 || pg_user_exists=$?
|
||||
if [ $pg_user_exists -ne 0 ]; then
|
||||
echo "Creating redash postgres user & database."
|
||||
create_database() {
|
||||
# Create user and database
|
||||
sudo -u postgres createuser redash --no-superuser --no-createdb --no-createrole
|
||||
sudo -u postgres createdb redash --owner=redash
|
||||
|
||||
cd /opt/redash/current
|
||||
sudo -u redash bin/run ./manage.py database create_tables
|
||||
fi
|
||||
}
|
||||
|
||||
# Create default admin user
|
||||
cd /opt/redash/current
|
||||
# TODO: make sure user created only once
|
||||
# TODO: generate temp password and print to screen
|
||||
sudo -u redash bin/run ./manage.py users create --admin --password admin "Admin" "admin"
|
||||
setup_supervisor() {
|
||||
wget -O /etc/supervisor/conf.d/redash.conf "$FILES_BASE_URL/supervisord.conf"
|
||||
service supervisor restart
|
||||
}
|
||||
|
||||
# Create Redash read only pg user & setup data source
|
||||
pg_user_exists=0
|
||||
sudo -u postgres psql postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='redash_reader'" | grep -q 1 || pg_user_exists=$?
|
||||
if [ $pg_user_exists -ne 0 ]; then
|
||||
echo "Creating redash reader postgres user."
|
||||
REDASH_READER_PASSWORD=$(pwgen -1)
|
||||
sudo -u postgres psql -c "CREATE ROLE redash_reader WITH PASSWORD '$REDASH_READER_PASSWORD' NOCREATEROLE NOCREATEDB NOSUPERUSER LOGIN"
|
||||
sudo -u redash psql -c "grant select(id,name,type) ON data_sources to redash_reader;" redash
|
||||
sudo -u redash psql -c "grant select(id,name) ON users to redash_reader;" redash
|
||||
sudo -u redash psql -c "grant select on alerts, alert_subscriptions, groups, events, queries, dashboards, widgets, visualizations, query_results to redash_reader;" redash
|
||||
setup_nginx() {
|
||||
rm /etc/nginx/sites-enabled/default
|
||||
wget -O /etc/nginx/sites-available/redash "$FILES_BASE_URL/nginx_redash_site"
|
||||
ln -nfs /etc/nginx/sites-available/redash /etc/nginx/sites-enabled/redash
|
||||
service nginx restart
|
||||
}
|
||||
|
||||
cd /opt/redash/current
|
||||
sudo -u redash bin/run ./manage.py ds new "Redash Metadata" --type "pg" --options "{\"user\": \"redash_reader\", \"password\": \"$REDASH_READER_PASSWORD\", \"host\": \"localhost\", \"dbname\": \"redash\"}"
|
||||
fi
|
||||
|
||||
# Pip requirements for all data source types
|
||||
cd /opt/redash/current
|
||||
pip install -r requirements_all_ds.txt
|
||||
|
||||
# Setup supervisord + sysv init startup script
|
||||
sudo -u redash mkdir -p /opt/redash/supervisord
|
||||
pip install supervisor==3.1.2 # TODO: move to requirements.txt
|
||||
|
||||
# Get supervisord startup script
|
||||
sudo -u redash wget -O /opt/redash/supervisord/supervisord.conf $FILES_BASE_URL"supervisord.conf"
|
||||
|
||||
wget -O /etc/init.d/redash_supervisord $FILES_BASE_URL"redash_supervisord_init"
|
||||
add_service "redash_supervisord"
|
||||
|
||||
# Nginx setup
|
||||
rm /etc/nginx/sites-enabled/default
|
||||
wget -O /etc/nginx/sites-available/redash $FILES_BASE_URL"nginx_redash_site"
|
||||
ln -nfs /etc/nginx/sites-available/redash /etc/nginx/sites-enabled/redash
|
||||
service nginx restart
|
||||
|
||||
# Hotfix: missing query snippets table:
|
||||
cd /opt/redash/current
|
||||
sudo -u redash bin/run python -c "from redash import models; models.QuerySnippet.create_table()"
|
||||
verify_root
|
||||
install_system_packages
|
||||
create_redash_user
|
||||
create_directories
|
||||
extract_redash_sources
|
||||
install_python_packages
|
||||
create_database
|
||||
setup_supervisor
|
||||
setup_nginx
|
||||
@@ -1,4 +1,3 @@
|
||||
export REDASH_LOG_LEVEL="INFO"
|
||||
export REDASH_REDIS_URL=redis://localhost:6379/0
|
||||
export REDASH_DATABASE_URL="postgresql://redash"
|
||||
export REDASH_COOKIE_SECRET=veryverysecret
|
||||
export REDASH_DATABASE_URL="postgresql:///redash"
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# script to add apt.postgresql.org to sources.list
|
||||
|
||||
# from command line
|
||||
CODENAME="$1"
|
||||
# lsb_release is the best interface, but not always available
|
||||
if [ -z "$CODENAME" ]; then
|
||||
CODENAME=$(lsb_release -cs 2>/dev/null)
|
||||
fi
|
||||
# parse os-release (unreliable, does not work on Ubuntu)
|
||||
if [ -z "$CODENAME" -a -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
# Debian: VERSION="7.0 (wheezy)"
|
||||
# Ubuntu: VERSION="13.04, Raring Ringtail"
|
||||
CODENAME=$(echo $VERSION | sed -ne 's/.*(\(.*\)).*/\1/')
|
||||
fi
|
||||
# guess from sources.list
|
||||
if [ -z "$CODENAME" ]; then
|
||||
CODENAME=$(grep '^deb ' /etc/apt/sources.list | head -n1 | awk '{ print $3 }')
|
||||
fi
|
||||
# complain if no result yet
|
||||
if [ -z "$CODENAME" ]; then
|
||||
cat <<EOF
|
||||
Could not determine the distribution codename. Please report this as a bug to
|
||||
pgsql-pkg-debian@postgresql.org. As a workaround, you can call this script with
|
||||
the proper codename as parameter, e.g. "$0 squeeze".
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# errors are non-fatal above
|
||||
set -e
|
||||
|
||||
cat <<EOF
|
||||
This script will enable the PostgreSQL APT repository on apt.postgresql.org on
|
||||
your system. The distribution codename used will be $CODENAME-pgdg.
|
||||
|
||||
EOF
|
||||
|
||||
case $CODENAME in
|
||||
# known distributions
|
||||
sid|wheezy|squeeze|lenny|etch) ;;
|
||||
precise|lucid) ;;
|
||||
*) # unknown distribution, verify on the web
|
||||
DISTURL="http://apt.postgresql.org/pub/repos/apt/dists/"
|
||||
if [ -x /usr/bin/curl ]; then
|
||||
DISTHTML=$(curl -s $DISTURL)
|
||||
elif [ -x /usr/bin/wget ]; then
|
||||
DISTHTML=$(wget --quiet -O - $DISTURL)
|
||||
fi
|
||||
if [ "$DISTHTML" ]; then
|
||||
if ! echo "$DISTHTML" | grep -q "$CODENAME-pgdg"; then
|
||||
cat <<EOF
|
||||
Your system is using the distribution codename $CODENAME, but $CODENAME-pgdg
|
||||
does not seem to be a valid distribution on
|
||||
$DISTURL
|
||||
|
||||
We abort the installation here. If you want to use a distribution different
|
||||
from your system, you can call this script with an explicit codename, e.g.
|
||||
"$0 precise".
|
||||
|
||||
Specifically, if you are using a non-LTS Ubuntu release, refer to
|
||||
https://wiki.postgresql.org/wiki/Apt/FAQ#I_am_using_a_non-LTS_release_of_Ubuntu
|
||||
|
||||
For more information, refer to https://wiki.postgresql.org/wiki/Apt
|
||||
or ask on the mailing list for assistance: pgsql-pkg-debian@postgresql.org
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Writing /etc/apt/sources.list.d/pgdg.list ..."
|
||||
cat > /etc/apt/sources.list.d/pgdg.list <<EOF
|
||||
deb http://apt.postgresql.org/pub/repos/apt/ $CODENAME-pgdg main
|
||||
#deb-src http://apt.postgresql.org/pub/repos/apt/ $CODENAME-pgdg main
|
||||
EOF
|
||||
|
||||
echo "Importing repository signing key ..."
|
||||
KEYRING="/etc/apt/trusted.gpg.d/apt.postgresql.org.gpg"
|
||||
test -e $KEYRING || touch $KEYRING
|
||||
apt-key --keyring $KEYRING add - <<EOF
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Version: GnuPG v1
|
||||
|
||||
mQINBE6XR8IBEACVdDKT2HEH1IyHzXkb4nIWAY7echjRxo7MTcj4vbXAyBKOfjja
|
||||
UrBEJWHN6fjKJXOYWXHLIYg0hOGeW9qcSiaa1/rYIbOzjfGfhE4x0Y+NJHS1db0V
|
||||
G6GUj3qXaeyqIJGS2z7m0Thy4Lgr/LpZlZ78Nf1fliSzBlMo1sV7PpP/7zUO+aA4
|
||||
bKa8Rio3weMXQOZgclzgeSdqtwKnyKTQdXY5MkH1QXyFIk1nTfWwyqpJjHlgtwMi
|
||||
c2cxjqG5nnV9rIYlTTjYG6RBglq0SmzF/raBnF4Lwjxq4qRqvRllBXdFu5+2pMfC
|
||||
IZ10HPRdqDCTN60DUix+BTzBUT30NzaLhZbOMT5RvQtvTVgWpeIn20i2NrPWNCUh
|
||||
hj490dKDLpK/v+A5/i8zPvN4c6MkDHi1FZfaoz3863dylUBR3Ip26oM0hHXf4/2U
|
||||
A/oA4pCl2W0hc4aNtozjKHkVjRx5Q8/hVYu+39csFWxo6YSB/KgIEw+0W8DiTII3
|
||||
RQj/OlD68ZDmGLyQPiJvaEtY9fDrcSpI0Esm0i4sjkNbuuh0Cvwwwqo5EF1zfkVj
|
||||
Tqz2REYQGMJGc5LUbIpk5sMHo1HWV038TWxlDRwtOdzw08zQA6BeWe9FOokRPeR2
|
||||
AqhyaJJwOZJodKZ76S+LDwFkTLzEKnYPCzkoRwLrEdNt1M7wQBThnC5z6wARAQAB
|
||||
tBxQb3N0Z3JlU1FMIERlYmlhbiBSZXBvc2l0b3J5iQI9BBMBCAAnAhsDBQsJCAcD
|
||||
BRUKCQgLBRYCAwEAAh4BAheABQJS6RUZBQkOhCctAAoJEH/MfUaszEz4zmQP/2ad
|
||||
HtuaXL5Xu3C3NGLha/aQb9iSJC8z5vN55HMCpsWlmslCBuEr+qR+oZvPkvwh0Io/
|
||||
8hQl/qN54DMNifRwVL2n2eG52yNERie9BrAMK2kNFZZCH4OxlMN0876BmDuNq2U6
|
||||
7vUtCv+pxT+g9R1LvlPgLCTjS3m+qMqUICJ310BMT2cpYlJx3YqXouFkdWBVurI0
|
||||
pGU/+QtydcJALz5eZbzlbYSPWbOm2ZSS2cLrCsVNFDOAbYLtUn955yXB5s4rIscE
|
||||
vTzBxPgID1iBknnPzdu2tCpk07yJleiupxI1yXstCtvhGCbiAbGFDaKzhgcAxSIX
|
||||
0ZPahpaYLdCkcoLlfgD+ar4K8veSK2LazrhO99O0onRG0p7zuXszXphO4E/WdbTO
|
||||
yDD35qCqYeAX6TaB+2l4kIdVqPgoXT/doWVLUK2NjZtd3JpMWI0OGYDFn2DAvgwP
|
||||
xqKEoGTOYuoWKssnwLlA/ZMETegak27gFAKfoQlmHjeA/PLC2KRYd6Wg2DSifhn+
|
||||
2MouoE4XFfeekVBQx98rOQ5NLwy/TYlsHXm1n0RW86ETN3chj/PPWjsi80t5oepx
|
||||
82azRoVu95LJUkHpPLYyqwfueoVzp2+B2hJU2Rg7w+cJq64TfeJG8hrc93MnSKIb
|
||||
zTvXfdPtvYdHhhA2LYu4+5mh5ASlAMJXD7zIOZt2iEYEEBEIAAYFAk6XSO4ACgkQ
|
||||
xa93SlhRC1qmjwCg9U7U+XN7Gc/dhY/eymJqmzUGT/gAn0guvoX75Y+BsZlI6dWn
|
||||
qaFU6N8HiQIcBBABCAAGBQJOl0kLAAoJEExaa6sS0qeuBfEP/3AnLrcKx+dFKERX
|
||||
o4NBCGWr+i1CnowupKS3rm2xLbmiB969szG5TxnOIvnjECqPz6skK3HkV3jTZaju
|
||||
v3sR6M2ItpnrncWuiLnYcCSDp9TEMpCWzTEgtrBlKdVuTNTeRGILeIcvqoZX5w+u
|
||||
i0eBvvbeRbHEyUsvOEnYjrqoAjqUJj5FUZtR1+V9fnZp8zDgpOSxx0LomnFdKnhj
|
||||
uyXAQlRCA6/roVNR9ruRjxTR5ubteZ9ubTsVYr2/eMYOjQ46LhAgR+3Alblu/WHB
|
||||
MR/9F9//RuOa43R5Sjx9TiFCYol+Ozk8XRt3QGweEH51YkSYY3oRbHBb2Fkql6N6
|
||||
YFqlLBL7/aiWnNmRDEs/cdpo9HpFsbjOv4RlsSXQfvvfOayHpT5nO1UQFzoyMVpJ
|
||||
615zwmQDJT5Qy7uvr2eQYRV9AXt8t/H+xjQsRZCc5YVmeAo91qIzI/tA2gtXik49
|
||||
6yeziZbfUvcZzuzjjxFExss4DSAwMgorvBeIbiz2k2qXukbqcTjB2XqAlZasd6Ll
|
||||
nLXpQdqDV3McYkP/MvttWh3w+J/woiBcA7yEI5e3YJk97uS6+ssbqLEd0CcdT+qz
|
||||
+Waw0z/ZIU99Lfh2Qm77OT6vr//Zulw5ovjZVO2boRIcve7S97gQ4KC+G/+QaRS+
|
||||
VPZ67j5UMxqtT/Y4+NHcQGgwF/1iiQI9BBMBCAAnAhsDBQsJCAcDBRUKCQgLBRYC
|
||||
AwEAAh4BAheABQJQeSssBQkDwxbfAAoJEH/MfUaszEz4bgkP/0AI0UgDgkNNqplA
|
||||
IpE/pkwem2jgGpJGKurh2xDu6j2ZL+BPzPhzyCeMHZwTXkkI373TXGQQP8dIa+RD
|
||||
HAZ3iijw4+ISdKWpziEUJjUk04UMPTlN+dYJt2EHLQDD0VLtX0yQC/wLmVEH/REp
|
||||
oclbVjZR/+ehwX2IxOIlXmkZJDSycl975FnSUjMAvyzty8P9DN0fIrQ7Ju+BfMOM
|
||||
TnUkOdp0kRUYez7pxbURJfkM0NxAP1geACI91aISBpFg3zxQs1d3MmUIhJ4wHvYB
|
||||
uaR7Fx1FkLAxWddre/OCYJBsjucE9uqc04rgKVjN5P/VfqNxyUoB+YZ+8Lk4t03p
|
||||
RBcD9XzcyOYlFLWXbcWxTn1jJ2QMqRIWi5lzZIOMw5B+OK9LLPX0dAwIFGr9WtuV
|
||||
J2zp+D4CBEMtn4Byh8EaQsttHeqAkpZoMlrEeNBDz2L7RquPQNmiuom15nb7xU/k
|
||||
7PGfqtkpBaaGBV9tJkdp7BdH27dZXx+uT+uHbpMXkRrXliHjWpAw+NGwADh/Pjmq
|
||||
ExlQSdgAiXy1TTOdzxKH7WrwMFGDK0fddKr8GH3f+Oq4eOoNRa6/UhTCmBPbryCS
|
||||
IA7EAd0Aae9YaLlOB+eTORg/F1EWLPm34kKSRtae3gfHuY2cdUmoDVnOF8C9hc0P
|
||||
bL65G4NWPt+fW7lIj+0+kF19s2PviQI9BBMBCAAnAhsDBQsJCAcDBRUKCQgLBRYC
|
||||
AwEAAh4BAheABQJRKm2VBQkINsBBAAoJEH/MfUaszEz4RTEP/1sQHyjHaUiAPaCA
|
||||
v8jw/3SaWP/g8qLjpY6ROjLnDMvwKwRAoxUwcIv4/TWDOMpwJN+CJIbjXsXNYvf9
|
||||
OX+UTOvq4iwi4ADrAAw2xw+Jomc6EsYla+hkN2FzGzhpXfZFfUsuphjY3FKL+4hX
|
||||
H+R8ucNwIz3yrkfc17MMn8yFNWFzm4omU9/JeeaafwUoLxlULL2zY7H3+QmxCl0u
|
||||
6t8VvlszdEFhemLHzVYRY0Ro/ISrR78CnANNsMIy3i11U5uvdeWVCoWV1BXNLzOD
|
||||
4+BIDbMB/Do8PQCWiliSGZi8lvmj/sKbumMFQonMQWOfQswTtqTyQ3yhUM1LaxK5
|
||||
PYq13rggi3rA8oq8SYb/KNCQL5pzACji4TRVK0kNpvtxJxe84X8+9IB1vhBvF/Ji
|
||||
/xDd/3VDNPY+k1a47cON0S8Qc8DA3mq4hRfcgvuWy7ZxoMY7AfSJOhleb9+PzRBB
|
||||
n9agYgMxZg1RUWZazQ5KuoJqbxpwOYVFja/stItNS4xsmi0lh2I4MNlBEDqnFLUx
|
||||
SvTDc22c3uJlWhzBM/f2jH19uUeqm4jaggob3iJvJmK+Q7Ns3WcfhuWwCnc1+58d
|
||||
iFAMRUCRBPeFS0qd56QGk1r97B6+3UfLUslCfaaA8IMOFvQSHJwDO87xWGyxeRTY
|
||||
IIP9up4xwgje9LB7fMxsSkCDTHOk
|
||||
=s3DI
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
EOF
|
||||
|
||||
echo "Running apt-get update ..."
|
||||
apt-get update
|
||||
|
||||
cat <<EOF
|
||||
|
||||
You can now start installing packages from apt.postgresql.org.
|
||||
|
||||
Have a look at https://wiki.postgresql.org/wiki/Apt for more information;
|
||||
most notably the FAQ at https://wiki.postgresql.org/wiki/Apt/FAQ
|
||||
EOF
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/bin/sh
|
||||
# /etc/init.d/redash_supervisord
|
||||
### BEGIN INIT INFO
|
||||
# Provides: supervisord
|
||||
# Required-Start: $remote_fs $syslog
|
||||
# Required-Stop: $remote_fs $syslog
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: process supervisor
|
||||
### END INIT INFO
|
||||
|
||||
# Author: Ron DuPlain <ron.duplain@gmail.com>
|
||||
|
||||
# Do NOT "set -e"
|
||||
|
||||
# PATH should only include /usr/* if it runs after the mountnfs.sh script
|
||||
PATH=/sbin:/usr/sbin:/usr/local/sbin:/bin:/usr/bin:/usr/local/bin
|
||||
NAME=supervisord
|
||||
DESC="process supervisor"
|
||||
DAEMON=/usr/local/bin/$NAME
|
||||
DAEMON_ARGS="--configuration /opt/redash/supervisord/supervisord.conf "
|
||||
PIDFILE=/opt/redash/supervisord/supervisord.pid
|
||||
SCRIPTNAME=/etc/init.d/redash_supervisord
|
||||
USER=redash
|
||||
|
||||
# Exit if the package is not installed
|
||||
[ -x "$DAEMON" ] || exit 0
|
||||
|
||||
# Read configuration variable file if it is present
|
||||
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
|
||||
|
||||
# Load the VERBOSE setting and other rcS variables
|
||||
. /lib/init/vars.sh
|
||||
|
||||
# Define LSB log_* functions.
|
||||
# Depend on lsb-base (>= 3.2-14) to ensure that this file is present
|
||||
# and status_of_proc is working.
|
||||
. /lib/lsb/init-functions
|
||||
|
||||
#
|
||||
# Function that starts the daemon/service
|
||||
#
|
||||
do_start()
|
||||
{
|
||||
# Return
|
||||
# 0 if daemon has been started
|
||||
# 1 if daemon was already running
|
||||
# 2 if daemon could not be started
|
||||
start-stop-daemon --start --quiet --pidfile $PIDFILE --user $USER --chuid $USER --exec $DAEMON --test > /dev/null \
|
||||
|| return 1
|
||||
start-stop-daemon --start --quiet --pidfile $PIDFILE --user $USER --chuid $USER --exec $DAEMON -- \
|
||||
$DAEMON_ARGS \
|
||||
|| return 2
|
||||
# Add code here, if necessary, that waits for the process to be ready
|
||||
# to handle requests from services started subsequently which depend
|
||||
# on this one. As a last resort, sleep for some time.
|
||||
}
|
||||
|
||||
#
|
||||
# Function that stops the daemon/service
|
||||
#
|
||||
do_stop()
|
||||
{
|
||||
# Return
|
||||
# 0 if daemon has been stopped
|
||||
# 1 if daemon was already stopped
|
||||
# 2 if daemon could not be stopped
|
||||
# other if a failure occurred
|
||||
start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE --user $USER --chuid $USER --name $NAME
|
||||
RETVAL="$?"
|
||||
[ "$RETVAL" = 2 ] && return 2
|
||||
# Wait for children to finish too if this is a daemon that forks
|
||||
# and if the daemon is only ever run from this initscript.
|
||||
# If the above conditions are not satisfied then add some other code
|
||||
# that waits for the process to drop all resources that could be
|
||||
# needed by services started subsequently. A last resort is to
|
||||
# sleep for some time.
|
||||
start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --user $USER --chuid $USER --exec $DAEMON
|
||||
[ "$?" = 2 ] && return 2
|
||||
# Many daemons don't delete their pidfiles when they exit.
|
||||
rm -f $PIDFILE
|
||||
return "$RETVAL"
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
[ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME"
|
||||
do_start
|
||||
case "$?" in
|
||||
0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
|
||||
2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
|
||||
esac
|
||||
;;
|
||||
stop)
|
||||
[ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME"
|
||||
do_stop
|
||||
case "$?" in
|
||||
0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
|
||||
2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
|
||||
esac
|
||||
;;
|
||||
status)
|
||||
status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $?
|
||||
;;
|
||||
restart)
|
||||
log_daemon_msg "Restarting $DESC" "$NAME"
|
||||
do_stop
|
||||
case "$?" in
|
||||
0|1)
|
||||
do_start
|
||||
case "$?" in
|
||||
0) log_end_msg 0 ;;
|
||||
1) log_end_msg 1 ;; # Old process is still running
|
||||
*) log_end_msg 1 ;; # Failed to start
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
# Failed to stop
|
||||
log_end_msg 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $SCRIPTNAME {start|stop|status|restart}" >&2
|
||||
exit 3
|
||||
;;
|
||||
esac
|
||||
|
||||
:
|
||||