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 # 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 ### Added

View File

@@ -19,19 +19,22 @@ Presto, Google Spreadsheets, Cloudera Impala, Hive and custom scripts.
**_Redash_** consists of two parts: **_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. 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 ## 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 ## 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). * [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/). * [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 ## Getting Help

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
</div> </div>
<div class="dropdown pull-right widget-menu-regular" ng-if="!$ctrl.public" uib-dropdown> <div class="dropdown pull-right widget-menu-regular" ng-if="!$ctrl.public" uib-dropdown>
<div class="actions"> <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> </div>
<ul class="dropdown-menu pull-right" uib-dropdown-menu style="z-index:1000000"> <ul class="dropdown-menu pull-right" uib-dropdown-menu style="z-index:1000000">
@@ -51,8 +51,10 @@
</div> </div>
<div class="body-row clearfix tile__bottom-control"> <div class="body-row clearfix tile__bottom-control">
<a class="small hidden-print" ng-click="$ctrl.reload(true)" ng-if="!$ctrl.public"> <a class="small hidden-print" ng-click="$ctrl.refresh()" ng-if="!$ctrl.public">
<i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()"></span> <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> </a>
<span class="small hidden-print" ng-if="$ctrl.public"> <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> <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}} <i class="zmdi zmdi-time-restore"></i> {{$ctrl.widget.getQueryResult().getUpdatedAt() | dateTime}}
</span> </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>
</div> </div>

View File

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

View File

@@ -47,7 +47,7 @@ function createBooleanFormatter(values) {
function createNumberFormatter(format) { function createNumberFormatter(format) {
if (_.isString(format) && (format !== '')) { if (_.isString(format) && (format !== '')) {
const n = numeral(0); // cache `numeral` instance 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; 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('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('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('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> </tr>
</thead> </thead>
<tbody> <tbody>

View File

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

View File

@@ -46,7 +46,7 @@ function DashboardCtrl(
this.saveInProgress = true; this.saveInProgress = true;
const showMessages = true; const showMessages = true;
return $q return $q
.all(_.map(widgets, widget => widget.$save())) .all(_.map(widgets, widget => widget.save()))
.then(() => { .then(() => {
if (showMessages) { if (showMessages) {
toastr.success('Changes saved.'); toastr.success('Changes saved.');
@@ -83,7 +83,7 @@ function DashboardCtrl(
this.refreshRate = rate; this.refreshRate = rate;
if (rate !== null) { if (rate !== null) {
if (load) { if (load) {
this.loadDashboard(true); this.refreshDashboard();
} }
this.autoRefresh(); this.autoRefresh();
} }
@@ -118,7 +118,7 @@ function DashboardCtrl(
}; };
const collectFilters = (dashboard, forceRefresh) => { 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) => { $q.all(queryResultPromises).then((queryResults) => {
const filters = {}; const filters = {};
@@ -206,9 +206,13 @@ function DashboardCtrl(
this.loadDashboard(); this.loadDashboard();
this.refreshDashboard = () => {
renderDashboard(this.dashboard, true);
};
this.autoRefresh = () => { this.autoRefresh = () => {
$timeout(() => { $timeout(() => {
this.loadDashboard(true); this.refreshDashboard();
}, this.refreshRate.rate * 1000).then(() => this.autoRefresh()); }, this.refreshRate.rate * 1000).then(() => this.autoRefresh());
}; };
@@ -282,8 +286,28 @@ function DashboardCtrl(
}; };
this.updateDashboardFiltersState = () => { this.updateDashboardFiltersState = () => {
// / do something for humanity.
collectFilters(this.dashboard, false); 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 = () => { this.addWidget = () => {
@@ -299,12 +323,13 @@ function DashboardCtrl(
// Save position of newly added widget (but not entire layout) // Save position of newly added widget (but not entire layout)
const widget = _.last(this.dashboard.widgets); const widget = _.last(this.dashboard.widgets);
if (_.isObject(widget)) { 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(); this.extractGlobalParameters();
if (!this.layoutEditing) { if (!this.layoutEditing) {
// We need to wait a bit while `angular` updates widgets, and only then save new layout // 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> <a href="data_sources/new" class="btn btn-default"><i class="fa fa-plus"></i> New Data Source</a>
</p> </p>
<div class="database-source"> <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}}"> <img ng-src="/static/images/db-logos/{{dataSource.type}}.png" alt="{{dataSource.name}}">
<h3>{{dataSource.name}}</h3> <h3>{{dataSource.name}}</h3>
</a> </a>

View File

@@ -58,7 +58,7 @@
<span class="zmdi zmdi-more"></span> <span class="zmdi zmdi-more"></span>
</button> </button>
<ul class="dropdown-menu pull-right" uib-dropdown-menu> <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 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'))"><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> <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.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'); $scope.canScheduleQuery = currentUser.hasPermission('schedule_query');
if ($route.current.locals.dataSources) { if ($route.current.locals.dataSources) {

View File

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

View File

@@ -1,65 +1,8 @@
import moment from 'moment';
import { truncate } from 'underscore.string'; 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 calculatePositionOptions(Visualization, dashboardGridOptions, widget) {
function prepareForSave(data) {
return pick(data, 'options', 'text', 'id', 'width', 'dashboard_id', 'visualization_id');
}
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' },
},
);
WidgetResource.prototype.getQuery = function getQuery() {
if (!this.query && this.visualization) {
this.query = new Query(this.visualization.query);
}
return this.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;
}
if (force || this.queryResult === undefined) {
if (maxAge === undefined || force) {
maxAge = force ? 0 : undefined;
}
this.queryResult = this.getQuery().getQueryResult(maxAge);
}
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 widget.width = 1; // Backward compatibility, user on back-end
const visualizationOptions = { const visualizationOptions = {
@@ -71,6 +14,7 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
minSizeY: dashboardGridOptions.minSizeY, minSizeY: dashboardGridOptions.minSizeY,
maxSizeY: dashboardGridOptions.maxSizeY, maxSizeY: dashboardGridOptions.maxSizeY,
}; };
const visualization = widget.visualization ? Visualization.visualizations[widget.visualization.type] : null; const visualization = widget.visualization ? Visualization.visualizations[widget.visualization.type] : null;
if (isObject(visualization)) { if (isObject(visualization)) {
const options = extend({}, visualization.defaultOptions); const options = extend({}, visualization.defaultOptions);
@@ -115,28 +59,108 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
} }
} }
widget.options = widget.options || {}; return visualizationOptions;
widget.options.position = extend( }
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, visualizationOptions,
pick(widget.options.position, ['col', 'row', 'sizeX', 'sizeY', 'autoHeight']), pick(this.options.position, ['col', 'row', 'sizeX', 'sizeY', 'autoHeight']),
); );
if (widget.options.position.sizeY < 0) { if (this.options.position.sizeY < 0) {
widget.options.position.autoHeight = true; this.options.position.autoHeight = true;
} }
const result = new WidgetResource(widget);
// Save original position (create a shallow copy) // Save original position (create a shallow copy)
result.$originalPosition = extend({}, result.options.position); this.$originalPosition = extend({}, this.options.position);
return result;
} }
return WidgetConstructor; getQuery() {
if (!this.query && this.visualization) {
this.query = new Query(this.visualization.query);
}
return this.query;
}
getQueryResult() {
return this.data;
}
getName() {
if (this.visualization) {
return `${this.visualization.query.name} (${this.visualization.name})`;
}
return truncate(this.text, 20);
}
load(force, maxAge) {
this.loading = true;
this.refreshStartedAt = moment();
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();
}
save() {
const data = pick(this, 'options', 'text', 'id', 'width', 'dashboard_id', 'visualization_id');
let url = 'api/widgets';
if (this.id) {
url = `${url}/${this.id}`;
}
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 Widget;
} }
export default function init(ngModule) { 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) => { _.each(sortedData, (item) => {
const groupKey = item.date + 0; const groupKey = item.date + 0;
result[groupKey] = result[groupKey] || { result[groupKey] = result[groupKey] || {
date: item.date, date: moment(item.date),
total: parseInt(item.total, 10), total: parseInt(item.total, 10),
values: {}, values: {},
}; };

7
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "redash-client", "name": "redash-client",
"version": "4.0.0-rc.1", "version": "4.0.1",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -7991,6 +7991,11 @@
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.2.0.tgz", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.2.0.tgz",
"integrity": "sha512-Bold8phAE6WcRsuwhofrQ7cOK1REFHaYIkKuj7+TBYK3ONKRpGGIb5oXR5akYotFnrWN0TWKh6Svlhflm3dogg==" "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": { "leaflet.markercluster": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.1.0.tgz", "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.1.0.tgz",

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ class WidgetListResource(BaseResource):
models.db.session.commit() models.db.session.commit()
models.db.session.commit() models.db.session.commit()
return {'widget': widget.to_dict()} return widget.to_dict()
class WidgetResource(BaseResource): 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 # 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 # http://docs.aws.amazon.com/redshift/latest/dg/PG_GET_LATE_BINDING_VIEW_COLS.html
query = """ query = """
SELECT DISTINCT table_name, table_schema, column_name WITH tables AS (
SELECT DISTINCT table_name,
table_schema,
column_name,
ordinal_position AS pos
FROM svv_columns FROM svv_columns
WHERE table_schema NOT IN ('pg_internal','pg_catalog','information_schema') WHERE table_schema NOT IN ('pg_internal','pg_catalog','information_schema')
UNION ALL UNION ALL
SELECT DISTINCT view_name::varchar AS table_name, SELECT DISTINCT view_name::varchar AS table_name,
view_schema::varchar AS table_schema, view_schema::varchar AS table_schema,
col_name::varchar AS column_name col_name::varchar AS column_name,
col_num AS pos
FROM pg_get_late_binding_view_cols() FROM pg_get_late_binding_view_cols()
cols(view_schema name, view_name name, col_name name, col_type varchar, col_num int); 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) self._get_definitions(schema, query)

View File

@@ -13,7 +13,7 @@
</head> </head>
<body class="d-flex flex-column justify-content-center align-items-center"> <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="d-flex flex-column justify-content-center align-items-center">
<div class="header"> <div class="header">
<div class="text-center m-b-15"> <div class="text-center m-b-15">
<a href="/"><img src="/static/images/redash_icon_small.png"></a> <a href="/"><img src="/static/images/redash_icon_small.png"></a>
@@ -23,7 +23,7 @@
{% endif %} {% endif %}
</div> </div>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<script src="/static/js/jquery.min.js"></script> <script src="/static/js/jquery.min.js"></script>

View File

@@ -71,7 +71,7 @@ extract_redash_sources() {
} }
install_python_packages() { install_python_packages() {
pip install --upgrade pip pip install --upgrade pip==9.0.3
# TODO: venv? # TODO: venv?
pip install setproctitle # setproctitle is used by Celery for "pretty" process titles pip install setproctitle # setproctitle is used by Celery for "pretty" process titles
pip install -r $REDASH_BASE_PATH/current/requirements.txt 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) rv = self.make_request('post', '/api/widgets', data=data)
self.assertEquals(rv.status_code, 200) 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): def test_delete_widget(self):
widget = self.factory.create_widget() widget = self.factory.create_widget()

View File

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