Compare commits

...

40 Commits

Author SHA1 Message Date
Arik Fraimovich
d43bfa9d10 Version bump to 4.0.1 2018-05-02 17:18:00 +03:00
Arik Fraimovich
b4d7a25c74 Update CHANGELOG.md 2018-05-02 17:13:46 +03:00
Arik Fraimovich
c8a3985f16 Merge pull request #2506 from kocsmy/fixes/overflowing-data-source-box
A nicer way of hiding overflowing text
2018-05-02 14:27:59 +03:00
Zsolt Kocsmarszky
3274a5d670 Add data source name to box title 2018-05-02 13:27:26 +02:00
Arik Fraimovich
8037c21a61 Merge pull request #2496 from akiray03/sorted-redshift-schema-info
Fixes the order of columns in the schema browser when Redshift is used as a DataSource.
2018-05-02 14:14:38 +03:00
Arik Fraimovich
ee8f6c1c71 Merge pull request #2472 from tonyjiangh/fix/disable_view_only_fork
Disable fork button for view_only user
2018-05-02 14:11:55 +03:00
Arik Fraimovich
bce0e8e547 Merge pull request #2507 from getredash/resolution
Log user's screen resolution
2018-05-02 14:09:52 +03:00
Arik Fraimovich
cddf398df4 Log user's screen resolution 2018-05-02 14:05:57 +03:00
Arik Fraimovich
bdb8682646 Merge pull request #2501 from getredash/fix-dashboard-filters
Improve dashboard refresh UX: show previous data while refreshing.
2018-05-02 14:02:59 +03:00
Arik Fraimovich
54bef2c05f Remove console.log call 2018-05-02 14:02:43 +03:00
Zsolt Kocsmarszky
d326f2b46c A nicer way of hiding overflowing text 2018-05-02 12:33:21 +02:00
Arik Fraimovich
06a7a7e65b Merge pull request #2505 from kocsmy/fixes/overflowing-data-source-box
Hide overflowing content
2018-05-02 13:26:31 +03:00
Zsolt Kocsmarszky
fc0a074a07 Hide overflowing content 2018-05-02 12:18:36 +02:00
Arik Fraimovich
44a330a4e6 Merge pull request #2499 from kocsmy/design/design-improvements
Design improvements
2018-05-02 10:36:02 +03:00
Zsolt Kocsmarszky
ab284efac1 Fix contianer width 2018-04-30 16:00:57 +02:00
Arik Fraimovich
9d4fd75ea8 Fix: update tests. 2018-04-30 11:16:27 +03:00
Arik Fraimovich
4a9b93a131 Improve dashboard refresh UX: show previous data while refreshing.
Also refactored the Widget (client side) model to accomodate the necessary changes.
2018-04-30 11:01:36 +03:00
Zsolt Kocsmarszky
31a48cfe2b Subtle background change 2018-04-29 14:59:27 +02:00
Zsolt Kocsmarszky
9df98b1c29 Fix login screen on smaller viewport 2018-04-29 14:59:11 +02:00
Akira Yumiyama
d1bc2fb649 Fixes the order of columns in the schema browser when Redshift is used as a DataSource. 2018-04-27 17:56:04 +09:00
Arik Fraimovich
df774b0304 Merge pull request #2492 from kravets-levko/bug/cohort-js-error
Cohort bug: JS error (value not wrapped with moment instance)
2018-04-26 15:04:44 +03:00
Levko Kravets
7ac0ba52dd Cohort bug: JS error (value not wrapped with moment instance) 2018-04-26 14:00:56 +03:00
Arik Fraimovich
b2decc895f Merge pull request #2487 from getredash/fix-dashboard-filters
Fix: dashboard filters setting wasn't persisting.
2018-04-25 11:46:45 +03:00
Arik Fraimovich
b12f3fb133 Merge pull request #2469 from PublicI/fix/null-number-format
Display nulls and empty values as blank in table numeric fields
2018-04-24 22:53:57 +03:00
Arik Fraimovich
fc30f141ec Merge pull request #2489 from dbravender/2488-alert-date-column
Fixes #2488
2018-04-24 22:33:42 +03:00
Dan Bravender
277eb35aea Fixes #2488 2018-04-24 13:50:44 -04:00
Arik Fraimovich
10bc9402b7 Fix: dashboard filters setting wasn't persisting. 2018-04-24 15:50:33 +03:00
Arik Fraimovich
16a07e19cc Merge pull request #2485 from kocsmy/fixes/new-readme-gif
Update readme gif + some smaller content improvements
2018-04-24 15:12:17 +03:00
Zsolt Kocsmarszky
479b34faed Make gif bigger 2018-04-24 14:04:31 +02:00
Zsolt Kocsmarszky
19eed1580e Refine content 2018-04-24 14:03:42 +02:00
Zsolt Kocsmarszky
d312dffe2c Update readme 2018-04-24 13:58:58 +02:00
Arik Fraimovich
90677b2b51 Merge pull request #2475 from ariarijp/fix-pip-version
To avoid "cannot import name main" error
2018-04-22 13:44:28 +03:00
ariarijp
df9bd38c08 To avoid "cannot import name main" error 2018-04-20 11:39:36 +09:00
Hao Jiang
be86d659ed Add $scope.canForkQuery() to data only view 2018-04-20 10:01:04 +09:00
Hao Jiang
66a4315ce2 Move dropdown disabled a tag color change code to disable class 2018-04-20 10:00:46 +09:00
Hao Jiang
b896dd461d Disable fork for view_only user 2018-04-19 10:44:52 +09:00
Chris Zubak-Skees
615aea7678 Merge remote-tracking branch 'upstream/master' into fix-null-number-format 2018-04-17 12:35:07 -04:00
Chris Zubak-Skees
10a9978b04 Fix style in PR branch 2018-04-17 12:34:20 -04:00
Arik Fraimovich
3f3a86eac3 Update CHANGELOG.md 2018-04-16 11:50:46 +03:00
Chris Zubak-Skees
f055e6750a Displays nulls and blanks in numeric fields as empty strings 2018-04-10 19:03:57 -04:00
27 changed files with 392 additions and 284 deletions

View File

@@ -1,6 +1,32 @@
# Change Log
## UNRELEASED
## v4.0.1 - 2018-05-02
### Added
- Log user's screen resolution. @arikfr
### Changed
- [Redshift] fix the order of columns in the schema browser. @akiray03
- Improve dashboard refresh UX: show previous data while refreshing. @arikfr
### Fixed
- Disable fork button to view_only users. @tonyjiangh
- Hide overflowing data source and alert destination names. @kocsmy
- Login pages were broken on mobile. @kocsmy
- Cohort visualization wasn't rendering if value wasn't properly detected as date. @kravets-levko
- Dashboard filters setting wasn't persisting. @arikfr
- Display nulls and empty values as blank in table numeric fields. @chriszs
- Date column on alerts page is labeled "Created By". @dbravender
- Bootstrap script was breaking due to incompatability with pip 10. @ariarijp
### Other
- Updated README. @kocsmy
## v4.0.0 - 2018-04-16
### Added

View File

@@ -19,19 +19,22 @@ Presto, Google Spreadsheets, Cloudera Impala, Hive and custom scripts.
**_Redash_** consists of two parts:
1. **Query Editor**: think of [JS Fiddle](http://jsfiddle.net) for SQL queries. It's your way to share data in the organization in an open way, by sharing both the dataset and the query that generated it. This way everyone can peer review not only the resulting dataset but also the process that generated it. Also it's possible to fork it and generate new datasets and reach new insights.
2. **Dashboards/Visualizations**: once you have a dataset, you can create different visualizations out of it, and then combine several visualizations into a single dashboard. Currently it supports charts, pivot table and cohorts.
2. **Visualizations and Dashboards**: once you have a dataset, you can create different visualizations out of it, and then combine several visualizations into a single dashboard. Currently Redash supports charts, pivot table, cohorts and [more](https://redash.io/help/user-guide/visualizations/visualization-types).
## Demo
<img src="https://cloud.githubusercontent.com/assets/71468/17391289/8e83878e-5a1d-11e6-8938-af9054a33b19.gif" width="60%"/>
<img src="https://raw.githubusercontent.com/getredash/website/8e820cd02c73a8ddf4f946a9d293c54fd3fb08b9/website/_assets/images/redash-anim.gif" width="80%"/>
You can try out the demo instance: http://demo.redash.io/ (login with any Google account).
Try out the [demo instance](http://demo.redash.io/) (login with any Google account).
## Getting Started
* [Setting up Redash instance](https://redash.io/help-onpremise/setup/setting-up-redash-instance.html) (includes links to ready made AWS/GCE images).
* [Documentation](https://redash.io/help/).
## Supported Data Sources
Redash supports more than 25 [data sources](https://redash.io/help/data-sources/supported-data-sources).
## Getting Help

View File

@@ -11,6 +11,7 @@
// The real magic ;)
> a {
pointer-events: none;
color: @dropdown-link-disabled-color;
}
}
@@ -29,8 +30,6 @@
&:not([class*="bg-"]) {
& > li > a {
color: #4C4C4C;
&:hover {
color: #000;
}

View File

@@ -14,7 +14,7 @@
// General
body {
padding-top: 0;
background: #cad1dc2b;
background: #F6F8F9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif !important;
&.headless {
@@ -178,6 +178,8 @@ body {
font-size: 13px;
color: #323232;
margin: 0 !important;
text-overflow: ellipsis;
overflow: hidden;
}
}

View File

@@ -49,6 +49,12 @@ hr {
width: 500px;
}
@media (max-width: 767px) {
.fixed-width-page {
width: 80vw;
}
}
.login-button {
display: flex;
align-items: center;

View File

@@ -94,11 +94,10 @@ const AddWidgetDialog = {
widget.options.position.col = position.col;
widget.options.position.row = position.row;
widget.$save()
.then((response) => {
// update dashboard layout
this.dashboard.version = response.version;
this.dashboard.widgets.push(new Widget(response.widget));
widget
.save()
.then(() => {
this.dashboard.widgets.push(widget);
this.close();
})
.catch(() => {

View File

@@ -10,7 +10,7 @@
</div>
<div class="dropdown pull-right widget-menu-regular" ng-if="!$ctrl.public" uib-dropdown>
<div class="actions">
<a data-toggle="dropdown" uib-dropdown-toggle><i class="zmdi zmdi-more"></i></a>
<a data-toggle="dropdown" uib-dropdown-toggle><i class="zmdi zmdi-more-vert"></i></a>
</div>
<ul class="dropdown-menu pull-right" uib-dropdown-menu style="z-index:1000000">
@@ -51,8 +51,10 @@
</div>
<div class="body-row clearfix tile__bottom-control">
<a class="small hidden-print" ng-click="$ctrl.reload(true)" ng-if="!$ctrl.public">
<i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()"></span>
<a class="small hidden-print" ng-click="$ctrl.refresh()" ng-if="!$ctrl.public">
<i ng-class='{"zmdi-hc-spin": $ctrl.widget.loading}' class="zmdi zmdi-refresh"></i>
<span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()" ng-if="!$ctrl.widget.loading"></span>
<rd-timer timestamp="$ctrl.widget.refreshStartedAt" ng-if="$ctrl.widget.loading"></rd-timer>
</a>
<span class="small hidden-print" ng-if="$ctrl.public">
<i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()"></span>
@@ -61,7 +63,7 @@
<i class="zmdi zmdi-time-restore"></i> {{$ctrl.widget.getQueryResult().getUpdatedAt() | dateTime}}
</span>
<button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.reload(true)" ng-if="!$ctrl.public"><i class="zmdi zmdi-refresh"></i></button>
<button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.refresh()" ng-if="!$ctrl.public"><i class="zmdi zmdi-refresh"></i></button>
</div>
</div>

View File

@@ -19,7 +19,7 @@ const EditTextBoxComponent = {
if (this.widget.new_text !== this.widget.existing_text) {
this.widget.text = this.widget.new_text;
this.widget
.$save()
.save()
.then(() => {
this.close();
})
@@ -67,9 +67,7 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
Events.record('delete', 'widget', this.widget.id);
this.widget.$delete((response) => {
this.dashboard.widgets = this.dashboard.widgets.filter(w => w.id !== undefined && w.id !== this.widget.id);
this.dashboard.version = response.version;
this.widget.delete().then(() => {
if (this.deleted) {
this.deleted({});
}
@@ -78,18 +76,21 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
Events.record('view', 'widget', this.widget.id);
this.reload = (force) => {
this.load = (refresh = false) => {
const maxAge = $location.search().maxAge;
this.widget.load(force, maxAge);
this.widget.load(refresh, maxAge);
};
this.refresh = () => {
this.load(true);
};
if (this.widget.visualization) {
Events.record('view', 'query', this.widget.visualization.query.id, { dashboard: true });
Events.record('view', 'visualization', this.widget.visualization.id, { dashboard: true });
this.reload(false);
this.type = 'visualization';
this.load();
} else if (this.widget.restricted) {
this.type = 'restricted';
} else {

View File

@@ -47,7 +47,7 @@ function createBooleanFormatter(values) {
function createNumberFormatter(format) {
if (_.isString(format) && (format !== '')) {
const n = numeral(0); // cache `numeral` instance
return value => n.set(value).format(format);
return value => (value === null || value === '' ? '' : n.set(value).format(format));
}
return value => value;
}

View File

@@ -14,7 +14,7 @@
<th class="sortable-column" ng-click="$ctrl.alerts.orderBy('name')">Name <sort-icon column="'name'" sort-column="$ctrl.alerts.orderByField" reverse="$ctrl.alerts.orderByReverse"></sort-icon></th>
<th class="sortable-column" ng-click="$ctrl.alerts.orderBy('created_by')">Created By <sort-icon column="'created_by'" sort-column="$ctrl.alerts.orderByField" reverse="$ctrl.alerts.orderByReverse"></sort-icon></th>
<th class="sortable-column" ng-click="$ctrl.alerts.orderBy('state')">State <sort-icon column="'state'" sort-column="$ctrl.alerts.orderByField" reverse="$ctrl.alerts.orderByReverse"></sort-icon></th>
<th class="sortable-column" ng-click="$ctrl.alerts.orderBy('created_at')">Created By <sort-icon column="'created_at'" sort-column="$ctrl.alerts.orderByField" reverse="$ctrl.alerts.orderByReverse"></sort-icon></th>
<th class="sortable-column" ng-click="$ctrl.alerts.orderBy('created_at')">Created At <sort-icon column="'created_at'" sort-column="$ctrl.alerts.orderByField" reverse="$ctrl.alerts.orderByReverse"></sort-icon></th>
</tr>
</thead>
<tbody>

View File

@@ -31,7 +31,7 @@
<div class="btn-group" uib-dropdown ng-if="!$ctrl.layoutEditing">
<button id="split-button" type="button"
ng-class="{'btn-default btn-sm': $ctrl.refreshRate === null,'btn-primary btn-sm':$ctrl.refreshRate !== null}"
class="btn btn-sm" ng-click="$ctrl.loadDashboard(true)">
class="btn btn-sm" ng-click="$ctrl.refreshDashboard()">
<i class="zmdi zmdi-refresh"></i> {{$ctrl.refreshRate === null ? 'Refresh' : $ctrl.refreshRate.name}}
</button>
<button type="button" class="btn" uib-dropdown-toggle
@@ -92,7 +92,7 @@
ng-repeat="widget in $ctrl.dashboard.widgets track by widget.id"
gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}">
<div class="grid-stack-item-content">
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" on-delete="$ctrl.removeWidget()"></dashboard-widget>
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" on-delete="$ctrl.removeWidget(widget.id)"></dashboard-widget>
</div>
</div>
</div>

View File

@@ -46,7 +46,7 @@ function DashboardCtrl(
this.saveInProgress = true;
const showMessages = true;
return $q
.all(_.map(widgets, widget => widget.$save()))
.all(_.map(widgets, widget => widget.save()))
.then(() => {
if (showMessages) {
toastr.success('Changes saved.');
@@ -83,7 +83,7 @@ function DashboardCtrl(
this.refreshRate = rate;
if (rate !== null) {
if (load) {
this.loadDashboard(true);
this.refreshDashboard();
}
this.autoRefresh();
}
@@ -118,7 +118,7 @@ function DashboardCtrl(
};
const collectFilters = (dashboard, forceRefresh) => {
const queryResultPromises = _.compact(this.dashboard.widgets.map(widget => widget.loadPromise(forceRefresh)));
const queryResultPromises = _.compact(this.dashboard.widgets.map(widget => widget.load(forceRefresh)));
$q.all(queryResultPromises).then((queryResults) => {
const filters = {};
@@ -206,9 +206,13 @@ function DashboardCtrl(
this.loadDashboard();
this.refreshDashboard = () => {
renderDashboard(this.dashboard, true);
};
this.autoRefresh = () => {
$timeout(() => {
this.loadDashboard(true);
this.refreshDashboard();
}, this.refreshRate.rate * 1000).then(() => this.autoRefresh());
};
@@ -282,8 +286,28 @@ function DashboardCtrl(
};
this.updateDashboardFiltersState = () => {
// / do something for humanity.
collectFilters(this.dashboard, false);
Dashboard.save(
{
slug: this.dashboard.id,
version: this.dashboard.version,
dashboard_filters_enabled: this.dashboard.dashboard_filters_enabled,
},
(dashboard) => {
this.dashboard = dashboard;
},
(error) => {
if (error.status === 403) {
toastr.error('Name update failed: Permission denied.');
} else if (error.status === 409) {
toastr.error(
'It seems like the dashboard has been modified by another user. ' +
'Please copy/backup your changes and reload this page.',
{ autoDismiss: false },
);
}
},
);
};
this.addWidget = () => {
@@ -299,12 +323,13 @@ function DashboardCtrl(
// Save position of newly added widget (but not entire layout)
const widget = _.last(this.dashboard.widgets);
if (_.isObject(widget)) {
return widget.$save();
return widget.save();
}
});
};
this.removeWidget = () => {
this.removeWidget = (widgetId) => {
this.dashboard.widgets = this.dashboard.widgets.filter(w => w.id !== undefined && w.id !== widgetId);
this.extractGlobalParameters();
if (!this.layoutEditing) {
// We need to wait a bit while `angular` updates widgets, and only then save new layout

View File

@@ -5,7 +5,7 @@
<a href="data_sources/new" class="btn btn-default"><i class="fa fa-plus"></i> New Data Source</a>
</p>
<div class="database-source">
<a class="visual-card" ng-href="data_sources/{{dataSource.id}}" ng-repeat="dataSource in dataSources">
<a class="visual-card" ng-href="data_sources/{{dataSource.id}}" ng-repeat="dataSource in dataSources" title="{{dataSource.name}}">
<img ng-src="/static/images/db-logos/{{dataSource.type}}.png" alt="{{dataSource.name}}">
<h3>{{dataSource.name}}</h3>
</a>

View File

@@ -58,7 +58,7 @@
<span class="zmdi zmdi-more"></span>
</button>
<ul class="dropdown-menu pull-right" uib-dropdown-menu>
<li><a ng-click="duplicateQuery()" ng-disabled="query.id === undefined || !canForkQuery()"> Fork</a></li>
<li ng-class="{'disabled': query.id === undefined || !canForkQuery() }"><a ng-click="duplicateQuery()"> Fork</a></li>
<li class="divider"></li>
<li ng-if="!query.is_archived && query.id != undefined && (isQueryOwner || currentUser.hasPermission('admin'))"><a ng-click="archiveQuery()">Archive</a></li>
<li ng-if="!query.is_archived && query.id != undefined && (isQueryOwner || currentUser.hasPermission('admin')) && showPermissionsControl"><a ng-click="showManagePermissionsModal()">Manage Permissions</a></li>

View File

@@ -145,6 +145,8 @@ function QueryViewCtrl(
$scope.canExecuteQuery = () => currentUser.hasPermission('execute_query') && !$scope.dataSource.view_only;
$scope.canForkQuery = () => currentUser.hasPermission('edit_query') && !$scope.dataSource.view_only;
$scope.canScheduleQuery = currentUser.hasPermission('schedule_query');
if ($route.current.locals.dataSources) {

View File

@@ -16,6 +16,7 @@ function Events($http) {
object_type: objectType,
object_id: objectId,
timestamp: Date.now() / 1000.0,
screen_resolution: `${window.screen.width}x${window.screen.height}`,
};
Object.assign(event, additionalProperties);
this.events.push(event);

View File

@@ -1,142 +1,166 @@
import moment from 'moment';
import { truncate } from 'underscore.string';
import { pick, flatten, extend, isObject } from 'underscore';
import { each, pick, extend, isObject } from 'underscore';
function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
function prepareForSave(data) {
return pick(data, 'options', 'text', 'id', 'width', 'dashboard_id', 'visualization_id');
function calculatePositionOptions(Visualization, dashboardGridOptions, widget) {
widget.width = 1; // Backward compatibility, user on back-end
const visualizationOptions = {
autoHeight: false,
sizeX: Math.round(dashboardGridOptions.columns / 2),
sizeY: dashboardGridOptions.defaultSizeY,
minSizeX: dashboardGridOptions.minSizeX,
maxSizeX: dashboardGridOptions.maxSizeX,
minSizeY: dashboardGridOptions.minSizeY,
maxSizeY: dashboardGridOptions.maxSizeY,
};
const visualization = widget.visualization ? Visualization.visualizations[widget.visualization.type] : null;
if (isObject(visualization)) {
const options = extend({}, visualization.defaultOptions);
if (Object.prototype.hasOwnProperty.call(options, 'autoHeight')) {
visualizationOptions.autoHeight = options.autoHeight;
}
// Width constraints
const minColumns = parseInt(options.minColumns, 10);
if (isFinite(minColumns) && minColumns >= 0) {
visualizationOptions.minSizeX = minColumns;
}
const maxColumns = parseInt(options.maxColumns, 10);
if (isFinite(maxColumns) && maxColumns >= 0) {
visualizationOptions.maxSizeX = Math.min(maxColumns, dashboardGridOptions.columns);
}
// Height constraints
// `minRows` is preferred, but it should be kept for backward compatibility
const height = parseInt(options.height, 10);
if (isFinite(height)) {
visualizationOptions.minSizeY = Math.ceil(height / dashboardGridOptions.rowHeight);
}
const minRows = parseInt(options.minRows, 10);
if (isFinite(minRows)) {
visualizationOptions.minSizeY = minRows;
}
const maxRows = parseInt(options.maxRows, 10);
if (isFinite(maxRows) && maxRows >= 0) {
visualizationOptions.maxSizeY = maxRows;
}
// Default dimensions
const defaultWidth = parseInt(options.defaultColumns, 10);
if (isFinite(defaultWidth) && defaultWidth > 0) {
visualizationOptions.sizeX = defaultWidth;
}
const defaultHeight = parseInt(options.defaultRows, 10);
if (isFinite(defaultHeight) && defaultHeight > 0) {
visualizationOptions.sizeY = defaultHeight;
}
}
const WidgetResource = $resource(
'api/widgets/:id',
{ id: '@id' },
{
get: { method: 'GET' },
save: {
method: 'POST',
transformRequest: flatten([prepareForSave, $http.defaults.transformRequest]),
},
query: { method: 'GET', isArray: true },
remove: { method: 'DELETE' },
delete: { method: 'DELETE' },
},
);
return visualizationOptions;
}
WidgetResource.prototype.getQuery = function getQuery() {
if (!this.query && this.visualization) {
this.query = new Query(this.visualization.query);
function WidgetFactory($http, Query, Visualization, dashboardGridOptions) {
class Widget {
constructor(data) {
// Copy properties
each(data, (v, k) => {
this[k] = v;
});
const visualizationOptions = calculatePositionOptions(Visualization, dashboardGridOptions, this);
this.options = this.options || {};
this.options.position = extend(
{},
visualizationOptions,
pick(this.options.position, ['col', 'row', 'sizeX', 'sizeY', 'autoHeight']),
);
if (this.options.position.sizeY < 0) {
this.options.position.autoHeight = true;
}
// Save original position (create a shallow copy)
this.$originalPosition = extend({}, this.options.position);
}
return this.query;
};
getQuery() {
if (!this.query && this.visualization) {
this.query = new Query(this.visualization.query);
}
WidgetResource.prototype.getQueryResult = function getQueryResult(force, maxAge) {
return this.load(force, maxAge);
};
WidgetResource.prototype.load = function load(force, maxAge) {
if (!this.visualization) {
return undefined;
return this.query;
}
if (force || this.queryResult === undefined) {
if (maxAge === undefined || force) {
maxAge = force ? 0 : undefined;
}
this.queryResult = this.getQuery().getQueryResult(maxAge);
getQueryResult() {
return this.data;
}
return this.queryResult;
};
WidgetResource.prototype.loadPromise = function loadPromise(force, maxAge) {
return this.load(force, maxAge).toPromise();
};
WidgetResource.prototype.getName = function getName() {
if (this.visualization) {
return `${this.visualization.query.name} (${this.visualization.name})`;
}
return truncate(this.text, 20);
};
function WidgetConstructor(widget) {
widget.width = 1; // Backward compatibility, user on back-end
const visualizationOptions = {
autoHeight: false,
sizeX: Math.round(dashboardGridOptions.columns / 2),
sizeY: dashboardGridOptions.defaultSizeY,
minSizeX: dashboardGridOptions.minSizeX,
maxSizeX: dashboardGridOptions.maxSizeX,
minSizeY: dashboardGridOptions.minSizeY,
maxSizeY: dashboardGridOptions.maxSizeY,
};
const visualization = widget.visualization ? Visualization.visualizations[widget.visualization.type] : null;
if (isObject(visualization)) {
const options = extend({}, visualization.defaultOptions);
if (Object.prototype.hasOwnProperty.call(options, 'autoHeight')) {
visualizationOptions.autoHeight = options.autoHeight;
}
// Width constraints
const minColumns = parseInt(options.minColumns, 10);
if (isFinite(minColumns) && minColumns >= 0) {
visualizationOptions.minSizeX = minColumns;
}
const maxColumns = parseInt(options.maxColumns, 10);
if (isFinite(maxColumns) && maxColumns >= 0) {
visualizationOptions.maxSizeX = Math.min(maxColumns, dashboardGridOptions.columns);
}
// Height constraints
// `minRows` is preferred, but it should be kept for backward compatibility
const height = parseInt(options.height, 10);
if (isFinite(height)) {
visualizationOptions.minSizeY = Math.ceil(height / dashboardGridOptions.rowHeight);
}
const minRows = parseInt(options.minRows, 10);
if (isFinite(minRows)) {
visualizationOptions.minSizeY = minRows;
}
const maxRows = parseInt(options.maxRows, 10);
if (isFinite(maxRows) && maxRows >= 0) {
visualizationOptions.maxSizeY = maxRows;
}
// Default dimensions
const defaultWidth = parseInt(options.defaultColumns, 10);
if (isFinite(defaultWidth) && defaultWidth > 0) {
visualizationOptions.sizeX = defaultWidth;
}
const defaultHeight = parseInt(options.defaultRows, 10);
if (isFinite(defaultHeight) && defaultHeight > 0) {
visualizationOptions.sizeY = defaultHeight;
getName() {
if (this.visualization) {
return `${this.visualization.query.name} (${this.visualization.name})`;
}
return truncate(this.text, 20);
}
widget.options = widget.options || {};
widget.options.position = extend(
{},
visualizationOptions,
pick(widget.options.position, ['col', 'row', 'sizeX', 'sizeY', 'autoHeight']),
);
load(force, maxAge) {
this.loading = true;
this.refreshStartedAt = moment();
if (widget.options.position.sizeY < 0) {
widget.options.position.autoHeight = true;
if (!this.visualization) {
return undefined;
}
if (force || this.queryResult === undefined) {
if (maxAge === undefined || force) {
maxAge = force ? 0 : undefined;
}
this.queryResult = this.getQuery().getQueryResult(maxAge);
this.queryResult.toPromise().then(
(queryResult) => {
this.data = queryResult;
this.loading = false;
},
() => {
this.loading = false;
this.data = null;
},
);
}
return this.queryResult.toPromise();
}
const result = new WidgetResource(widget);
save() {
const data = pick(this, 'options', 'text', 'id', 'width', 'dashboard_id', 'visualization_id');
// Save original position (create a shallow copy)
result.$originalPosition = extend({}, result.options.position);
let url = 'api/widgets';
if (this.id) {
url = `${url}/${this.id}`;
}
return result;
return $http.post(url, data).then((response) => {
each(response.data, (v, k) => {
this[k] = v;
});
return this;
});
}
delete() {
const url = `api/widgets/${this.id}`;
return $http.delete(url);
}
}
return WidgetConstructor;
return Widget;
}
export default function init(ngModule) {
ngModule.factory('Widget', Widget);
ngModule.factory('Widget', WidgetFactory);
}

View File

@@ -30,7 +30,7 @@ function groupData(sortedData) {
_.each(sortedData, (item) => {
const groupKey = item.date + 0;
result[groupKey] = result[groupKey] || {
date: item.date,
date: moment(item.date),
total: parseInt(item.total, 10),
values: {},
};

7
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "redash-client",
"version": "4.0.0-rc.1",
"version": "4.0.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -7991,6 +7991,11 @@
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.2.0.tgz",
"integrity": "sha512-Bold8phAE6WcRsuwhofrQ7cOK1REFHaYIkKuj7+TBYK3ONKRpGGIb5oXR5akYotFnrWN0TWKh6Svlhflm3dogg=="
},
"leaflet-fullscreen": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/leaflet-fullscreen/-/leaflet-fullscreen-1.0.2.tgz",
"integrity": "sha1-CcYcS6xF9jsu4Sav2H5c2XZQ/Bs="
},
"leaflet.markercluster": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "redash-client",
"version": "4.0.0",
"version": "4.0.1",
"description": "The frontend part of Redash.",
"main": "index.js",
"scripts": {

View File

@@ -19,7 +19,7 @@ from redash.query_runner import import_query_runners
from redash.destinations import import_destinations
__version__ = '4.0.0'
__version__ = '4.0.1'
def setup_logging():

View File

@@ -44,7 +44,7 @@ class WidgetListResource(BaseResource):
models.db.session.commit()
models.db.session.commit()
return {'widget': widget.to_dict()}
return widget.to_dict()
class WidgetResource(BaseResource):

View File

@@ -236,15 +236,24 @@ class Redshift(PostgreSQL):
# Use PG_GET_LATE_BINDING_VIEW_COLS to include schema for late binding views data for Redshift
# http://docs.aws.amazon.com/redshift/latest/dg/PG_GET_LATE_BINDING_VIEW_COLS.html
query = """
SELECT DISTINCT table_name, table_schema, column_name
FROM svv_columns
WHERE table_schema NOT IN ('pg_internal','pg_catalog','information_schema')
UNION ALL
SELECT DISTINCT view_name::varchar AS table_name,
view_schema::varchar AS table_schema,
col_name::varchar AS column_name
FROM pg_get_late_binding_view_cols()
cols(view_schema name, view_name name, col_name name, col_type varchar, col_num int);
WITH tables AS (
SELECT DISTINCT table_name,
table_schema,
column_name,
ordinal_position AS pos
FROM svv_columns
WHERE table_schema NOT IN ('pg_internal','pg_catalog','information_schema')
UNION ALL
SELECT DISTINCT view_name::varchar AS table_name,
view_schema::varchar AS table_schema,
col_name::varchar AS column_name,
col_num AS pos
FROM pg_get_late_binding_view_cols()
cols(view_schema name, view_name name, col_name name, col_type varchar, col_num int)
)
SELECT table_name, table_schema, column_name
FROM tables
ORDER BY table_name, pos
"""
self._get_definitions(schema, query)

View File

@@ -13,17 +13,17 @@
</head>
<body class="d-flex flex-column justify-content-center align-items-center">
<div class="d-flex flex-column justify-content-center align-items-center">
<div class="header">
<div class="text-center m-b-15">
<a href="/"><img src="/static/images/redash_icon_small.png"></a>
<div class="d-flex flex-column justify-content-center align-items-center">
<div class="header">
<div class="text-center m-b-15">
<a href="/"><img src="/static/images/redash_icon_small.png"></a>
</div>
{% if not hide_page_header %}
<h3 class="text-center m-t-0 m-b-25">{{ self.title() }}</h3>
{% endif %}
</div>
{% if not hide_page_header %}
<h3 class="text-center m-t-0 m-b-25">{{ self.title() }}</h3>
{% endif %}
{% block content %}{% endblock %}
</div>
{% block content %}{% endblock %}
</div>
<script src="/static/js/jquery.min.js"></script>

View File

@@ -71,7 +71,7 @@ extract_redash_sources() {
}
install_python_packages() {
pip install --upgrade pip
pip install --upgrade pip==9.0.3
# TODO: venv?
pip install setproctitle # setproctitle is used by Celery for "pretty" process titles
pip install -r $REDASH_BASE_PATH/current/requirements.txt

View File

@@ -54,7 +54,7 @@ class WidgetAPITest(BaseTestCase):
rv = self.make_request('post', '/api/widgets', data=data)
self.assertEquals(rv.status_code, 200)
self.assertEquals(rv.json['widget']['text'], 'Sample text.')
self.assertEquals(rv.json['text'], 'Sample text.')
def test_delete_widget(self):
widget = self.factory.create_widget()

View File

@@ -1,94 +1,87 @@
/* eslint-disable */
const fs = require('fs');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const fs = require("fs");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const WebpackBuildNotifierPlugin = require('webpack-build-notifier');
const ManifestPlugin = require('webpack-manifest-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const LessPluginAutoPrefix = require('less-plugin-autoprefix');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const path = require('path');
const WebpackBuildNotifierPlugin = require("webpack-build-notifier");
const ManifestPlugin = require("webpack-manifest-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const LessPluginAutoPrefix = require("less-plugin-autoprefix");
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
const path = require("path");
const redashBackend = process.env.REDASH_BACKEND || 'http://localhost:5000';
const redashBackend = process.env.REDASH_BACKEND || "http://localhost:5000";
const basePath = fs.realpathSync(path.join(__dirname, 'client'));
const appPath = fs.realpathSync(path.join(__dirname, 'client', 'app'));
const basePath = fs.realpathSync(path.join(__dirname, "client"));
const appPath = fs.realpathSync(path.join(__dirname, "client", "app"));
const config = {
entry: {
app: [
'./client/app/index.js',
'./client/app/assets/less/main.less',
],
server: [
'./client/app/assets/less/server.less',
],
app: ["./client/app/index.js", "./client/app/assets/less/main.less"],
server: ["./client/app/assets/less/server.less"]
},
output: {
path: path.join(basePath, './dist'),
filename: '[name].js',
publicPath: '/static/'
path: path.join(basePath, "./dist"),
filename: "[name].js",
publicPath: "/static/"
},
resolve: {
alias: {
'@': appPath,
"@": appPath,
// Currently `lodash` is used only by `gridstack.js`, but it can work
// with `underscore` as well, so set an alias to avoid bundling both `lodash` and
// `underscore`. When adding new libraries, check if they can work
// with `underscore`, otherwise remove this line
'lodash': 'underscore',
lodash: "underscore"
}
},
plugins: [
new WebpackBuildNotifierPlugin({title: 'Redash'}),
new WebpackBuildNotifierPlugin({ title: "Redash" }),
new webpack.DefinePlugin({
ON_TEST: process.env.NODE_ENV === 'test'
ON_TEST: process.env.NODE_ENV === "test"
}),
// Enforce angular to use jQuery instead of jqLite
new webpack.ProvidePlugin({'window.jQuery': 'jquery'}),
new webpack.ProvidePlugin({ "window.jQuery": "jquery" }),
// bundle only default `moment` locale (`en`)
new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/),
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module, count) {
name: "vendor",
minChunks: function(module, count) {
// any required modules inside node_modules are extracted to vendor
return (
module.resource &&
/\.js$/.test(module.resource) &&
module.resource.indexOf(
path.join(__dirname, './node_modules')
) === 0
)
module.resource.indexOf(path.join(__dirname, "./node_modules")) === 0
);
}
}),
// extract webpack runtime and module manifest to its own file in order to
// prevent vendor hash from being updated whenever app bundle is updated
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
chunks: ['vendor']
name: "manifest",
chunks: ["vendor"]
}),
new HtmlWebpackPlugin({
template: './client/app/index.html',
filename: 'index.html',
excludeChunks: ['server'],
template: "./client/app/index.html",
filename: "index.html",
excludeChunks: ["server"]
}),
new HtmlWebpackPlugin({
template: './client/app/multi_org.html',
filename: 'multi_org.html',
excludeChunks: ['server'],
template: "./client/app/multi_org.html",
filename: "multi_org.html",
excludeChunks: ["server"]
}),
new ExtractTextPlugin({
filename: '[name].[chunkhash].css',
filename: "[name].[chunkhash].css"
}),
new ManifestPlugin({
fileName: 'asset-manifest.json'
fileName: "asset-manifest.json"
}),
new CopyWebpackPlugin([
{ from: 'client/app/assets/robots.txt' },
{ from: 'client/app/assets/css/login.css', to: 'styles/login.css' },
{ from: 'node_modules/jquery/dist/jquery.min.js', to: 'js/jquery.min.js' },
{ from: "client/app/assets/robots.txt" },
{ from: "client/app/assets/css/login.css", to: "styles/login.css" },
{ from: "node_modules/jquery/dist/jquery.min.js", to: "js/jquery.min.js" }
])
],
@@ -97,113 +90,122 @@ const config = {
{
test: /\.js$/,
exclude: /node_modules/,
use: ['babel-loader', 'eslint-loader']
use: ["babel-loader", "eslint-loader"]
},
{
test: /\.html$/,
exclude: [/node_modules/, /index\.html/],
use: [{
loader: 'raw-loader'
}]
use: [
{
loader: "raw-loader"
}
]
},
{
test: /\.css$/,
use: ExtractTextPlugin.extract([{
loader: 'css-loader',
options: {
minimize: process.env.NODE_ENV === 'production'
use: ExtractTextPlugin.extract([
{
loader: "css-loader",
options: {
minimize: process.env.NODE_ENV === "production"
}
}
}])
])
},
{
test: /\.less$/,
use: ExtractTextPlugin.extract([
{
loader: 'css-loader',
loader: "css-loader",
options: {
minimize: process.env.NODE_ENV === 'production'
minimize: process.env.NODE_ENV === "production"
}
}, {
loader: 'less-loader',
},
{
loader: "less-loader",
options: {
plugins: [
new LessPluginAutoPrefix({browsers: ['last 3 versions']})
]
plugins: [new LessPluginAutoPrefix({ browsers: ["last 3 versions"] })]
}
}
])
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use: [{
loader: 'file-loader',
options: {
context: path.resolve(appPath, './assets/images/'),
outputPath: 'images/',
name: '[path][name].[ext]',
use: [
{
loader: "file-loader",
options: {
context: path.resolve(appPath, "./assets/images/"),
outputPath: "images/",
name: "[path][name].[ext]"
}
}
}]
]
},
{
test: /\.geo\.json$/,
use: [{
loader: 'file-loader',
options: {
outputPath: 'data/',
name: '[hash:7].[name].[ext]',
use: [
{
loader: "file-loader",
options: {
outputPath: "data/",
name: "[hash:7].[name].[ext]"
}
}
}]
]
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: [{
loader: 'url-loader',
options: {
limit: 10000,
name: 'fonts/[name].[hash:7].[ext]'
use: [
{
loader: "url-loader",
options: {
limit: 10000,
name: "fonts/[name].[hash:7].[ext]"
}
}
}]
]
}
]
},
devtool: 'cheap-eval-module-source-map',
devtool: "cheap-eval-module-source-map",
stats: {
modules: false,
chunkModules: false,
chunkModules: false
},
watchOptions: {
ignored: /\.sw.$/,
ignored: /\.sw.$/
},
devServer: {
inline: true,
index: '/static/index.html',
index: "/static/index.html",
historyApiFallback: {
index: '/static/index.html',
rewrites: [{from: /./, to: '/static/index.html'}],
index: "/static/index.html",
rewrites: [{ from: /./, to: "/static/index.html" }]
},
contentBase: false,
publicPath: '/static/',
publicPath: "/static/",
proxy: [
{
context: ['/login', '/logout', '/invite', '/setup', '/status.json', '/api', '/oauth'],
target: redashBackend + '/',
changeOrigin: true,
secure: false,
context: ["/login", "/logout", "/invite", "/setup", "/status.json", "/api", "/oauth"],
target: redashBackend + "/",
changeOrigin: false,
secure: false
},
{
context: (path) => {
context: path => {
// CSS/JS for server-rendered pages should be served from backend
return /^\/static\/[a-z]+\.[0-9a-fA-F]+\.(css|js)$/.test(path);
},
target: redashBackend + '/',
target: redashBackend + "/",
changeOrigin: true,
secure: false,
secure: false
}
],
stats: {
modules: false,
chunkModules: false,
},
chunkModules: false
}
}
};
@@ -211,15 +213,17 @@ if (process.env.DEV_SERVER_HOST) {
config.devServer.host = process.env.DEV_SERVER_HOST;
}
if (process.env.NODE_ENV === 'production') {
config.output.filename = '[name].[chunkhash].js';
config.plugins.push(new webpack.optimize.UglifyJsPlugin({
sourceMap: true,
compress: {
warnings: true
}
}));
config.devtool = 'source-map';
if (process.env.NODE_ENV === "production") {
config.output.filename = "[name].[chunkhash].js";
config.plugins.push(
new webpack.optimize.UglifyJsPlugin({
sourceMap: true,
compress: {
warnings: true
}
})
);
config.devtool = "source-map";
}
if (process.env.BUNDLE_ANALYZER) {