mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Compare commits
29 Commits
ts-migrate
...
user-and-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dce31dd32 | ||
|
|
46e97a08cc | ||
|
|
640fea5e47 | ||
|
|
c865293aaa | ||
|
|
3d3f6b1916 | ||
|
|
0e1587a068 | ||
|
|
04edf16ed4 | ||
|
|
49536de1ed | ||
|
|
2f1394a6f4 | ||
|
|
911f398006 | ||
|
|
b0b1d6c81c | ||
|
|
23a279f318 | ||
|
|
e71ccf5de5 | ||
|
|
bb42e92cd0 | ||
|
|
4ec96caac5 | ||
|
|
829247c2d2 | ||
|
|
7d33af4343 | ||
|
|
84c2abed59 | ||
|
|
8b068dfd0b | ||
|
|
06eb868120 | ||
|
|
52ae7bedb2 | ||
|
|
fbe57de53c | ||
|
|
db0cb98ed3 | ||
|
|
dcdff66e62 | ||
|
|
d0793c4ba8 | ||
|
|
7b8bcdf356 | ||
|
|
c290864ccd | ||
|
|
b70e95a323 | ||
|
|
18ee5343aa |
@@ -79,6 +79,9 @@ WORKDIR /app
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV PIP_NO_CACHE_DIR=1
|
||||
|
||||
# Use legacy resolver to work around broken build due to resolver changes in pip
|
||||
ENV PIP_USE_DEPRECATED=legacy-resolver
|
||||
|
||||
# We first copy only the requirements file, to avoid rebuilding on every file
|
||||
# change.
|
||||
COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./
|
||||
|
||||
@@ -20,6 +20,7 @@ module.exports = {
|
||||
// allow debugger during development
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
||||
"jsx-a11y/anchor-is-valid": "off",
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -33,7 +33,13 @@ function useNavbarActiveState() {
|
||||
return useMemo(
|
||||
() => ({
|
||||
dashboards: includes(
|
||||
["Dashboards.List", "Dashboards.Favorites", "Dashboards.ViewOrEdit", "Dashboards.LegacyViewOrEdit"],
|
||||
[
|
||||
"Dashboards.List",
|
||||
"Dashboards.Favorites",
|
||||
"Dashboards.My",
|
||||
"Dashboards.ViewOrEdit",
|
||||
"Dashboards.LegacyViewOrEdit",
|
||||
],
|
||||
currentRoute.id
|
||||
),
|
||||
queries: includes(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
// @ts-expect-error (Must be removed after adding @redash/viz typing)
|
||||
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||
import { Auth } from "@/services/auth";
|
||||
import { policy } from "@/services/policy";
|
||||
@@ -62,9 +61,10 @@ export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserS
|
||||
return (
|
||||
<ApplicationLayout>
|
||||
<React.Fragment key={currentRoute.key}>
|
||||
{/* @ts-expect-error FIXME */}
|
||||
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
|
||||
<ErrorBoundaryContext.Consumer>
|
||||
{({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] }) =>
|
||||
{({ handleError } /* : { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] } FIXME bring back type */) =>
|
||||
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
|
||||
}
|
||||
</ErrorBoundaryContext.Consumer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '~antd/lib/button/style/index';
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
.code-block {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
|
||||
@@ -45,7 +45,7 @@ export const TYPES = mapValues(
|
||||
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
|
||||
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
|
||||
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"],
|
||||
QUERIES: ["/help/user-guide/querying", "Guide: Queries"],
|
||||
QUERIES: ["/user-guide/querying", "Guide: Queries"],
|
||||
ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
|
||||
},
|
||||
([url, title]) => [DOMAIN + HELP_PATH + url, title]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~antd/lib/drawer/style/drawer";
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
@help-doc-bg: #f7f7f7; // according to https://github.com/getredash/website/blob/13daff2d8b570956565f482236f6245042e8477f/src/scss/_components/_variables.scss#L15
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '~antd/lib/modal/style/index'; // for ant @vars
|
||||
@import (reference, less) "~@/assets/less/ant"; // for ant @vars
|
||||
|
||||
.parameters-mapping-list {
|
||||
.keyword {
|
||||
@@ -63,7 +63,8 @@
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
&.disabled, .fa {
|
||||
&.disabled,
|
||||
.fa {
|
||||
color: #a4a4a4;
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,6 @@ class ParameterValueInput extends React.Component {
|
||||
<SelectWithVirtualScroll
|
||||
className={this.props.className}
|
||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||
optionFilterProp="children"
|
||||
value={normalize(value)}
|
||||
onChange={this.onSelect}
|
||||
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
|
||||
@@ -120,7 +119,6 @@ class ParameterValueInput extends React.Component {
|
||||
<QueryBasedParameterInput
|
||||
className={this.props.className}
|
||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||
optionFilterProp="children"
|
||||
parameter={parameter}
|
||||
value={value}
|
||||
queryId={queryId}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~antd/lib/input-number/style/index"; // for ant @vars
|
||||
@import (reference, less) "~@/assets/less/ant"; // for ant @vars
|
||||
|
||||
@input-dirty: #fffce1;
|
||||
|
||||
|
||||
@@ -23,19 +23,23 @@ export default class Parameters extends React.Component {
|
||||
static propTypes = {
|
||||
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
|
||||
editable: PropTypes.bool,
|
||||
sortable: PropTypes.bool,
|
||||
disableUrlUpdate: PropTypes.bool,
|
||||
onValuesChange: PropTypes.func,
|
||||
onPendingValuesChange: PropTypes.func,
|
||||
onParametersEdit: PropTypes.func,
|
||||
appendSortableToParent: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
parameters: [],
|
||||
editable: false,
|
||||
sortable: false,
|
||||
disableUrlUpdate: false,
|
||||
onValuesChange: () => {},
|
||||
onPendingValuesChange: () => {},
|
||||
onParametersEdit: () => {},
|
||||
appendSortableToParent: true,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
@@ -85,7 +89,7 @@ export default class Parameters extends React.Component {
|
||||
if (oldIndex !== newIndex) {
|
||||
this.setState(({ parameters }) => {
|
||||
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
|
||||
onParametersEdit();
|
||||
onParametersEdit(parameters);
|
||||
return { parameters };
|
||||
});
|
||||
}
|
||||
@@ -110,7 +114,7 @@ export default class Parameters extends React.Component {
|
||||
this.setState(({ parameters }) => {
|
||||
const updatedParameter = extend(parameter, updated);
|
||||
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
||||
onParametersEdit();
|
||||
onParametersEdit(parameters);
|
||||
return { parameters };
|
||||
});
|
||||
});
|
||||
@@ -146,15 +150,17 @@ export default class Parameters extends React.Component {
|
||||
|
||||
render() {
|
||||
const { parameters } = this.state;
|
||||
const { editable } = this.props;
|
||||
const { sortable, appendSortableToParent } = this.props;
|
||||
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
|
||||
|
||||
return (
|
||||
<SortableContainer
|
||||
disabled={!editable}
|
||||
disabled={!sortable}
|
||||
axis="xy"
|
||||
useDragHandle
|
||||
lockToContainerEdges
|
||||
helperClass="parameter-dragged"
|
||||
helperContainer={containerEl => (appendSortableToParent ? containerEl : document.body)}
|
||||
updateBeforeSortStart={this.onBeforeSortStart}
|
||||
onSortEnd={this.moveParameter}
|
||||
containerProps={{
|
||||
@@ -163,8 +169,11 @@ export default class Parameters extends React.Component {
|
||||
}}>
|
||||
{parameters.map((param, index) => (
|
||||
<SortableElement key={param.name} index={index}>
|
||||
<div className="parameter-block" data-editable={editable || null}>
|
||||
{editable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
||||
<div
|
||||
className="parameter-block"
|
||||
data-editable={sortable || null}
|
||||
data-test={`ParameterBlock-${param.name}`}>
|
||||
{sortable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
||||
{this.renderParameter(param, index)}
|
||||
</div>
|
||||
</SortableElement>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "../assets/less/ant";
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
.parameter-block {
|
||||
display: inline-block;
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
&.parameter-dragged {
|
||||
z-index: 2;
|
||||
margin: 4px 0 0 4px;
|
||||
padding: 3px 6px 6px;
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ export default class QueryBasedParameterInput extends React.Component {
|
||||
value={this.state.value}
|
||||
onChange={onSelect}
|
||||
options={map(options, ({ value, name }) => ({ label: String(name), value }))}
|
||||
optionFilterProp="children"
|
||||
showSearch
|
||||
showArrow
|
||||
notFoundContent={isEmpty(options) ? "No options available" : null}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface VirtualScrollLabeledValue extends LabeledValue {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface VirtualScrollSelectProps extends SelectProps<string> {
|
||||
interface VirtualScrollSelectProps extends Omit<SelectProps<string>, "optionFilterProp" | "children"> {
|
||||
options: Array<VirtualScrollLabeledValue>;
|
||||
}
|
||||
function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element {
|
||||
@@ -32,7 +32,14 @@ function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps
|
||||
return false;
|
||||
}, [options]);
|
||||
|
||||
return <AntdSelect<string> dropdownMatchSelectWidth={dropdownMatchSelectWidth} options={options} {...props} />;
|
||||
return (
|
||||
<AntdSelect<string>
|
||||
dropdownMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
options={options}
|
||||
optionFilterProp="label" // as this component expects "options" prop
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectWithVirtualScroll;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~@/assets/less/ant";
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
.tags-list {
|
||||
.tags-list-title {
|
||||
|
||||
@@ -35,11 +35,11 @@ CounterCard.defaultProps = {
|
||||
|
||||
const queryJobsColumns = [
|
||||
{ title: "Queue", dataIndex: "origin" },
|
||||
{ title: "Query ID", dataIndex: "meta.query_id" },
|
||||
{ title: "Org ID", dataIndex: "meta.org_id" },
|
||||
{ title: "Data Source ID", dataIndex: "meta.data_source_id" },
|
||||
{ title: "User ID", dataIndex: "meta.user_id" },
|
||||
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: "meta.scheduled" }),
|
||||
{ title: "Query ID", dataIndex: ["meta", "query_id"] },
|
||||
{ title: "Org ID", dataIndex: ["meta", "org_id"] },
|
||||
{ title: "Data Source ID", dataIndex: ["meta", "data_source_id"] },
|
||||
{ title: "User ID", dataIndex: ["meta", "user_id"] },
|
||||
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: ["meta", "scheduled"] }),
|
||||
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
|
||||
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }),
|
||||
];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
@import '../../assets/less/inc/variables';
|
||||
@import (reference, less) "~@/assets/less/inc/variables";
|
||||
|
||||
.visual-card-list {
|
||||
width: 100%;
|
||||
@@ -7,7 +6,7 @@
|
||||
}
|
||||
|
||||
.visual-card {
|
||||
background: #FFFFFF;
|
||||
background: #ffffff;
|
||||
border: 1px solid fade(@redash-gray, 15%);
|
||||
border-radius: 3px;
|
||||
margin: 5px;
|
||||
@@ -74,4 +73,4 @@
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ const DashboardWidget = React.memo(
|
||||
onRefreshWidget,
|
||||
onRemoveWidget,
|
||||
onParameterMappingsChange,
|
||||
isEditing,
|
||||
canEdit,
|
||||
isPublic,
|
||||
isLoading,
|
||||
@@ -57,6 +58,7 @@ const DashboardWidget = React.memo(
|
||||
widget={widget}
|
||||
dashboard={dashboard}
|
||||
filters={filters}
|
||||
isEditing={isEditing}
|
||||
canEdit={canEdit}
|
||||
isPublic={isPublic}
|
||||
isLoading={isLoading}
|
||||
@@ -77,7 +79,8 @@ const DashboardWidget = React.memo(
|
||||
prevProps.canEdit === nextProps.canEdit &&
|
||||
prevProps.isPublic === nextProps.isPublic &&
|
||||
prevProps.isLoading === nextProps.isLoading &&
|
||||
prevProps.filters === nextProps.filters
|
||||
prevProps.filters === nextProps.filters &&
|
||||
prevProps.isEditing === nextProps.isEditing
|
||||
);
|
||||
|
||||
class DashboardGrid extends React.Component {
|
||||
@@ -223,7 +226,6 @@ class DashboardGrid extends React.Component {
|
||||
});
|
||||
|
||||
render() {
|
||||
const className = cx("dashboard-wrapper", this.props.isEditing ? "editing-mode" : "preview-mode");
|
||||
const {
|
||||
onLoadWidget,
|
||||
onRefreshWidget,
|
||||
@@ -232,19 +234,21 @@ class DashboardGrid extends React.Component {
|
||||
filters,
|
||||
dashboard,
|
||||
isPublic,
|
||||
isEditing,
|
||||
widgets,
|
||||
} = this.props;
|
||||
const className = cx("dashboard-wrapper", isEditing ? "editing-mode" : "preview-mode");
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveGridLayout
|
||||
draggableCancel="input"
|
||||
draggableCancel="input,.sortable-container"
|
||||
className={cx("layout", { "disable-animations": this.state.disableAnimations })}
|
||||
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
|
||||
rowHeight={cfg.rowHeight - cfg.margins}
|
||||
margin={[cfg.margins, cfg.margins]}
|
||||
isDraggable={this.props.isEditing}
|
||||
isResizable={this.props.isEditing}
|
||||
isDraggable={isEditing}
|
||||
isResizable={isEditing}
|
||||
onResizeStart={this.autoHeightCtrl.stop}
|
||||
onResizeStop={this.onWidgetResize}
|
||||
layouts={this.state.layouts}
|
||||
@@ -266,6 +270,7 @@ class DashboardGrid extends React.Component {
|
||||
filters={filters}
|
||||
isPublic={isPublic}
|
||||
isLoading={widget.loading}
|
||||
isEditing={isEditing}
|
||||
canEdit={dashboard.canEdit()}
|
||||
onLoadWidget={onLoadWidget}
|
||||
onRefreshWidget={onRefreshWidget}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { compact, isEmpty, invoke } from "lodash";
|
||||
import { compact, isEmpty, invoke, map } from "lodash";
|
||||
import { markdown } from "markdown";
|
||||
import cx from "classnames";
|
||||
import Menu from "antd/lib/menu";
|
||||
@@ -84,7 +84,14 @@ function RefreshIndicator({ refreshStartedAt }) {
|
||||
RefreshIndicator.propTypes = { refreshStartedAt: Moment };
|
||||
RefreshIndicator.defaultProps = { refreshStartedAt: null };
|
||||
|
||||
function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onParametersUpdate }) {
|
||||
function VisualizationWidgetHeader({
|
||||
widget,
|
||||
refreshStartedAt,
|
||||
parameters,
|
||||
isEditing,
|
||||
onParametersUpdate,
|
||||
onParametersEdit,
|
||||
}) {
|
||||
const canViewQuery = currentUser.hasPermission("view_query");
|
||||
|
||||
return (
|
||||
@@ -104,7 +111,13 @@ function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onPar
|
||||
</div>
|
||||
{!isEmpty(parameters) && (
|
||||
<div className="m-b-10">
|
||||
<Parameters parameters={parameters} onValuesChange={onParametersUpdate} />
|
||||
<Parameters
|
||||
parameters={parameters}
|
||||
sortable={isEditing}
|
||||
appendSortableToParent={false}
|
||||
onValuesChange={onParametersUpdate}
|
||||
onParametersEdit={onParametersEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -115,12 +128,16 @@ VisualizationWidgetHeader.propTypes = {
|
||||
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
refreshStartedAt: Moment,
|
||||
parameters: PropTypes.arrayOf(PropTypes.object),
|
||||
isEditing: PropTypes.bool,
|
||||
onParametersUpdate: PropTypes.func,
|
||||
onParametersEdit: PropTypes.func,
|
||||
};
|
||||
|
||||
VisualizationWidgetHeader.defaultProps = {
|
||||
refreshStartedAt: null,
|
||||
onParametersUpdate: () => {},
|
||||
onParametersEdit: () => {},
|
||||
isEditing: false,
|
||||
parameters: [],
|
||||
};
|
||||
|
||||
@@ -190,6 +207,7 @@ class VisualizationWidget extends React.Component {
|
||||
isPublic: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
canEdit: PropTypes.bool,
|
||||
isEditing: PropTypes.bool,
|
||||
onLoad: PropTypes.func,
|
||||
onRefresh: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
@@ -201,6 +219,7 @@ class VisualizationWidget extends React.Component {
|
||||
isPublic: false,
|
||||
isLoading: false,
|
||||
canEdit: false,
|
||||
isEditing: false,
|
||||
onLoad: () => {},
|
||||
onRefresh: () => {},
|
||||
onDelete: () => {},
|
||||
@@ -284,10 +303,15 @@ class VisualizationWidget extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { widget, isLoading, isPublic, canEdit, onRefresh } = this.props;
|
||||
const { widget, isLoading, isPublic, canEdit, isEditing, onRefresh } = this.props;
|
||||
const { localParameters } = this.state;
|
||||
const widgetQueryResult = widget.getQueryResult();
|
||||
const isRefreshing = isLoading && !!(widgetQueryResult && widgetQueryResult.getStatus());
|
||||
const onParametersEdit = parameters => {
|
||||
const paramOrder = map(parameters, "name");
|
||||
widget.options.paramOrder = paramOrder;
|
||||
widget.save("options", { paramOrder });
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget
|
||||
@@ -303,7 +327,9 @@ class VisualizationWidget extends React.Component {
|
||||
widget={widget}
|
||||
refreshStartedAt={isRefreshing ? widget.refreshStartedAt : null}
|
||||
parameters={localParameters}
|
||||
isEditing={isEditing}
|
||||
onParametersUpdate={onRefresh}
|
||||
onParametersEdit={onParametersEdit}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "../../../assets/less/inc/variables";
|
||||
@import (reference, less) "~@/assets/less/inc/variables";
|
||||
|
||||
.tile .t-header .th-title a.query-link {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~@/assets/less/ant";
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
@btn-extra-options-bg: fade(@redash-gray, 10%);
|
||||
@btn-extra-options-border: fade(@redash-gray, 15%);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "../../assets/less/inc/variables";
|
||||
@import (reference, less) "~@/assets/less/inc/variables";
|
||||
|
||||
.date-range-parameter,
|
||||
.date-parameter {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Empty states
|
||||
.empty-state {
|
||||
width: 100%;
|
||||
margin: 0px auto 10px;
|
||||
margin: 0 auto 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
.empty-state__steps {
|
||||
padding-left: 0px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.empty-state__summary {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '~antd/lib/button/style/index';
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
.embed-query-dialog {
|
||||
label {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import (reference, less) '~@/assets/less/main.less';
|
||||
@import (reference, less) "~@/assets/less/main.less";
|
||||
|
||||
.ant-list {
|
||||
&.add-to-dashboard-dialog-search-results {
|
||||
@@ -13,7 +13,8 @@
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover, &:active {
|
||||
&:hover,
|
||||
&:active {
|
||||
@table-row-hover-bg: fade(@redash-gray, 5%);
|
||||
background-color: @table-row-hover-bg;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~@/assets/less/ant";
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
.databricks-schema-browser {
|
||||
.schema-control {
|
||||
|
||||
@@ -9,6 +9,7 @@ function getQueryResultData(queryResult, queryResultStatus = null) {
|
||||
filters: invoke(queryResult, "getFilters") || [],
|
||||
updatedAt: invoke(queryResult, "getUpdatedAt") || null,
|
||||
retrievedAt: get(queryResult, "query_result.retrieved_at", null),
|
||||
truncated: invoke(queryResult, "getTruncated") || null,
|
||||
log: invoke(queryResult, "getLog") || [],
|
||||
error: invoke(queryResult, "getError") || null,
|
||||
runtime: invoke(queryResult, "getRuntime") || null,
|
||||
|
||||
@@ -30,6 +30,13 @@ const sidebarMenu = [
|
||||
key: "all",
|
||||
href: "dashboards",
|
||||
title: "All Dashboards",
|
||||
icon: () => <Sidebar.MenuIcon icon="zmdi zmdi-view-quilt" />,
|
||||
},
|
||||
{
|
||||
key: "my",
|
||||
href: "dashboards/my",
|
||||
title: "My Dashboards",
|
||||
icon: () => <Sidebar.ProfileImage user={currentUser} />,
|
||||
},
|
||||
{
|
||||
key: "favorites",
|
||||
@@ -157,6 +164,7 @@ const DashboardListPage = itemsList(
|
||||
getResource({ params: { currentPage } }) {
|
||||
return {
|
||||
all: Dashboard.query.bind(Dashboard),
|
||||
my: Dashboard.myDashboards.bind(Dashboard),
|
||||
favorites: Dashboard.favorites.bind(Dashboard),
|
||||
}[currentPage];
|
||||
},
|
||||
@@ -183,3 +191,11 @@ routes.register(
|
||||
render: pageProps => <DashboardListPage {...pageProps} currentPage="favorites" />,
|
||||
})
|
||||
);
|
||||
routes.register(
|
||||
"Dashboards.My",
|
||||
routeWithUserSession({
|
||||
path: "/dashboards/my",
|
||||
title: "My Dashboards",
|
||||
render: pageProps => <DashboardListPage {...pageProps} currentPage="my" />,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEmpty } from "lodash";
|
||||
import { isEmpty, map } from "lodash";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
@@ -24,8 +24,8 @@ import DashboardHeader from "./components/DashboardHeader";
|
||||
|
||||
import "./DashboardPage.less";
|
||||
|
||||
function DashboardSettings({ dashboardOptions }) {
|
||||
const { dashboard, updateDashboard } = dashboardOptions;
|
||||
function DashboardSettings({ dashboardConfiguration }) {
|
||||
const { dashboard, updateDashboard } = dashboardConfiguration;
|
||||
return (
|
||||
<div className="m-b-10 p-15 bg-white tiled">
|
||||
<Checkbox
|
||||
@@ -39,11 +39,11 @@ function DashboardSettings({ dashboardOptions }) {
|
||||
}
|
||||
|
||||
DashboardSettings.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
function AddWidgetContainer({ dashboardOptions, className, ...props }) {
|
||||
const { showAddTextboxDialog, showAddWidgetDialog } = dashboardOptions;
|
||||
function AddWidgetContainer({ dashboardConfiguration, className, ...props }) {
|
||||
const { showAddTextboxDialog, showAddWidgetDialog } = dashboardConfiguration;
|
||||
return (
|
||||
<div className={cx("add-widget-container", className)} {...props}>
|
||||
<h2>
|
||||
@@ -66,12 +66,12 @@ function AddWidgetContainer({ dashboardOptions, className, ...props }) {
|
||||
}
|
||||
|
||||
AddWidgetContainer.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
function DashboardComponent(props) {
|
||||
const dashboardOptions = useDashboard(props.dashboard);
|
||||
const dashboardConfiguration = useDashboard(props.dashboard);
|
||||
const {
|
||||
dashboard,
|
||||
filters,
|
||||
@@ -81,14 +81,19 @@ function DashboardComponent(props) {
|
||||
removeWidget,
|
||||
saveDashboardLayout,
|
||||
globalParameters,
|
||||
updateDashboard,
|
||||
refreshDashboard,
|
||||
refreshWidget,
|
||||
editingLayout,
|
||||
setGridDisabled,
|
||||
} = dashboardOptions;
|
||||
} = dashboardConfiguration;
|
||||
|
||||
const [pageContainer, setPageContainer] = useState(null);
|
||||
const [bottomPanelStyles, setBottomPanelStyles] = useState({});
|
||||
const onParametersEdit = parameters => {
|
||||
const paramOrder = map(parameters, "name");
|
||||
updateDashboard({ options: { globalParamOrder: paramOrder } });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (pageContainer) {
|
||||
@@ -114,14 +119,23 @@ function DashboardComponent(props) {
|
||||
return (
|
||||
<div className="container" ref={setPageContainer} data-test={`DashboardId${dashboard.id}Container`}>
|
||||
<DashboardHeader
|
||||
dashboardOptions={dashboardOptions}
|
||||
dashboardConfiguration={dashboardConfiguration}
|
||||
headerExtra={
|
||||
<DynamicComponent name="Dashboard.HeaderExtra" dashboard={dashboard} dashboardOptions={dashboardOptions} />
|
||||
<DynamicComponent
|
||||
name="Dashboard.HeaderExtra"
|
||||
dashboard={dashboard}
|
||||
dashboardConfiguration={dashboardConfiguration}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!isEmpty(globalParameters) && (
|
||||
<div className="dashboard-parameters m-b-10 p-15 bg-white tiled" data-test="DashboardParameters">
|
||||
<Parameters parameters={globalParameters} onValuesChange={refreshDashboard} />
|
||||
<Parameters
|
||||
parameters={globalParameters}
|
||||
onValuesChange={refreshDashboard}
|
||||
sortable={editingLayout}
|
||||
onParametersEdit={onParametersEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(filters) && (
|
||||
@@ -129,7 +143,7 @@ function DashboardComponent(props) {
|
||||
<Filters filters={filters} onChange={setFilters} />
|
||||
</div>
|
||||
)}
|
||||
{editingLayout && <DashboardSettings dashboardOptions={dashboardOptions} />}
|
||||
{editingLayout && <DashboardSettings dashboardConfiguration={dashboardConfiguration} />}
|
||||
<div id="dashboard-container">
|
||||
<DashboardGrid
|
||||
dashboard={dashboard}
|
||||
@@ -144,7 +158,9 @@ function DashboardComponent(props) {
|
||||
onParameterMappingsChange={loadDashboard}
|
||||
/>
|
||||
</div>
|
||||
{editingLayout && <AddWidgetContainer dashboardOptions={dashboardOptions} style={bottomPanelStyles} />}
|
||||
{editingLayout && (
|
||||
<AddWidgetContainer dashboardConfiguration={dashboardConfiguration} style={bottomPanelStyles} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~@/assets/less/inc/variables";
|
||||
@import (reference, less) "~@/assets/less/inc/variables";
|
||||
|
||||
/****
|
||||
grid bg - based on 6 cols, 35px rows and 15px spacing
|
||||
|
||||
@@ -27,8 +27,8 @@ function buttonType(value) {
|
||||
return value ? "primary" : "default";
|
||||
}
|
||||
|
||||
function DashboardPageTitle({ dashboardOptions }) {
|
||||
const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardOptions;
|
||||
function DashboardPageTitle({ dashboardConfiguration }) {
|
||||
const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardConfiguration;
|
||||
return (
|
||||
<div className="title-with-tags">
|
||||
<div className="page-title">
|
||||
@@ -58,11 +58,11 @@ function DashboardPageTitle({ dashboardOptions }) {
|
||||
}
|
||||
|
||||
DashboardPageTitle.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
function RefreshButton({ dashboardOptions }) {
|
||||
const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardOptions;
|
||||
function RefreshButton({ dashboardConfiguration }) {
|
||||
const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardConfiguration;
|
||||
const allowedIntervals = policy.getDashboardRefreshIntervals();
|
||||
const refreshRateOptions = clientConfig.dashboardRefreshIntervals;
|
||||
const onRefreshRateSelected = ({ key }) => {
|
||||
@@ -105,10 +105,10 @@ function RefreshButton({ dashboardOptions }) {
|
||||
}
|
||||
|
||||
RefreshButton.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
function DashboardMoreOptionsButton({ dashboardOptions }) {
|
||||
function DashboardMoreOptionsButton({ dashboardConfiguration }) {
|
||||
const {
|
||||
dashboard,
|
||||
setEditingLayout,
|
||||
@@ -117,7 +117,7 @@ function DashboardMoreOptionsButton({ dashboardOptions }) {
|
||||
managePermissions,
|
||||
gridDisabled,
|
||||
isDashboardOwnerOrAdmin,
|
||||
} = dashboardOptions;
|
||||
} = dashboardConfiguration;
|
||||
|
||||
const archive = () => {
|
||||
Modal.confirm({
|
||||
@@ -163,10 +163,10 @@ function DashboardMoreOptionsButton({ dashboardOptions }) {
|
||||
}
|
||||
|
||||
DashboardMoreOptionsButton.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
function DashboardControl({ dashboardOptions, headerExtra }) {
|
||||
function DashboardControl({ dashboardConfiguration, headerExtra }) {
|
||||
const {
|
||||
dashboard,
|
||||
togglePublished,
|
||||
@@ -174,7 +174,7 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
|
||||
fullscreen,
|
||||
toggleFullscreen,
|
||||
showShareDashboardDialog,
|
||||
} = dashboardOptions;
|
||||
} = dashboardConfiguration;
|
||||
const showPublishButton = dashboard.is_draft;
|
||||
const showRefreshButton = true;
|
||||
const showFullscreenButton = !dashboard.is_draft;
|
||||
@@ -190,7 +190,7 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
|
||||
<span className="fa fa-paper-plane m-r-5" /> Publish
|
||||
</Button>
|
||||
)}
|
||||
{showRefreshButton && <RefreshButton dashboardOptions={dashboardOptions} />}
|
||||
{showRefreshButton && <RefreshButton dashboardConfiguration={dashboardConfiguration} />}
|
||||
{showFullscreenButton && (
|
||||
<Tooltip className="hidden-xs" title="Enable/Disable Fullscreen display">
|
||||
<Button type={buttonType(fullscreen)} className="icon-button m-l-5" onClick={toggleFullscreen}>
|
||||
@@ -210,7 +210,7 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showMoreOptionsButton && <DashboardMoreOptionsButton dashboardOptions={dashboardOptions} />}
|
||||
{showMoreOptionsButton && <DashboardMoreOptionsButton dashboardConfiguration={dashboardConfiguration} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -218,12 +218,17 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
|
||||
}
|
||||
|
||||
DashboardControl.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
headerExtra: PropTypes.node,
|
||||
};
|
||||
|
||||
function DashboardEditControl({ dashboardOptions, headerExtra }) {
|
||||
const { setEditingLayout, doneBtnClickedWhileSaving, dashboardStatus, retrySaveDashboardLayout } = dashboardOptions;
|
||||
function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
|
||||
const {
|
||||
setEditingLayout,
|
||||
doneBtnClickedWhileSaving,
|
||||
dashboardStatus,
|
||||
retrySaveDashboardLayout,
|
||||
} = dashboardConfiguration;
|
||||
let status;
|
||||
if (dashboardStatus === DashboardStatusEnum.SAVED) {
|
||||
status = <span className="save-status">Saved</span>;
|
||||
@@ -258,23 +263,23 @@ function DashboardEditControl({ dashboardOptions, headerExtra }) {
|
||||
}
|
||||
|
||||
DashboardEditControl.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
headerExtra: PropTypes.node,
|
||||
};
|
||||
|
||||
export default function DashboardHeader({ dashboardOptions, headerExtra }) {
|
||||
const { editingLayout } = dashboardOptions;
|
||||
export default function DashboardHeader({ dashboardConfiguration, headerExtra }) {
|
||||
const { editingLayout } = dashboardConfiguration;
|
||||
const DashboardControlComponent = editingLayout ? DashboardEditControl : DashboardControl;
|
||||
|
||||
return (
|
||||
<div className="dashboard-header">
|
||||
<DashboardPageTitle dashboardOptions={dashboardOptions} />
|
||||
<DashboardControlComponent dashboardOptions={dashboardOptions} headerExtra={headerExtra} />
|
||||
<DashboardPageTitle dashboardConfiguration={dashboardConfiguration} />
|
||||
<DashboardControlComponent dashboardConfiguration={dashboardConfiguration} headerExtra={headerExtra} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DashboardHeader.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
headerExtra: PropTypes.node,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~@/components/ApplicationArea/ApplicationLayout/index.less";
|
||||
@import (reference, less) "~@/components/ApplicationArea/ApplicationLayout/index.less";
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
|
||||
@@ -4,6 +4,10 @@ import BigMessage from "@/components/BigMessage";
|
||||
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
|
||||
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import Link from "@/components/Link";
|
||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||
import { currentUser } from "@/services/auth";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
|
||||
export interface DashboardListEmptyStateProps {
|
||||
page: string;
|
||||
@@ -21,6 +25,20 @@ export default function DashboardListEmptyState({ page, searchTerm, selectedTags
|
||||
switch (page) {
|
||||
case "favorites":
|
||||
return <BigMessage message="Mark dashboards as Favorite to list them here." icon="fa-star" />;
|
||||
case "my":
|
||||
const my_msg = currentUser.hasPermission("create_dashboard") ? (
|
||||
<span>
|
||||
<Link.Button type="primary" size="small" onClick={() => CreateDashboardDialog.showModal()}>
|
||||
Create your first dashboard!
|
||||
</Link.Button>{" "}
|
||||
<HelpTrigger className="f-14" type="DASHBOARDS" showTooltip={false}>
|
||||
Need help?
|
||||
</HelpTrigger>
|
||||
</span>
|
||||
) : (
|
||||
<span>Sorry, we couldn't find anything.</span>
|
||||
);
|
||||
return <BigMessage icon="fa-search">{my_msg}</BigMessage>;
|
||||
default:
|
||||
return (
|
||||
<DynamicComponent name="DashboardList.EmptyState">
|
||||
|
||||
@@ -33,19 +33,19 @@ const sidebarMenu = [
|
||||
key: "all",
|
||||
href: "queries",
|
||||
title: "All Queries",
|
||||
},
|
||||
{
|
||||
key: "favorites",
|
||||
href: "queries/favorites",
|
||||
title: "Favorites",
|
||||
icon: () => <Sidebar.MenuIcon icon="fa fa-star" />,
|
||||
icon: () => <Sidebar.MenuIcon icon="fa fa-code" />,
|
||||
},
|
||||
{
|
||||
key: "my",
|
||||
href: "queries/my",
|
||||
title: "My Queries",
|
||||
icon: () => <Sidebar.ProfileImage user={currentUser} />,
|
||||
isAvailable: () => currentUser.hasPermission("create_query"),
|
||||
},
|
||||
{
|
||||
key: "favorites",
|
||||
href: "queries/favorites",
|
||||
title: "Favorites",
|
||||
icon: () => <Sidebar.MenuIcon icon="fa fa-star" />,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
|
||||
@@ -5,6 +5,8 @@ import BigMessage from "@/components/BigMessage";
|
||||
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
|
||||
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import { currentUser } from "@/services/auth";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
|
||||
export default function QueriesListEmptyState({ page, searchTerm, selectedTags }) {
|
||||
if (searchTerm !== "") {
|
||||
@@ -19,15 +21,19 @@ export default function QueriesListEmptyState({ page, searchTerm, selectedTags }
|
||||
case "archive":
|
||||
return <BigMessage message="Archived queries will be listed here." icon="fa-archive" />;
|
||||
case "my":
|
||||
return (
|
||||
<div className="tiled bg-white p-15">
|
||||
const my_msg = currentUser.hasPermission("create_query") ? (
|
||||
<span>
|
||||
<Link.Button href="queries/new" type="primary" size="small">
|
||||
Create your first query
|
||||
Create your first query!
|
||||
</Link.Button>{" "}
|
||||
to populate My Queries list. Need help? Check out our{" "}
|
||||
<Link href="https://redash.io/help/user-guide/querying/writing-queries">query writing documentation</Link>.
|
||||
</div>
|
||||
<HelpTrigger className="f-13" type="QUERIES" showTooltip={false}>
|
||||
Need help?
|
||||
</HelpTrigger>
|
||||
</span>
|
||||
) : (
|
||||
<span>Sorry, we couldn't find anything.</span>
|
||||
);
|
||||
return <BigMessage icon="fa-search">{my_msg}</BigMessage>;
|
||||
default:
|
||||
return (
|
||||
<DynamicComponent name="QueriesList.EmptyState">
|
||||
|
||||
@@ -336,6 +336,7 @@ function QuerySource(props) {
|
||||
<div className="query-parameters-wrapper">
|
||||
<Parameters
|
||||
editable={queryFlags.canEdit}
|
||||
sortable={queryFlags.canEdit}
|
||||
disableUrlUpdate={queryFlags.isNew}
|
||||
parameters={parameters}
|
||||
onPendingValuesChange={() => updateParametersDirtyFlag()}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import WarningTwoTone from "@ant-design/icons/WarningTwoTone";
|
||||
import TimeAgo from "@/components/TimeAgo";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import useAddToDashboardDialog from "../hooks/useAddToDashboardDialog";
|
||||
import useEmbedDialog from "../hooks/useEmbedDialog";
|
||||
import QueryControlDropdown from "@/components/EditVisualizationButton/QueryControlDropdown";
|
||||
@@ -42,6 +44,18 @@ export default function QueryExecutionMetadata({
|
||||
)}
|
||||
<span className="m-l-5 m-r-10">
|
||||
<span>
|
||||
{queryResultData.truncated === true && (
|
||||
<span className="m-r-5">
|
||||
<Tooltip
|
||||
title={
|
||||
"Result truncated to " +
|
||||
queryResultData.rows.length +
|
||||
" rows. Databricks may truncate query results that are unstably large."
|
||||
}>
|
||||
<WarningTwoTone twoToneColor="#FF9800" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
<strong>{queryResultData.rows.length}</strong> {pluralize("row", queryResultData.rows.length)}
|
||||
</span>
|
||||
<span className="m-l-5">
|
||||
|
||||
@@ -26,7 +26,7 @@ function OrganizationSettings({ onError }) {
|
||||
{isLoading ? (
|
||||
<Skeleton.Button active />
|
||||
) : (
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
<Button type="primary" htmlType="submit" loading={isSaving} data-test="OrganizationSettingsSaveButton">
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -20,7 +20,9 @@ export default function FormatSettings(props) {
|
||||
onChange={value => onChange({ date_format: value })}
|
||||
data-test="DateFormatSelect">
|
||||
{clientConfig.dateFormatList.map(dateFormat => (
|
||||
<Select.Option key={dateFormat}>{dateFormat}</Select.Option>
|
||||
<Select.Option key={dateFormat} data-test={`DateFormatSelect:${dateFormat}`}>
|
||||
{dateFormat}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import recordEvent from "@/services/recordEvent";
|
||||
import OrgSettings from "@/services/organizationSettings";
|
||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
||||
import { updateClientConfig } from "@/services/auth";
|
||||
|
||||
export default function useOrganizationSettings({ onError }) {
|
||||
const [settings, setSettings] = useState({});
|
||||
@@ -49,6 +50,11 @@ export default function useOrganizationSettings({ onError }) {
|
||||
const settings = get(response, "settings");
|
||||
setSettings(settings);
|
||||
setCurrentValues({ ...settings });
|
||||
updateClientConfig({
|
||||
dateFormat: currentValues.date_format,
|
||||
timeFormat: currentValues.time_format,
|
||||
dateTimeFormat: `${currentValues.date_format} ${currentValues.time_format}`,
|
||||
});
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => setIsSaving(false));
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
@import "variables";
|
||||
@import "./variables";
|
||||
|
||||
@font-face {
|
||||
font-family: '@{icomoon-font-family}';
|
||||
src: url('@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm');
|
||||
src: url('@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm#iefix') format('embedded-opentype'),
|
||||
url('@{icomoon-font-path}/@{icomoon-font-family}.ttf?ehpufm') format('truetype'),
|
||||
url('@{icomoon-font-path}/@{icomoon-font-family}.woff?ehpufm') format('woff'),
|
||||
url('@{icomoon-font-path}/@{icomoon-font-family}.svg?ehpufm#@{icomoon-font-family}') format('svg');
|
||||
font-family: "@{icomoon-font-family}";
|
||||
src: url("@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm");
|
||||
src: url("@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm#iefix") format("embedded-opentype"),
|
||||
url("@{icomoon-font-path}/@{icomoon-font-family}.ttf?ehpufm") format("truetype"),
|
||||
url("@{icomoon-font-path}/@{icomoon-font-family}.woff?ehpufm") format("woff"),
|
||||
url("@{icomoon-font-path}/@{icomoon-font-family}.svg?ehpufm#@{icomoon-font-family}") format("svg");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
i.icon {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: '@{icomoon-font-family}' !important;
|
||||
font-family: "@{icomoon-font-family}" !important;
|
||||
speak: none;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
@@ -28,12 +28,11 @@ i.icon {
|
||||
|
||||
.icon-flash-off {
|
||||
&:before {
|
||||
content: @icon-flash-off;
|
||||
content: @icon-flash-off;
|
||||
}
|
||||
}
|
||||
.icon-flash {
|
||||
&:before {
|
||||
content: @icon-flash;
|
||||
content: @icon-flash;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ const AuthUrls = {
|
||||
Login: "login",
|
||||
};
|
||||
|
||||
export function updateClientConfig(newClientConfig) {
|
||||
extend(clientConfig, newClientConfig);
|
||||
}
|
||||
|
||||
function updateSession(sessionData) {
|
||||
logger("Updating session to be:", sessionData);
|
||||
extend(session, sessionData, { loaded: true });
|
||||
|
||||
@@ -168,6 +168,7 @@ const DashboardService = {
|
||||
delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse),
|
||||
query: params => axios.get("api/dashboards", { params }).then(transformResponse),
|
||||
recent: params => axios.get("api/dashboards/recent", { params }).then(transformResponse),
|
||||
myDashboards: params => axios.get("api/dashboards/my", { params }).then(transformResponse),
|
||||
favorites: params => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
|
||||
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
|
||||
unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`),
|
||||
@@ -208,12 +209,19 @@ Dashboard.prototype.getParametersDefs = function getParametersDefs() {
|
||||
});
|
||||
}
|
||||
});
|
||||
return _.values(
|
||||
const resultingGlobalParams = _.values(
|
||||
_.each(globalParams, param => {
|
||||
param.setValue(param.value); // apply global param value to all locals
|
||||
param.fromUrlParams(queryParams); // try to initialize from url (may do nothing)
|
||||
})
|
||||
);
|
||||
|
||||
// order dashboard params using paramOrder
|
||||
return _.sortBy(resultingGlobalParams, param =>
|
||||
_.includes(this.options.globalParamOrder, param.name)
|
||||
? _.indexOf(this.options.globalParamOrder, param.name)
|
||||
: _.size(this.options.globalParamOrder)
|
||||
);
|
||||
};
|
||||
|
||||
Dashboard.prototype.addWidget = function addWidget(textOrVisualization, options = {}) {
|
||||
|
||||
@@ -271,6 +271,10 @@ class QueryResult {
|
||||
return this.getColumnNames().map(col => getColumnFriendlyName(col));
|
||||
}
|
||||
|
||||
getTruncated() {
|
||||
return this.query_result.data ? this.query_result.data.truncated : null;
|
||||
}
|
||||
|
||||
getFilters() {
|
||||
if (!this.getColumns()) {
|
||||
return [];
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import moment from "moment";
|
||||
import { axios } from "@/services/axios";
|
||||
import { each, pick, extend, isObject, truncate, keys, difference, filter, map, merge } from "lodash";
|
||||
import {
|
||||
each,
|
||||
pick,
|
||||
extend,
|
||||
isObject,
|
||||
truncate,
|
||||
keys,
|
||||
difference,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
sortBy,
|
||||
indexOf,
|
||||
size,
|
||||
includes,
|
||||
} from "lodash";
|
||||
import location from "@/services/location";
|
||||
import { cloneParameter } from "@/services/parameters";
|
||||
import dashboardGridOptions from "@/config/dashboard-grid-options";
|
||||
@@ -207,7 +222,7 @@ class Widget {
|
||||
const queryParams = location.search;
|
||||
|
||||
const localTypes = [Widget.MappingType.WidgetLevel, Widget.MappingType.StaticValue];
|
||||
return map(
|
||||
const localParameters = map(
|
||||
filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0),
|
||||
param => {
|
||||
const mapping = mappings[param.name];
|
||||
@@ -223,6 +238,13 @@ class Widget {
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
// order widget params using paramOrder
|
||||
return sortBy(localParameters, param =>
|
||||
includes(this.options.paramOrder, param.name)
|
||||
? indexOf(this.options.paramOrder, param.name)
|
||||
: size(this.options.paramOrder)
|
||||
);
|
||||
}
|
||||
|
||||
getParameterMappings() {
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { createQueryAndAddWidget } from "../../support/dashboard";
|
||||
|
||||
describe("Parameter Mapping", () => {
|
||||
beforeEach(function() {
|
||||
cy.login();
|
||||
cy.createDashboard("Foo Bar")
|
||||
.then(({ id }) => {
|
||||
this.dashboardId = id;
|
||||
this.dashboardUrl = `/dashboards/${id}`;
|
||||
})
|
||||
.then(() => {
|
||||
const queryData = {
|
||||
name: "Text Parameter",
|
||||
query: "SELECT '{{test-parameter}}' AS parameter",
|
||||
options: {
|
||||
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "text", value: "example" }],
|
||||
},
|
||||
};
|
||||
const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
|
||||
createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(widgetTestId => {
|
||||
cy.visit(this.dashboardUrl);
|
||||
this.widgetTestId = widgetTestId;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const openMappingOptions = (widgetTestId, paramName) => {
|
||||
cy.getByTestId(widgetTestId).within(() => {
|
||||
cy.getByTestId("WidgetDropdownButton").click();
|
||||
});
|
||||
|
||||
cy.getByTestId("WidgetDropdownButtonMenu")
|
||||
.contains("Edit Parameters")
|
||||
.click();
|
||||
|
||||
cy.getByTestId(`EditParamMappingButton-${paramName}`).click();
|
||||
};
|
||||
|
||||
const saveMappingOptions = () => {
|
||||
cy.getByTestId("EditParamMappingPopover").within(() => {
|
||||
cy.contains("button", "OK").click();
|
||||
});
|
||||
|
||||
cy.contains("button", "OK").click();
|
||||
};
|
||||
|
||||
it("supports widget parameters", function() {
|
||||
// widget parameter mapping is the default for the API
|
||||
cy.getByTestId(this.widgetTestId).within(() => {
|
||||
cy.getByTestId("TableVisualization").should("contain", "example");
|
||||
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.type("{selectall}Redash");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
|
||||
cy.getByTestId("TableVisualization").should("contain", "Redash");
|
||||
});
|
||||
|
||||
cy.getByTestId("DashboardParameters").should("not.exist");
|
||||
});
|
||||
|
||||
it("supports dashboard parameters", function() {
|
||||
openMappingOptions(this.widgetTestId, "test-parameter");
|
||||
|
||||
cy.getByTestId("NewDashboardParameterOption").click();
|
||||
|
||||
saveMappingOptions();
|
||||
|
||||
cy.getByTestId(this.widgetTestId).within(() => {
|
||||
cy.getByTestId("ParameterName-test-parameter").should("not.exist");
|
||||
});
|
||||
|
||||
cy.getByTestId("DashboardParameters").within(() => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.type("{selectall}DashboardParam");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
});
|
||||
|
||||
cy.getByTestId(this.widgetTestId).within(() => {
|
||||
cy.getByTestId("TableVisualization").should("contain", "DashboardParam");
|
||||
});
|
||||
});
|
||||
|
||||
it("supports static values for parameters", function() {
|
||||
openMappingOptions(this.widgetTestId, "test-parameter");
|
||||
|
||||
cy.getByTestId("StaticValueOption").click();
|
||||
|
||||
cy.getByTestId("EditParamMappingPopover").within(() => {
|
||||
cy.getByTestId("ParameterValueInput")
|
||||
.find("input")
|
||||
.type("{selectall}StaticValue");
|
||||
});
|
||||
|
||||
saveMappingOptions();
|
||||
|
||||
cy.getByTestId(this.widgetTestId).within(() => {
|
||||
cy.getByTestId("ParameterName-test-parameter").should("not.exist");
|
||||
});
|
||||
|
||||
cy.getByTestId("DashboardParameters").should("not.exist");
|
||||
|
||||
cy.getByTestId(this.widgetTestId).within(() => {
|
||||
cy.getByTestId("TableVisualization").should("contain", "StaticValue");
|
||||
});
|
||||
});
|
||||
});
|
||||
164
client/cypress/integration/dashboard/parameter_spec.js
Normal file
164
client/cypress/integration/dashboard/parameter_spec.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import { createQueryAndAddWidget, editDashboard } from "../../support/dashboard";
|
||||
import { dragParam, expectParamOrder } from "../../support/parameters";
|
||||
|
||||
describe("Dashboard Parameters", () => {
|
||||
const parameters = [
|
||||
{ name: "param1", title: "Parameter 1", type: "text", value: "example1" },
|
||||
{ name: "param2", title: "Parameter 2", type: "text", value: "example2" },
|
||||
];
|
||||
|
||||
beforeEach(function() {
|
||||
cy.login();
|
||||
cy.createDashboard("Foo Bar")
|
||||
.then(({ id }) => {
|
||||
this.dashboardId = id;
|
||||
this.dashboardUrl = `/dashboards/${id}`;
|
||||
})
|
||||
.then(() => {
|
||||
const queryData = {
|
||||
name: "Text Parameter",
|
||||
query: "SELECT '{{param1}}', '{{param2}}' AS parameter",
|
||||
options: {
|
||||
parameters,
|
||||
},
|
||||
};
|
||||
const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
|
||||
createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(widgetTestId => {
|
||||
cy.visit(this.dashboardUrl);
|
||||
this.widgetTestId = widgetTestId;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const openMappingOptions = widgetTestId => {
|
||||
cy.getByTestId(widgetTestId).within(() => {
|
||||
cy.getByTestId("WidgetDropdownButton").click();
|
||||
});
|
||||
|
||||
cy.getByTestId("WidgetDropdownButtonMenu")
|
||||
.contains("Edit Parameters")
|
||||
.click();
|
||||
};
|
||||
|
||||
const saveMappingOptions = (closeMappingMenu = false) => {
|
||||
return cy
|
||||
.getByTestId("EditParamMappingPopover")
|
||||
.filter(":visible")
|
||||
.as("Popover")
|
||||
.within(() => {
|
||||
// This is needed to grant the element will have finished loading
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(500);
|
||||
cy.contains("button", "OK").click();
|
||||
})
|
||||
.then(() => {
|
||||
if (closeMappingMenu) {
|
||||
cy.contains("button", "OK").click();
|
||||
}
|
||||
return cy.get("@Popover").should("not.be.visible");
|
||||
});
|
||||
};
|
||||
|
||||
const setWidgetParametersToDashboard = parameters => {
|
||||
cy.wrap(parameters).each(({ name: paramName }, i) => {
|
||||
cy.getByTestId(`EditParamMappingButton-${paramName}`).click();
|
||||
cy.getByTestId("NewDashboardParameterOption")
|
||||
.filter(":visible")
|
||||
.click();
|
||||
return saveMappingOptions(i === parameters.length - 1);
|
||||
});
|
||||
};
|
||||
|
||||
it("supports widget parameters", function() {
|
||||
// widget parameter mapping is the default for the API
|
||||
cy.getByTestId(this.widgetTestId).within(() => {
|
||||
cy.getByTestId("TableVisualization").should("contain", "example1");
|
||||
|
||||
cy.getByTestId("ParameterName-param1")
|
||||
.find("input")
|
||||
.type("{selectall}Redash");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
|
||||
cy.getByTestId("TableVisualization").should("contain", "Redash");
|
||||
});
|
||||
|
||||
cy.getByTestId("DashboardParameters").should("not.exist");
|
||||
});
|
||||
|
||||
it("supports dashboard parameters", function() {
|
||||
openMappingOptions(this.widgetTestId);
|
||||
setWidgetParametersToDashboard(parameters);
|
||||
|
||||
cy.getByTestId(this.widgetTestId).within(() => {
|
||||
cy.getByTestId("ParameterName-param1").should("not.exist");
|
||||
});
|
||||
|
||||
cy.getByTestId("DashboardParameters").within(() => {
|
||||
cy.getByTestId("ParameterName-param1")
|
||||
.find("input")
|
||||
.type("{selectall}DashboardParam");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
});
|
||||
|
||||
cy.getByTestId(this.widgetTestId).within(() => {
|
||||
cy.getByTestId("TableVisualization").should("contain", "DashboardParam");
|
||||
});
|
||||
});
|
||||
|
||||
it("supports static values for parameters", function() {
|
||||
openMappingOptions(this.widgetTestId);
|
||||
cy.getByTestId("EditParamMappingButton-param1").click();
|
||||
|
||||
cy.getByTestId("StaticValueOption").click();
|
||||
|
||||
cy.getByTestId("EditParamMappingPopover").within(() => {
|
||||
cy.getByTestId("ParameterValueInput")
|
||||
.find("input")
|
||||
.type("{selectall}StaticValue");
|
||||
});
|
||||
|
||||
saveMappingOptions(true);
|
||||
|
||||
cy.getByTestId(this.widgetTestId).within(() => {
|
||||
cy.getByTestId("ParameterName-param1").should("not.exist");
|
||||
});
|
||||
|
||||
cy.getByTestId("DashboardParameters").should("not.exist");
|
||||
|
||||
cy.getByTestId(this.widgetTestId).within(() => {
|
||||
cy.getByTestId("TableVisualization").should("contain", "StaticValue");
|
||||
});
|
||||
});
|
||||
|
||||
it("reorders parameters", function() {
|
||||
// Reorder is only available in edit mode
|
||||
editDashboard();
|
||||
|
||||
const [param1, param2] = parameters;
|
||||
|
||||
cy.getByTestId("ParameterBlock-param1")
|
||||
.invoke("width")
|
||||
.then(paramWidth => {
|
||||
cy.server();
|
||||
cy.route("POST", `**/api/dashboards/*`).as("SaveDashboard");
|
||||
cy.route("POST", `**/api/widgets/*`).as("SaveWidget");
|
||||
|
||||
// Asserts widget param order
|
||||
dragParam(param1.name, paramWidth, 1);
|
||||
cy.wait("@SaveWidget");
|
||||
cy.reload();
|
||||
expectParamOrder([param2.title, param1.title]);
|
||||
|
||||
// Asserts dashboard param order
|
||||
openMappingOptions(this.widgetTestId);
|
||||
setWidgetParametersToDashboard(parameters);
|
||||
cy.wait("@SaveWidget");
|
||||
dragParam(param1.name, paramWidth, 1);
|
||||
cy.wait("@SaveDashboard");
|
||||
cy.reload();
|
||||
expectParamOrder([param2.title, param1.title]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,11 @@
|
||||
import { dragParam } from "../../support/parameters";
|
||||
|
||||
function openAndSearchAntdDropdown(testId, paramOption) {
|
||||
cy.getByTestId(testId)
|
||||
.find(".ant-select-selection-search-input")
|
||||
.type(paramOption, { force: true });
|
||||
}
|
||||
|
||||
describe("Parameter", () => {
|
||||
const expectDirtyStateChange = edit => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
@@ -107,11 +115,13 @@ describe("Parameter", () => {
|
||||
});
|
||||
|
||||
it("updates the results after selecting a value", () => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find(".ant-select")
|
||||
.click();
|
||||
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
|
||||
|
||||
cy.contains(".ant-select-item-option", "value2").click();
|
||||
// only the filtered option should be on the DOM
|
||||
cy.get(".ant-select-item-option")
|
||||
.should("have.length", 1)
|
||||
.and("contain", "value2")
|
||||
.click();
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
// ensure that query is being executed
|
||||
@@ -219,6 +229,22 @@ describe("Parameter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the results after selecting a value", () => {
|
||||
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
|
||||
|
||||
// only the filtered option should be on the DOM
|
||||
cy.get(".ant-select-item-option")
|
||||
.should("have.length", 1)
|
||||
.and("contain", "value2")
|
||||
.click();
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
// ensure that query is being executed
|
||||
cy.getByTestId("QueryExecutionStatus").should("exist");
|
||||
|
||||
cy.getByTestId("TableVisualization").should("contain", "2");
|
||||
});
|
||||
|
||||
it("supports multi-selection", () => {
|
||||
cy.clickThrough(`
|
||||
ParameterSettings-test-parameter
|
||||
@@ -575,16 +601,6 @@ describe("Parameter", () => {
|
||||
cy.get("body").type("{alt}D"); // hide schema browser
|
||||
});
|
||||
|
||||
const dragParam = (paramName, offsetLeft, offsetTop) => {
|
||||
cy.getByTestId(`DragHandle-${paramName}`)
|
||||
.trigger("mouseover")
|
||||
.trigger("mousedown");
|
||||
|
||||
cy.get(".parameter-dragged .drag-handle")
|
||||
.trigger("mousemove", offsetLeft, offsetTop, { force: true })
|
||||
.trigger("mouseup", { force: true });
|
||||
};
|
||||
|
||||
it("is possible to rearrange parameters", function() {
|
||||
cy.server();
|
||||
cy.route("POST", "**/api/queries/*").as("QuerySave");
|
||||
|
||||
@@ -6,10 +6,40 @@ describe("Settings", () => {
|
||||
|
||||
it("renders the page and takes a screenshot", () => {
|
||||
cy.getByTestId("OrganizationSettings").within(() => {
|
||||
cy.getByTestId("DateFormatSelect").should("contain", "DD/MM/YY");
|
||||
cy.getByTestId("TimeFormatSelect").should("contain", "HH:mm");
|
||||
});
|
||||
|
||||
cy.percySnapshot("Organization Settings");
|
||||
});
|
||||
|
||||
it("can set date format setting", () => {
|
||||
cy.getByTestId("DateFormatSelect").click();
|
||||
cy.getByTestId("DateFormatSelect:YYYY-MM-DD").click();
|
||||
cy.getByTestId("OrganizationSettingsSaveButton").click();
|
||||
|
||||
cy.createQuery({
|
||||
name: "test date format",
|
||||
query: "SELECT NOW()",
|
||||
}).then(({ id: queryId }) => {
|
||||
cy.visit(`/queries/${queryId}`);
|
||||
cy.findByText("Refresh Now").click();
|
||||
|
||||
// "created at" field is formatted with the date format.
|
||||
cy.getByTestId("TableVisualization")
|
||||
.findAllByText(/\d{4}-\d{2}-\d{2}/)
|
||||
.should("exist");
|
||||
|
||||
// set to a different format and expect a different result in the table
|
||||
cy.visit("/settings/general");
|
||||
cy.getByTestId("DateFormatSelect").click();
|
||||
cy.getByTestId("DateFormatSelect:MM/DD/YY").click();
|
||||
cy.getByTestId("OrganizationSettingsSaveButton").click();
|
||||
|
||||
cy.visit(`/queries/${queryId}`);
|
||||
|
||||
cy.getByTestId("TableVisualization")
|
||||
.findAllByText(/\d{2}\/\d{2}\/\d{2}/)
|
||||
.should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
110
client/cypress/integration/visualizations/chart_spec.js
Normal file
110
client/cypress/integration/visualizations/chart_spec.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/* global cy */
|
||||
|
||||
import { getWidgetTestId } from "../../support/dashboard";
|
||||
import {
|
||||
assertAxesAndAddLabels,
|
||||
assertPlotPreview,
|
||||
assertTabbedEditor,
|
||||
createChartThroughUI,
|
||||
createDashboardWithCharts,
|
||||
} from "../../support/visualizations/chart";
|
||||
|
||||
const SQL = `
|
||||
SELECT 'a' AS stage, 11 AS value1, 22 AS value2 UNION ALL
|
||||
SELECT 'a' AS stage, 12 AS value1, 41 AS value2 UNION ALL
|
||||
SELECT 'a' AS stage, 45 AS value1, 93 AS value2 UNION ALL
|
||||
SELECT 'a' AS stage, 54 AS value1, 79 AS value2 UNION ALL
|
||||
SELECT 'b' AS stage, 33 AS value1, 65 AS value2 UNION ALL
|
||||
SELECT 'b' AS stage, 73 AS value1, 50 AS value2 UNION ALL
|
||||
SELECT 'b' AS stage, 90 AS value1, 40 AS value2 UNION ALL
|
||||
SELECT 'c' AS stage, 19 AS value1, 33 AS value2 UNION ALL
|
||||
SELECT 'c' AS stage, 92 AS value1, 14 AS value2 UNION ALL
|
||||
SELECT 'c' AS stage, 63 AS value1, 65 AS value2 UNION ALL
|
||||
SELECT 'c' AS stage, 44 AS value1, 27 AS value2\
|
||||
`;
|
||||
|
||||
describe("Chart", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
cy.createQuery({ name: "Chart Visualization", query: SQL })
|
||||
.its("id")
|
||||
.as("queryId");
|
||||
});
|
||||
|
||||
it("creates Bar charts", function() {
|
||||
cy.visit(`queries/${this.queryId}/source`);
|
||||
cy.getByTestId("ExecuteButton").click();
|
||||
|
||||
const getBarChartAssertionFunction = (specificBarChartAssertionFn = () => {}) => () => {
|
||||
// checks for TabbedEditor standard tabs
|
||||
assertTabbedEditor();
|
||||
|
||||
// standard chart should be bar
|
||||
cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar");
|
||||
|
||||
// checks the plot canvas exists and is empty
|
||||
assertPlotPreview("not.exist");
|
||||
|
||||
// creates a chart and checks it is plotted
|
||||
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
|
||||
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
|
||||
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2");
|
||||
assertPlotPreview("exist");
|
||||
|
||||
specificBarChartAssertionFn();
|
||||
};
|
||||
|
||||
const chartTests = [
|
||||
{
|
||||
name: "Basic Bar Chart",
|
||||
alias: "basicBarChart",
|
||||
assertionFn: () => {
|
||||
assertAxesAndAddLabels("Stage", "Value");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Horizontal Bar Chart",
|
||||
alias: "horizontalBarChart",
|
||||
assertionFn: () => {
|
||||
cy.getByTestId("Chart.SwappedAxes").check();
|
||||
cy.getByTestId("VisualizationEditor.Tabs.XAxis").should("have.text", "Y Axis");
|
||||
cy.getByTestId("VisualizationEditor.Tabs.YAxis").should("have.text", "X Axis");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Stacked Bar Chart",
|
||||
alias: "stackedBarChart",
|
||||
assertionFn: () => {
|
||||
cy.getByTestId("Chart.Stacking").selectAntdOption("Chart.Stacking.Stack");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Normalized Bar Chart",
|
||||
alias: "normalizedBarChart",
|
||||
assertionFn: () => {
|
||||
cy.getByTestId("Chart.NormalizeValues").check();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
chartTests.forEach(({ name, alias, assertionFn }) => {
|
||||
createChartThroughUI(name, getBarChartAssertionFunction(assertionFn)).as(alias);
|
||||
});
|
||||
|
||||
const chartGetters = chartTests.map(({ alias }) => alias);
|
||||
|
||||
const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => {
|
||||
cy.visit(dashboardUrl);
|
||||
widgetGetters.forEach(widgetGetter => {
|
||||
cy.get(`@${widgetGetter}`).then(widget => {
|
||||
cy.getByTestId(getWidgetTestId(widget)).within(() => {
|
||||
cy.get("g.points").should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn);
|
||||
cy.percySnapshot("Visualizations - Charts - Bar");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import "@percy/cypress"; // eslint-disable-line import/no-extraneous-dependencies, import/no-unresolved
|
||||
|
||||
import "@testing-library/cypress/add-commands";
|
||||
|
||||
const { each } = Cypress._;
|
||||
|
||||
Cypress.Commands.add("login", (email = "admin@redash.io", password = "password") => {
|
||||
|
||||
13
client/cypress/support/parameters.js
Normal file
13
client/cypress/support/parameters.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export function dragParam(paramName, offsetLeft, offsetTop) {
|
||||
cy.getByTestId(`DragHandle-${paramName}`)
|
||||
.trigger("mouseover")
|
||||
.trigger("mousedown");
|
||||
|
||||
cy.get(".parameter-dragged .drag-handle")
|
||||
.trigger("mousemove", offsetLeft, offsetTop, { force: true })
|
||||
.trigger("mouseup", { force: true });
|
||||
}
|
||||
|
||||
export function expectParamOrder(expectedOrder) {
|
||||
cy.get(".parameter-container label").each(($label, index) => expect($label).to.have.text(expectedOrder[index]));
|
||||
}
|
||||
100
client/cypress/support/visualizations/chart.js
Normal file
100
client/cypress/support/visualizations/chart.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Asserts the preview canvas exists, then captures the g.points element, which should be generated by plotly and asserts whether it exists
|
||||
* @param should Passed to should expression after plot points are captured
|
||||
*/
|
||||
export function assertPlotPreview(should = "exist") {
|
||||
cy.getByTestId("VisualizationPreview")
|
||||
.find("g.plot")
|
||||
.should("exist")
|
||||
.find("g.points")
|
||||
.should(should);
|
||||
}
|
||||
|
||||
export function createChartThroughUI(chartName, chartSpecificAssertionFn = () => {}) {
|
||||
cy.getByTestId("NewVisualization").click();
|
||||
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.CHART");
|
||||
cy.getByTestId("VisualizationName")
|
||||
.clear()
|
||||
.type(chartName);
|
||||
|
||||
chartSpecificAssertionFn();
|
||||
|
||||
cy.server();
|
||||
cy.route("POST", "**/api/visualizations").as("SaveVisualization");
|
||||
|
||||
cy.getByTestId("EditVisualizationDialog")
|
||||
.contains("button", "Save")
|
||||
.click();
|
||||
|
||||
cy.getByTestId("QueryPageVisualizationTabs")
|
||||
.contains("span", chartName)
|
||||
.should("exist");
|
||||
|
||||
cy.wait("@SaveVisualization").should("have.property", "status", 200);
|
||||
|
||||
return cy.get("@SaveVisualization").then(xhr => {
|
||||
const { id, name, options } = xhr.response.body;
|
||||
return cy.wrap({ id, name, options });
|
||||
});
|
||||
}
|
||||
|
||||
export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () => {}) {
|
||||
cy.getByTestId("Chart.GlobalSeriesType").should("exist");
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.Series").click();
|
||||
cy.getByTestId("VisualizationEditor")
|
||||
.find("table")
|
||||
.should("exist");
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
|
||||
cy.getByTestId("VisualizationEditor")
|
||||
.find("table")
|
||||
.should("exist");
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.DataLabels").click();
|
||||
cy.getByTestId("VisualizationEditor")
|
||||
.getByTestId("Chart.DataLabels.ShowDataLabels")
|
||||
.should("exist");
|
||||
|
||||
chartSpecificTabbedEditorAssertionFn();
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.General").click();
|
||||
}
|
||||
|
||||
export function assertAxesAndAddLabels(xaxisLabel, yaxisLabel) {
|
||||
cy.getByTestId("VisualizationEditor.Tabs.XAxis").click();
|
||||
cy.getByTestId("Chart.XAxis.Type")
|
||||
.contains(".ant-select-selection-item", "Auto Detect")
|
||||
.should("exist");
|
||||
|
||||
cy.getByTestId("Chart.XAxis.Name")
|
||||
.clear()
|
||||
.type(xaxisLabel);
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.YAxis").click();
|
||||
cy.getByTestId("Chart.LeftYAxis.Type")
|
||||
.contains(".ant-select-selection-item", "Linear")
|
||||
.should("exist");
|
||||
|
||||
cy.getByTestId("Chart.LeftYAxis.Name")
|
||||
.clear()
|
||||
.type(yaxisLabel);
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.General").click();
|
||||
}
|
||||
|
||||
export function createDashboardWithCharts(title, chartGetters, widgetsAssertionFn = () => {}) {
|
||||
cy.createDashboard(title).then(dashboard => {
|
||||
const dashboardUrl = `/dashboards/${dashboard.id}`;
|
||||
const widgetGetters = chartGetters.map(chartGetter => `${chartGetter}Widget`);
|
||||
|
||||
chartGetters.forEach((chartGetter, i) => {
|
||||
const position = { autoHeight: false, sizeY: 8, sizeX: 3, col: (i % 2) * 3 };
|
||||
cy.get(`@${chartGetter}`)
|
||||
.then(chart => cy.addWidget(dashboard.id, chart.id, { position }))
|
||||
.as(widgetGetters[i]);
|
||||
});
|
||||
|
||||
widgetsAssertionFn(widgetGetters, dashboardUrl);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["cypress","@percy/cypress","@testing-library/cypress"]
|
||||
"types": ["cypress", "@percy/cypress", "@testing-library/cypress"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -15,21 +15,14 @@
|
||||
"jsx": "react",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["./app/*"]
|
||||
}
|
||||
},
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"app/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
"include": ["app/**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
|
||||
28
migrations/versions/0ec979123ba4_.py
Normal file
28
migrations/versions/0ec979123ba4_.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 0ec979123ba4
|
||||
Revises: e5c7a4e2df4d
|
||||
Create Date: 2020-12-23 21:35:32.766354
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0ec979123ba4'
|
||||
down_revision = 'e5c7a4e2df4d'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('dashboards', sa.Column('options', postgresql.JSON(astext_type=sa.Text()), server_default='{}', nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('dashboards', 'options')
|
||||
# ### end Alembic commands ###
|
||||
24
migrations/versions/89bc7873a3e0_fix_multiple_heads.py
Normal file
24
migrations/versions/89bc7873a3e0_fix_multiple_heads.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""fix_multiple_heads
|
||||
|
||||
Revision ID: 89bc7873a3e0
|
||||
Revises: 0ec979123ba4, d7d747033183
|
||||
Create Date: 2021-01-21 18:11:04.312259
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '89bc7873a3e0'
|
||||
down_revision = ('0ec979123ba4', 'd7d747033183')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@@ -0,0 +1,64 @@
|
||||
"""encrypt alert destinations
|
||||
|
||||
Revision ID: d7d747033183
|
||||
Revises: e5c7a4e2df4d
|
||||
Create Date: 2020-12-14 21:42:48.661684
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.sql import table
|
||||
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
|
||||
|
||||
from redash import settings
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
from redash.models.base import key_type
|
||||
from redash.models.types import (
|
||||
EncryptedConfiguration,
|
||||
Configuration,
|
||||
)
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd7d747033183'
|
||||
down_revision = 'e5c7a4e2df4d'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"notification_destinations",
|
||||
sa.Column("encrypted_options", postgresql.BYTEA(), nullable=True)
|
||||
)
|
||||
|
||||
# copy values
|
||||
notification_destinations = table(
|
||||
"notification_destinations",
|
||||
sa.Column("id", key_type("NotificationDestination"), primary_key=True),
|
||||
sa.Column(
|
||||
"encrypted_options",
|
||||
ConfigurationContainer.as_mutable(
|
||||
EncryptedConfiguration(
|
||||
sa.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine
|
||||
)
|
||||
),
|
||||
),
|
||||
sa.Column("options", ConfigurationContainer.as_mutable(Configuration)),
|
||||
)
|
||||
|
||||
conn = op.get_bind()
|
||||
for dest in conn.execute(notification_destinations.select()):
|
||||
conn.execute(
|
||||
notification_destinations.update()
|
||||
.where(notification_destinations.c.id == dest.id)
|
||||
.values(encrypted_options=dest.options)
|
||||
)
|
||||
|
||||
op.drop_column("notification_destinations", "options")
|
||||
op.alter_column("notification_destinations", "encrypted_options", nullable=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
411
package-lock.json
generated
411
package-lock.json
generated
@@ -3982,6 +3982,64 @@
|
||||
"integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==",
|
||||
"dev": true
|
||||
},
|
||||
"@jest/types": {
|
||||
"version": "26.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",
|
||||
"integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^3.0.0",
|
||||
"@types/node": "*",
|
||||
"@types/yargs": "^15.0.0",
|
||||
"chalk": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
|
||||
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-name": "~1.1.4"
|
||||
}
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"has-flag": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@mapbox/geojson-area": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz",
|
||||
@@ -4709,6 +4767,35 @@
|
||||
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
|
||||
"dev": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz",
|
||||
"integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"follow-redirects": "1.5.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
|
||||
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"follow-redirects": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
|
||||
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "=3.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
|
||||
@@ -4780,6 +4867,12 @@
|
||||
"esprima": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
|
||||
"dev": true
|
||||
},
|
||||
"path-type": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
@@ -4988,6 +5081,199 @@
|
||||
"any-observable": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"@testing-library/cypress": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-7.0.2.tgz",
|
||||
"integrity": "sha512-avBCbcCMPFHvWg4BBCA/pIh3MCwcKoJ+rc3XyZdKC5mFYPV7edjW3s4KZx/x3klXuttyuM/UesRhbSj4l5aQbw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@testing-library/dom": "^7.26.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz",
|
||||
"integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.7",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
|
||||
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@testing-library/dom": {
|
||||
"version": "7.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.28.1.tgz",
|
||||
"integrity": "sha512-acv3l6kDwZkQif/YqJjstT3ks5aaI33uxGNVIQmdKzbZ2eMKgg3EV2tB84GDdc72k3Kjhl6mO8yUt6StVIdRDg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^4.2.0",
|
||||
"aria-query": "^4.2.2",
|
||||
"chalk": "^4.1.0",
|
||||
"dom-accessibility-api": "^0.5.4",
|
||||
"lz-string": "^1.4.4",
|
||||
"pretty-format": "^26.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
|
||||
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.10.4"
|
||||
}
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz",
|
||||
"integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.10.4",
|
||||
"chalk": "^2.0.0",
|
||||
"js-tokens": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^3.2.1",
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
"supports-color": "^5.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/runtime": {
|
||||
"version": "7.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz",
|
||||
"integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"@babel/runtime-corejs3": {
|
||||
"version": "7.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.12.5.tgz",
|
||||
"integrity": "sha512-roGr54CsTmNPPzZoCP1AmDXuBoNao7tnSA83TXTwt+UK5QVyh1DIJnrgYRPWKCF2flqZQXwa7Yr8v7VmLzF0YQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"core-js-pure": "^3.0.0",
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
|
||||
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
|
||||
"dev": true
|
||||
},
|
||||
"aria-query": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz",
|
||||
"integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.10.2",
|
||||
"@babel/runtime-corejs3": "^7.10.2"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
|
||||
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"has-flag": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-name": "~1.1.4"
|
||||
}
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "26.6.2",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz",
|
||||
"integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@jest/types": "^26.6.2",
|
||||
"ansi-regex": "^5.0.0",
|
||||
"ansi-styles": "^4.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "17.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz",
|
||||
"integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==",
|
||||
"dev": true
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.7",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
|
||||
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@turf/area": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@turf/area/-/area-6.0.1.tgz",
|
||||
@@ -5028,6 +5314,12 @@
|
||||
"@turf/helpers": "6.x"
|
||||
}
|
||||
},
|
||||
"@types/aria-query": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz",
|
||||
"integrity": "sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/classnames": {
|
||||
"version": "2.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.10.tgz",
|
||||
@@ -5066,6 +5358,30 @@
|
||||
"hoist-non-react-statics": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz",
|
||||
"integrity": "sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/istanbul-lib-report": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
|
||||
"integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "*"
|
||||
}
|
||||
},
|
||||
"@types/istanbul-reports": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz",
|
||||
"integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/istanbul-lib-report": "*"
|
||||
}
|
||||
},
|
||||
"@types/json-schema": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.3.tgz",
|
||||
@@ -5103,22 +5419,22 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/react": {
|
||||
"version": "16.9.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.41.tgz",
|
||||
"integrity": "sha512-6cFei7F7L4wwuM+IND/Q2cV1koQUvJ8iSV+Gwn0c3kvABZ691g7sp3hfEQHOUBJtccl1gPi+EyNjMIl9nGA0ug==",
|
||||
"version": "16.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.2.tgz",
|
||||
"integrity": "sha512-BzzcAlyDxXl2nANlabtT4thtvbbnhee8hMmH/CcJrISDBVcJS1iOsP1f0OAgSdGE0MsY9tqcrb9YoZcOFv9dbQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^2.2.0"
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"version": "16.9.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.8.tgz",
|
||||
"integrity": "sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==",
|
||||
"version": "16.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.10.tgz",
|
||||
"integrity": "sha512-ItatOrnXDMAYpv6G8UCk2VhbYVTjZT9aorLtA/OzDN9XJ2GKcfam68jutoAcILdRjsRUO8qb7AmyObF77Q8QFw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
"@types/react": "^16"
|
||||
}
|
||||
},
|
||||
"@types/sinonjs__fake-timers": {
|
||||
@@ -5139,6 +5455,21 @@
|
||||
"integrity": "sha512-Xh9kEOaKWhm3vYD5lUjYFFiSfpN4y3/iQCJUAVwFaQ1rVvHs4WXTa5C8E7gyF3kxwsMS8KgttW7WBAPtFlsvAg==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/yargs": {
|
||||
"version": "15.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.11.tgz",
|
||||
"integrity": "sha512-jfcNBxHFYJ4nPIacsi3woz1+kvUO6s1CyeEhtnDHBjHUMNj5UlW2GynmnSgiJJEdNg9yW5C8lfoNRZrHGv5EqA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
},
|
||||
"@types/yargs-parser": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz",
|
||||
"integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==",
|
||||
"dev": true
|
||||
},
|
||||
"@typescript-eslint/eslint-plugin": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.10.0.tgz",
|
||||
@@ -6098,40 +6429,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz",
|
||||
"integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==",
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
||||
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
|
||||
"requires": {
|
||||
"follow-redirects": "1.5.10",
|
||||
"is-buffer": "^2.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
|
||||
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
|
||||
"requires": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"follow-redirects": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
|
||||
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
|
||||
"requires": {
|
||||
"debug": "=3.1.0"
|
||||
}
|
||||
},
|
||||
"is-buffer": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz",
|
||||
"integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A=="
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
||||
}
|
||||
"follow-redirects": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"axios-auth-refresh": {
|
||||
@@ -9152,9 +9454,9 @@
|
||||
}
|
||||
},
|
||||
"csstype": {
|
||||
"version": "2.6.11",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.11.tgz",
|
||||
"integrity": "sha512-l8YyEC9NBkSm783PFTvh0FmJy7s5pFKrDp49ZL7zBGX3fWkO+N4EEyan1qqp8cwPLDcD0OSdyY6hAMoxp34JFw==",
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.5.tgz",
|
||||
"integrity": "sha512-uVDi8LpBUKQj6sdxNaTetL6FpeCqTjOvAQuQUa/qAqq8oOd4ivkbhgnqayl0dnPal8Tb/yB1tF+gOvCBiicaiQ==",
|
||||
"dev": true
|
||||
},
|
||||
"cubic-hermite": {
|
||||
@@ -9999,6 +10301,12 @@
|
||||
"esutils": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"dom-accessibility-api": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz",
|
||||
"integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==",
|
||||
"dev": true
|
||||
},
|
||||
"dom-align": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.0.tgz",
|
||||
@@ -12249,8 +12557,7 @@
|
||||
"follow-redirects": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz",
|
||||
"integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA=="
|
||||
},
|
||||
"font-atlas": {
|
||||
"version": "2.1.0",
|
||||
@@ -16992,6 +17299,12 @@
|
||||
"yallist": "^2.1.2"
|
||||
}
|
||||
},
|
||||
"lz-string": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
|
||||
"integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=",
|
||||
"dev": true
|
||||
},
|
||||
"magic-string": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
|
||||
@@ -25139,9 +25452,9 @@
|
||||
}
|
||||
},
|
||||
"typescript": {
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.6.tgz",
|
||||
"integrity": "sha512-Pspx3oKAPJtjNwE92YS05HQoY7z2SFyOpHo9MqJor3BXAGNaPUs83CuVp9VISFkSjyRfiTpmKuAYGJB7S7hOxw==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.2.tgz",
|
||||
"integrity": "sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ==",
|
||||
"dev": true
|
||||
},
|
||||
"uglify-js": {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"@redash/viz": "file:viz-lib",
|
||||
"ace-builds": "^1.4.12",
|
||||
"antd": "^4.4.3",
|
||||
"axios": "^0.19.0",
|
||||
"axios": "^0.21.1",
|
||||
"axios-auth-refresh": "^3.0.0",
|
||||
"bootstrap": "^3.3.7",
|
||||
"classnames": "^2.2.6",
|
||||
@@ -90,12 +90,13 @@
|
||||
"@percy/agent": "0.24.3",
|
||||
"@percy/cypress": "^2.3.2",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
|
||||
"@testing-library/cypress": "^7.0.2",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/hoist-non-react-statics": "^3.3.1",
|
||||
"@types/lodash": "^4.14.157",
|
||||
"@types/prop-types": "^15.7.3",
|
||||
"@types/react": "^16.9.41",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react": "^16.14.2",
|
||||
"@types/react-dom": "^16.9.10",
|
||||
"@types/sql-formatter": "^2.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^2.10.0",
|
||||
"@typescript-eslint/parser": "^2.10.0",
|
||||
@@ -143,7 +144,7 @@
|
||||
"request": "^2.88.0",
|
||||
"request-cookies": "^1.1.0",
|
||||
"style-loader": "^2.0.0",
|
||||
"typescript": "^3.9.6",
|
||||
"typescript": "^4.1.2",
|
||||
"url-loader": "^1.1.2",
|
||||
"webpack": "^4.44.2",
|
||||
"webpack-build-notifier": "^0.1.30",
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.sql import select
|
||||
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
|
||||
|
||||
from redash import settings
|
||||
from redash.models.base import Column
|
||||
from redash.models.base import Column, key_type
|
||||
from redash.models.types import EncryptedConfiguration
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
|
||||
@@ -27,7 +27,7 @@ def _wait_for_db_connection(db):
|
||||
|
||||
retried = True
|
||||
|
||||
|
||||
|
||||
def is_db_empty():
|
||||
from redash.models import db
|
||||
|
||||
@@ -86,36 +86,40 @@ def reencrypt(old_secret, new_secret, show_sql):
|
||||
logging.basicConfig()
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
|
||||
|
||||
table_for_select = sqlalchemy.Table(
|
||||
"data_sources",
|
||||
sqlalchemy.MetaData(),
|
||||
Column("id", db.Integer, primary_key=True),
|
||||
Column(
|
||||
"encrypted_options",
|
||||
ConfigurationContainer.as_mutable(
|
||||
EncryptedConfiguration(db.Text, old_secret, FernetEngine)
|
||||
def _reencrypt_for_table(table_name, orm_name):
|
||||
table_for_select = sqlalchemy.Table(
|
||||
table_name,
|
||||
sqlalchemy.MetaData(),
|
||||
Column("id", key_type(orm_name), primary_key=True),
|
||||
Column(
|
||||
"encrypted_options",
|
||||
ConfigurationContainer.as_mutable(
|
||||
EncryptedConfiguration(db.Text, old_secret, FernetEngine)
|
||||
),
|
||||
),
|
||||
)
|
||||
table_for_update = sqlalchemy.Table(
|
||||
table_name,
|
||||
sqlalchemy.MetaData(),
|
||||
Column("id", key_type(orm_name), primary_key=True),
|
||||
Column(
|
||||
"encrypted_options",
|
||||
ConfigurationContainer.as_mutable(
|
||||
EncryptedConfiguration(db.Text, new_secret, FernetEngine)
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
table_for_update = sqlalchemy.Table(
|
||||
"data_sources",
|
||||
sqlalchemy.MetaData(),
|
||||
Column("id", db.Integer, primary_key=True),
|
||||
Column(
|
||||
"encrypted_options",
|
||||
ConfigurationContainer.as_mutable(
|
||||
EncryptedConfiguration(db.Text, new_secret, FernetEngine)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
update = table_for_update.update()
|
||||
data_sources = db.session.execute(select([table_for_select]))
|
||||
for ds in data_sources:
|
||||
stmt = update.where(table_for_update.c.id == ds["id"]).values(
|
||||
encrypted_options=ds["encrypted_options"]
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
data_sources.close()
|
||||
db.session.commit()
|
||||
update = table_for_update.update()
|
||||
selected_items = db.session.execute(select([table_for_select]))
|
||||
for item in selected_items:
|
||||
stmt = update.where(table_for_update.c.id == item["id"]).values(
|
||||
encrypted_options=item["encrypted_options"]
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
selected_items.close()
|
||||
db.session.commit()
|
||||
|
||||
_reencrypt_for_table("data_sources", "DataSource")
|
||||
_reencrypt_for_table("notification_destinations", "NotificationDestination")
|
||||
|
||||
@@ -50,30 +50,22 @@ def worker(queues):
|
||||
|
||||
|
||||
class WorkerHealthcheck(base.BaseCheck):
|
||||
NAME = 'RQ Worker Healthcheck'
|
||||
INTERVAL = datetime.timedelta(minutes=5)
|
||||
_last_check_time = {}
|
||||
|
||||
def time_to_check(self, pid):
|
||||
now = datetime.datetime.utcnow()
|
||||
|
||||
if pid not in self._last_check_time:
|
||||
self._last_check_time[pid] = now
|
||||
|
||||
if now - self._last_check_time[pid] >= self.INTERVAL:
|
||||
self._last_check_time[pid] = now
|
||||
return True
|
||||
|
||||
return False
|
||||
NAME = "RQ Worker Healthcheck"
|
||||
|
||||
def __call__(self, process_spec):
|
||||
pid = process_spec['pid']
|
||||
if not self.time_to_check(pid):
|
||||
return True
|
||||
|
||||
pid = process_spec["pid"]
|
||||
all_workers = Worker.all(connection=rq_redis_connection)
|
||||
worker = [w for w in all_workers if w.hostname == socket.gethostname().encode() and
|
||||
w.pid == pid].pop()
|
||||
workers = [
|
||||
w
|
||||
for w in all_workers
|
||||
if w.hostname == socket.gethostname() and w.pid == pid
|
||||
]
|
||||
|
||||
if not workers:
|
||||
self._log(f"Cannot find worker for hostname {socket.gethostname()} and pid {pid}. ==> Is healthy? False")
|
||||
return False
|
||||
|
||||
worker = workers.pop()
|
||||
|
||||
is_busy = worker.get_state() == WorkerStatus.BUSY
|
||||
|
||||
@@ -85,12 +77,19 @@ class WorkerHealthcheck(base.BaseCheck):
|
||||
|
||||
is_healthy = is_busy or seen_lately or has_nothing_to_do
|
||||
|
||||
self._log("Worker %s healthcheck: Is busy? %s. "
|
||||
"Seen lately? %s (%d seconds ago). "
|
||||
"Has nothing to do? %s (%d jobs in watched queues). "
|
||||
"==> Is healthy? %s",
|
||||
worker.key, is_busy, seen_lately, time_since_seen.seconds,
|
||||
has_nothing_to_do, total_jobs_in_watched_queues, is_healthy)
|
||||
self._log(
|
||||
"Worker %s healthcheck: Is busy? %s. "
|
||||
"Seen lately? %s (%d seconds ago). "
|
||||
"Has nothing to do? %s (%d jobs in watched queues). "
|
||||
"==> Is healthy? %s",
|
||||
worker.key,
|
||||
is_busy,
|
||||
seen_lately,
|
||||
time_since_seen.seconds,
|
||||
has_nothing_to_do,
|
||||
total_jobs_in_watched_queues,
|
||||
is_healthy,
|
||||
)
|
||||
|
||||
return is_healthy
|
||||
|
||||
@@ -98,4 +97,5 @@ class WorkerHealthcheck(base.BaseCheck):
|
||||
@manager.command()
|
||||
def healthcheck():
|
||||
return check_runner.CheckRunner(
|
||||
'worker_healthcheck', 'worker', None, [(WorkerHealthcheck, {})]).run()
|
||||
"worker_healthcheck", "worker", None, [(WorkerHealthcheck, {})]
|
||||
).run()
|
||||
|
||||
@@ -22,6 +22,7 @@ class ChatWork(BaseDestination):
|
||||
"title": "Message Template",
|
||||
},
|
||||
},
|
||||
"secret": ["api_token"],
|
||||
"required": ["message_template", "api_token", "room_id"],
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class HangoutsChat(BaseDestination):
|
||||
"title": "Icon URL (32x32 or multiple, png format)",
|
||||
},
|
||||
},
|
||||
"secret": ["url"],
|
||||
"required": ["url"],
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class HipChat(BaseDestination):
|
||||
"title": "HipChat Notification URL (get it from the Integrations page)",
|
||||
}
|
||||
},
|
||||
"secret": ["url"],
|
||||
"required": ["url"],
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class Mattermost(BaseDestination):
|
||||
"icon_url": {"type": "string", "title": "Icon (URL)"},
|
||||
"channel": {"type": "string", "title": "Channel"},
|
||||
},
|
||||
"secret": "url"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -32,6 +32,7 @@ class PagerDuty(BaseDestination):
|
||||
"title": "Description for the event, defaults to alert name",
|
||||
},
|
||||
},
|
||||
"secret": ["integration_key"],
|
||||
"required": ["integration_key"],
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ class Slack(BaseDestination):
|
||||
"icon_url": {"type": "string", "title": "Icon (URL)"},
|
||||
"channel": {"type": "string", "title": "Channel"},
|
||||
},
|
||||
"secret": ["url"]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -18,7 +18,7 @@ class Webhook(BaseDestination):
|
||||
"password": {"type": "string"},
|
||||
},
|
||||
"required": ["url"],
|
||||
"secret": ["password"],
|
||||
"secret": ["password", "url"],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -11,6 +11,7 @@ from redash.handlers.alerts import (
|
||||
)
|
||||
from redash.handlers.base import org_scoped_rule
|
||||
from redash.handlers.dashboards import (
|
||||
MyDashboardsResource,
|
||||
DashboardFavoriteListResource,
|
||||
DashboardListResource,
|
||||
DashboardResource,
|
||||
@@ -209,6 +210,8 @@ api.add_org_resource(
|
||||
endpoint="dashboard_favorite",
|
||||
)
|
||||
|
||||
api.add_org_resource(MyDashboardsResource, "/api/dashboards/my", endpoint="my_dashboards")
|
||||
|
||||
api.add_org_resource(QueryTagsResource, "/api/queries/tags", endpoint="query_tags")
|
||||
api.add_org_resource(
|
||||
DashboardTagsResource, "/api/dashboards/tags", endpoint="dashboard_tags"
|
||||
|
||||
@@ -113,6 +113,43 @@ class DashboardListResource(BaseResource):
|
||||
return DashboardSerializer(dashboard).serialize()
|
||||
|
||||
|
||||
class MyDashboardsResource(BaseResource):
|
||||
@require_permission("list_dashboards")
|
||||
def get(self):
|
||||
"""
|
||||
Retrieve a list of dashboards created by the current user.
|
||||
|
||||
:qparam number page_size: Number of dashboards to return per page
|
||||
:qparam number page: Page number to retrieve
|
||||
:qparam number order: Name of column to order by
|
||||
:qparam number search: Full text search term
|
||||
|
||||
Responds with an array of :ref:`dashboard <dashboard-response-label>`
|
||||
objects.
|
||||
"""
|
||||
search_term = request.args.get("q", "")
|
||||
if search_term:
|
||||
results = models.Dashboard.search_by_user(search_term, self.current_user)
|
||||
else:
|
||||
results = models.Dashboard.by_user(self.current_user)
|
||||
|
||||
results = filter_by_tags(results, models.Dashboard.tags)
|
||||
|
||||
# order results according to passed order parameter,
|
||||
# special-casing search queries where the database
|
||||
# provides an order by search rank
|
||||
ordered_results = order_results(results, fallback=not bool(search_term))
|
||||
|
||||
page = request.args.get("page", 1, type=int)
|
||||
page_size = request.args.get("page_size", 25, type=int)
|
||||
return paginate(
|
||||
ordered_results,
|
||||
page,
|
||||
page_size,
|
||||
DashboardSerializer
|
||||
)
|
||||
|
||||
|
||||
class DashboardResource(BaseResource):
|
||||
@require_permission("list_dashboards")
|
||||
def get(self, dashboard_id=None):
|
||||
@@ -135,6 +172,7 @@ class DashboardResource(BaseResource):
|
||||
:>json boolean is_draft: Whether this dashboard is a draft or not.
|
||||
:>json array layout: Array of arrays containing widget IDs, corresponding to the rows and columns the widgets are displayed in
|
||||
:>json array widgets: Array of arrays containing :ref:`widget <widget-response-label>` data
|
||||
:>json object options: Dashboard options
|
||||
|
||||
.. _widget-response-label:
|
||||
|
||||
@@ -205,6 +243,7 @@ class DashboardResource(BaseResource):
|
||||
"is_draft",
|
||||
"is_archived",
|
||||
"dashboard_filters_enabled",
|
||||
"options",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1099,6 +1099,9 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
|
||||
tags = Column(
|
||||
"tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True
|
||||
)
|
||||
options = Column(
|
||||
MutableDict.as_mutable(postgresql.JSON), server_default="{}", default={}
|
||||
)
|
||||
|
||||
__tablename__ = "dashboards"
|
||||
__mapper_args__ = {"version_id_col": version}
|
||||
@@ -1132,7 +1135,6 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
|
||||
),
|
||||
Dashboard.org == org,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
query = query.filter(
|
||||
@@ -1148,6 +1150,10 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
|
||||
cls.name.ilike("%{}%".format(search_term))
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def search_by_user(cls, term, user, limit=None):
|
||||
return cls.by_user(user).filter(cls.name.ilike("%{}%".format(term))).limit(limit)
|
||||
|
||||
@classmethod
|
||||
def all_tags(cls, org, user):
|
||||
dashboards = cls.all(org, user.group_ids, user.id)
|
||||
@@ -1177,6 +1183,10 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
|
||||
)
|
||||
).filter(Favorite.user_id == user.id)
|
||||
|
||||
@classmethod
|
||||
def by_user(cls, user):
|
||||
return cls.all(user.org, user.group_ids, user.id).filter(Dashboard.user == user)
|
||||
|
||||
@classmethod
|
||||
def get_by_slug_and_org(cls, slug, org):
|
||||
return cls.query.filter(cls.slug == slug, cls.org == org).one()
|
||||
@@ -1351,7 +1361,14 @@ class NotificationDestination(BelongsToOrgMixin, db.Model):
|
||||
user = db.relationship(User, backref="notification_destinations")
|
||||
name = Column(db.String(255))
|
||||
type = Column(db.String(255))
|
||||
options = Column(ConfigurationContainer.as_mutable(Configuration))
|
||||
options = Column(
|
||||
"encrypted_options",
|
||||
ConfigurationContainer.as_mutable(
|
||||
EncryptedConfiguration(
|
||||
db.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine
|
||||
)
|
||||
),
|
||||
)
|
||||
created_at = Column(db.DateTime(True), default=db.func.now())
|
||||
|
||||
__tablename__ = "notification_destinations"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import sqlparse
|
||||
from redash.query_runner import (
|
||||
NotSupported,
|
||||
@@ -11,8 +13,9 @@ from redash.query_runner import (
|
||||
TYPE_INTEGER,
|
||||
TYPE_FLOAT,
|
||||
)
|
||||
from redash.settings import cast_int_or_default
|
||||
from redash.utils import json_dumps, json_loads
|
||||
from redash import __version__
|
||||
from redash import __version__, settings, statsd_client
|
||||
|
||||
try:
|
||||
import pyodbc
|
||||
@@ -30,6 +33,9 @@ TYPES_MAP = {
|
||||
float: TYPE_FLOAT,
|
||||
}
|
||||
|
||||
ROW_LIMIT = cast_int_or_default(os.environ.get("DATABRICKS_ROW_LIMIT"), 20000)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _build_odbc_connection_string(**kwargs):
|
||||
return ";".join([f"{k}={v}" for k, v in kwargs.items()])
|
||||
@@ -40,8 +46,10 @@ def split_sql_statements(query):
|
||||
idx = len(stmt.tokens) - 1
|
||||
while idx >= 0:
|
||||
tok = stmt.tokens[idx]
|
||||
if tok.is_whitespace or sqlparse.utils.imt(tok, i=sqlparse.sql.Comment, t=sqlparse.tokens.Comment):
|
||||
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, ' ')
|
||||
if tok.is_whitespace or sqlparse.utils.imt(
|
||||
tok, i=sqlparse.sql.Comment, t=sqlparse.tokens.Comment
|
||||
):
|
||||
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, " ")
|
||||
else:
|
||||
break
|
||||
idx -= 1
|
||||
@@ -53,8 +61,13 @@ def split_sql_statements(query):
|
||||
tok = stmt.tokens[idx]
|
||||
# we expect that trailing comments already are removed
|
||||
if not tok.is_whitespace:
|
||||
if sqlparse.utils.imt(tok, t=sqlparse.tokens.Punctuation) and tok.value == ";":
|
||||
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, ' ')
|
||||
if (
|
||||
sqlparse.utils.imt(tok, t=sqlparse.tokens.Punctuation)
|
||||
and tok.value == ";"
|
||||
):
|
||||
stmt.tokens[idx] = sqlparse.sql.Token(
|
||||
sqlparse.tokens.Whitespace, " "
|
||||
)
|
||||
break
|
||||
idx -= 1
|
||||
return stmt
|
||||
@@ -74,7 +87,11 @@ def split_sql_statements(query):
|
||||
result = [stmt for stmt in stack.run(query)]
|
||||
result = [strip_trailing_comments(stmt) for stmt in result]
|
||||
result = [strip_trailing_semicolon(stmt) for stmt in result]
|
||||
result = [sqlparse.text_type(stmt).strip() for stmt in result if not is_empty_statement(stmt)]
|
||||
result = [
|
||||
sqlparse.text_type(stmt).strip()
|
||||
for stmt in result
|
||||
if not is_empty_statement(stmt)
|
||||
]
|
||||
|
||||
if len(result) > 0:
|
||||
return result
|
||||
@@ -147,7 +164,7 @@ class Databricks(BaseSQLQueryRunner):
|
||||
cursor.execute(stmt)
|
||||
|
||||
if cursor.description is not None:
|
||||
data = cursor.fetchall()
|
||||
result_set = cursor.fetchmany(ROW_LIMIT)
|
||||
columns = self.fetch_columns(
|
||||
[
|
||||
(i[0], TYPES_MAP.get(i[1], TYPE_STRING))
|
||||
@@ -157,10 +174,18 @@ class Databricks(BaseSQLQueryRunner):
|
||||
|
||||
rows = [
|
||||
dict(zip((column["name"] for column in columns), row))
|
||||
for row in data
|
||||
for row in result_set
|
||||
]
|
||||
|
||||
data = {"columns": columns, "rows": rows}
|
||||
|
||||
if (
|
||||
len(result_set) >= ROW_LIMIT
|
||||
and cursor.fetchone() is not None
|
||||
):
|
||||
logger.warning("Truncated result set.")
|
||||
statsd_client.incr("redash.query_runner.databricks.truncated")
|
||||
data["truncated"] = True
|
||||
json_data = json_dumps(data)
|
||||
error = None
|
||||
else:
|
||||
|
||||
@@ -133,6 +133,8 @@ class MongoDB(BaseQueryRunner):
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connectionString": {"type": "string", "title": "Connection String"},
|
||||
"username": {"type": "string"},
|
||||
"password": {"type": "string"},
|
||||
"dbName": {"type": "string", "title": "Database Name"},
|
||||
"replicaSetName": {"type": "string", "title": "Replica Set Name"},
|
||||
"readPreference": {
|
||||
@@ -147,6 +149,7 @@ class MongoDB(BaseQueryRunner):
|
||||
"title": "Replica Set Read Preference",
|
||||
},
|
||||
},
|
||||
"secret": ["password"],
|
||||
"required": ["connectionString", "dbName"],
|
||||
}
|
||||
|
||||
@@ -176,6 +179,12 @@ class MongoDB(BaseQueryRunner):
|
||||
if readPreference:
|
||||
kwargs["readPreference"] = readPreference
|
||||
|
||||
if "username" in self.configuration:
|
||||
kwargs["username"] = self.configuration["username"]
|
||||
|
||||
if "password" in self.configuration:
|
||||
kwargs["password"] = self.configuration["password"]
|
||||
|
||||
db_connection = pymongo.MongoClient(
|
||||
self.configuration["connectionString"], **kwargs
|
||||
)
|
||||
|
||||
@@ -169,7 +169,7 @@ class PostgreSQL(BaseSQLQueryRunner):
|
||||
},
|
||||
"order": ["host", "port", "user", "password"],
|
||||
"required": ["dbname"],
|
||||
"secret": ["password"],
|
||||
"secret": ["password", "sslrootcertFile", "sslcertFile", "sslkeyFile"],
|
||||
"extra_options": [
|
||||
"sslmode",
|
||||
"sslrootcertFile",
|
||||
|
||||
@@ -53,6 +53,7 @@ class TreasureData(BaseQueryRunner):
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"secret": ["apikey"],
|
||||
"required": ["apikey", "db"],
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ class YandexMetrica(BaseSQLQueryRunner):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {"token": {"type": "string", "title": "OAuth Token"}},
|
||||
"secret": ["token"],
|
||||
"required": ["token"],
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def public_widget(widget):
|
||||
def public_dashboard(dashboard):
|
||||
dashboard_dict = project(
|
||||
serialize_dashboard(dashboard, with_favorite_state=False),
|
||||
("name", "layout", "dashboard_filters_enabled", "updated_at", "created_at"),
|
||||
("name", "layout", "dashboard_filters_enabled", "updated_at", "created_at", "options"),
|
||||
)
|
||||
|
||||
widget_list = (
|
||||
@@ -257,6 +257,7 @@ def serialize_dashboard(obj, with_widgets=False, user=None, with_favorite_state=
|
||||
"layout": layout,
|
||||
"dashboard_filters_enabled": obj.dashboard_filters_enabled,
|
||||
"widgets": widgets,
|
||||
"options": obj.options,
|
||||
"is_archived": obj.is_archived,
|
||||
"is_draft": obj.is_draft,
|
||||
"tags": obj.tags or [],
|
||||
|
||||
@@ -11,6 +11,7 @@ from .helpers import (
|
||||
int_or_none,
|
||||
set_from_string,
|
||||
add_decode_responses_to_redis_url,
|
||||
cast_int_or_default
|
||||
)
|
||||
from .organization import DATE_FORMAT, TIME_FORMAT # noqa
|
||||
|
||||
@@ -304,7 +305,7 @@ RATELIMIT_ENABLED = parse_boolean(os.environ.get("REDASH_RATELIMIT_ENABLED", "tr
|
||||
THROTTLE_LOGIN_PATTERN = os.environ.get("REDASH_THROTTLE_LOGIN_PATTERN", "50/hour")
|
||||
LIMITER_STORAGE = os.environ.get("REDASH_LIMITER_STORAGE", REDIS_URL)
|
||||
|
||||
# CORS settings for the Query Result API (and possbily future external APIs).
|
||||
# CORS settings for the Query Result API (and possibly future external APIs).
|
||||
# In most cases all you need to do is set REDASH_CORS_ACCESS_CONTROL_ALLOW_ORIGIN
|
||||
# to the calling domain (or domains in a comma separated list).
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN = set_from_string(
|
||||
@@ -511,4 +512,6 @@ ENFORCE_CSRF = parse_boolean(
|
||||
os.environ.get("REDASH_ENFORCE_CSRF", "false")
|
||||
)
|
||||
|
||||
# Databricks
|
||||
|
||||
CSRF_TIME_LIMIT = int(os.environ.get("REDASH_CSRF_TIME_LIMIT", 3600 * 6))
|
||||
|
||||
@@ -60,4 +60,15 @@ def database_key_definitions(default):
|
||||
|
||||
# Since you can define custom primary key types using `database_key_definitions`, you may want to load certain extensions when creating the database.
|
||||
# To do so, simply add the name of the extension you'd like to load to this list.
|
||||
database_extensions = []
|
||||
database_extensions = []
|
||||
|
||||
|
||||
# If you'd like to limit the amount of concurrent query executions made by a certain org or user,
|
||||
# implement this method by returning a boolean which would indicate if the limit has reached.
|
||||
# If you return `True`, the query execution would move to a waiting list and would only be executed
|
||||
# when a spot clears up for it within the defined capacity.
|
||||
# `entity` is either "user" or "org".
|
||||
# `executions` is the number of currently running query execution jobs for the specific user/org.
|
||||
# `meta` is the query execution job's meta attribute.
|
||||
def capacity_reached_for(entity, executions, meta):
|
||||
return False
|
||||
@@ -29,6 +29,11 @@ def parse_boolean(s):
|
||||
else:
|
||||
raise ValueError("Invalid boolean value %r" % s)
|
||||
|
||||
def cast_int_or_default(val, default=None):
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def int_or_none(value):
|
||||
if value is None:
|
||||
|
||||
@@ -3,7 +3,6 @@ from .general import (
|
||||
version_check,
|
||||
send_mail,
|
||||
sync_user_details,
|
||||
purge_failed_jobs,
|
||||
)
|
||||
from .queries import (
|
||||
enqueue_query,
|
||||
@@ -17,6 +16,7 @@ from .queries import (
|
||||
from .alerts import check_alerts_for_query
|
||||
from .failure_report import send_aggregated_errors
|
||||
from .worker import Worker, Queue, Job
|
||||
from .capacity import cleanup_waiting_lists
|
||||
from .schedule import rq_scheduler, schedule_periodic_jobs, periodic_job_definitions
|
||||
|
||||
from redash import rq_redis_connection
|
||||
|
||||
135
redash/tasks/capacity.py
Normal file
135
redash/tasks/capacity.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import re
|
||||
import itertools
|
||||
import logging
|
||||
from rq import Queue, Worker
|
||||
from rq.job import Job
|
||||
from redash import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cleanup_waiting_lists():
|
||||
"""
|
||||
When a job is enqueued/dequeued to/from a CapacityQueue and it exceeds the org/user capacity, it is entered into a waiting list.
|
||||
Later on, when a CapacityWorker finishes work on a job and a slot for a job on the waiting list becomes available, the worker will trigger the corresponding job
|
||||
on the waiting list and re-queue it back to the original queue.
|
||||
|
||||
However, if a (non-horse) worker dies in the middle of execution, it will not trigger the next item on the waiting list. If there is any other
|
||||
job for that org or user queued or executing, they will trigger those jobs eventually, but if no other jobs are queued or executing, the jobs
|
||||
on the waiting list may never execute.
|
||||
|
||||
This periodic task looks at all waiting lists and sees if there are no triggers for any of them. In case no triggers are found, we can assume that
|
||||
their worker died and re-enqueue them back into their original queues.
|
||||
|
||||
If a waiting list is empty, it can be deleted.
|
||||
"""
|
||||
queues = set(Queue.all())
|
||||
waiting_lists = set([q for q in queues if q.name.endswith(":waiting")])
|
||||
wip = itertools.chain(
|
||||
*[
|
||||
queue.started_job_registry.get_job_ids()
|
||||
for queue in (queues - waiting_lists)
|
||||
]
|
||||
)
|
||||
|
||||
for waiting_list in waiting_lists:
|
||||
trigger = next(
|
||||
(j for j in wip if waiting_list.name.split(":origin")[0] in j), None
|
||||
)
|
||||
|
||||
if trigger is None:
|
||||
if waiting_list.is_empty():
|
||||
logger.warning(
|
||||
f"Waiting list {waiting_list.name} is empty and will be deleted."
|
||||
)
|
||||
waiting_list.delete()
|
||||
else:
|
||||
origin_name = re.findall(r"origin:(.*?):", waiting_list.name)[0]
|
||||
logger.warning(
|
||||
f"Waiting list {waiting_list.name} has no executing job to trigger it. Returning all jobs from the waiting list back to their original queue ({origin_name})."
|
||||
)
|
||||
origin = CapacityQueue(origin_name)
|
||||
|
||||
while waiting_list.count > 0:
|
||||
job_id = waiting_list.pop_job_id()
|
||||
job = Job.fetch(job_id)
|
||||
origin.enqueue_job(job, at_front=True)
|
||||
|
||||
|
||||
entity_key = lambda entity, job: f"{entity}:{job.meta[f'{entity}_id']}"
|
||||
|
||||
waiting_list_key = (
|
||||
lambda entity, job, origin_name: f"{entity_key(entity, job)}:origin:{origin_name}:waiting"
|
||||
)
|
||||
|
||||
|
||||
class CapacityQueue(Queue):
|
||||
def find_waiting_list(self, job_ids, entity, job):
|
||||
executions = sum(map(lambda job_id: entity_key(entity, job) in job_id, job_ids))
|
||||
if settings.dynamic_settings.capacity_reached_for(entity, executions, job.meta):
|
||||
waiting_list = waiting_list_key(entity, job, self.name)
|
||||
logger.warning(
|
||||
f"Moving job {job.id} to the {entity}'s waiting list ({waiting_list}) since {entity_key(entity, job)} is currently executing {executions} jobs and has reached the {entity} capacity."
|
||||
)
|
||||
return waiting_list
|
||||
|
||||
def enter_waiting_list(self, job, pipeline=None):
|
||||
if job.meta.get("is_query_execution", False):
|
||||
job_ids = self.started_job_registry.get_job_ids()
|
||||
|
||||
waiting_list = self.find_waiting_list(
|
||||
job_ids, "user", job
|
||||
) or self.find_waiting_list(job_ids, "org", job)
|
||||
|
||||
if waiting_list:
|
||||
return Queue(waiting_list).enqueue_job(job, pipeline=pipeline)
|
||||
|
||||
@classmethod
|
||||
def dequeue_any(cls, *args, **kwargs):
|
||||
result = super(CapacityQueue, cls).dequeue_any(*args, **kwargs)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
job, queue = result
|
||||
|
||||
if queue.enter_waiting_list(job):
|
||||
return cls.dequeue_any(*args, **kwargs)
|
||||
else:
|
||||
return job, queue
|
||||
|
||||
def enqueue_job(self, job, pipeline=None, at_front=False):
|
||||
return self.enter_waiting_list(job, pipeline) or super().enqueue_job(
|
||||
job, pipeline=pipeline, at_front=at_front
|
||||
)
|
||||
|
||||
|
||||
class CapacityWorker(Worker):
|
||||
queue_class = CapacityQueue
|
||||
|
||||
def _process_waiting_lists(self, queue, job):
|
||||
if job.meta.get("is_query_execution", False):
|
||||
waiting_lists = [
|
||||
Queue(waiting_list_key("user", job, queue.name)),
|
||||
Queue(waiting_list_key("org", job, queue.name)),
|
||||
]
|
||||
|
||||
result = Queue.dequeue_any(waiting_lists, None, job_class=self.job_class)
|
||||
|
||||
if result is not None:
|
||||
waiting_job, _ = result
|
||||
logger.warning(
|
||||
f"Moving job {waiting_job.id} from waiting list ({waiting_job.origin}) back to the original queue ({queue.name}) since an execution slot opened up for it."
|
||||
)
|
||||
queue.enqueue_job(waiting_job)
|
||||
|
||||
def handle_job_success(self, job, queue, started_job_registry):
|
||||
try:
|
||||
super().handle_job_success(job, queue, started_job_registry)
|
||||
finally:
|
||||
self._process_waiting_lists(queue, job)
|
||||
|
||||
def handle_job_failure(self, job, queue, started_job_registry=None, exc_string=""):
|
||||
try:
|
||||
super().handle_job_failure(job, queue, started_job_registry, exc_string)
|
||||
finally:
|
||||
self._process_waiting_lists(queue, job)
|
||||
@@ -2,13 +2,10 @@ import requests
|
||||
from datetime import datetime
|
||||
|
||||
from flask_mail import Message
|
||||
from rq import Connection, Queue
|
||||
from rq.registry import FailedJobRegistry
|
||||
from rq.job import Job
|
||||
from redash import mail, models, settings, rq_redis_connection
|
||||
from redash import mail, models, settings
|
||||
from redash.models import users
|
||||
from redash.version_check import run_version_check
|
||||
from redash.worker import job, get_job_logger, default_operational_queues
|
||||
from redash.worker import job, get_job_logger
|
||||
from redash.tasks.worker import Queue
|
||||
from redash.query_runner import NotSupported
|
||||
|
||||
@@ -94,35 +91,3 @@ def get_schema(data_source_id, refresh):
|
||||
|
||||
def sync_user_details():
|
||||
users.sync_last_active_at()
|
||||
|
||||
|
||||
def purge_failed_jobs():
|
||||
with Connection(rq_redis_connection):
|
||||
queues = [q for q in Queue.all() if q.name not in default_operational_queues]
|
||||
for queue in queues:
|
||||
failed_job_ids = FailedJobRegistry(queue=queue).get_job_ids()
|
||||
failed_jobs = Job.fetch_many(failed_job_ids, rq_redis_connection)
|
||||
stale_jobs = []
|
||||
for failed_job in failed_jobs:
|
||||
# the job may not actually exist anymore in Redis
|
||||
if not failed_job:
|
||||
continue
|
||||
# the job could have an empty ended_at value in case
|
||||
# of a worker dying before it can save the ended_at value,
|
||||
# in which case we also consider them stale
|
||||
if not failed_job.ended_at:
|
||||
stale_jobs.append(failed_job)
|
||||
elif (
|
||||
datetime.utcnow() - failed_job.ended_at
|
||||
).total_seconds() > settings.JOB_DEFAULT_FAILURE_TTL:
|
||||
stale_jobs.append(failed_job)
|
||||
|
||||
for stale_job in stale_jobs:
|
||||
stale_job.delete()
|
||||
|
||||
if stale_jobs:
|
||||
logger.info(
|
||||
"Purged %d old failed jobs from the %s queue.",
|
||||
len(stale_jobs),
|
||||
queue.name,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import signal
|
||||
import time
|
||||
import redis
|
||||
from uuid import uuid4
|
||||
|
||||
from rq import get_current_job
|
||||
from rq.job import JobStatus
|
||||
@@ -86,11 +87,14 @@ def enqueue_query(
|
||||
|
||||
queue = Queue(queue_name)
|
||||
enqueue_kwargs = {
|
||||
"job_id": f"org:{data_source.org_id}:user:{user_id}:id:{uuid4()}",
|
||||
"user_id": user_id,
|
||||
"scheduled_query_id": scheduled_query_id,
|
||||
"is_api_key": is_api_key,
|
||||
"job_timeout": time_limit,
|
||||
"failure_ttl": settings.JOB_DEFAULT_FAILURE_TTL,
|
||||
"meta": {
|
||||
"is_query_execution": True,
|
||||
"data_source_id": data_source.id,
|
||||
"org_id": data_source.org_id,
|
||||
"scheduled": scheduled_query_id is not None,
|
||||
@@ -248,7 +252,7 @@ class QueryExecutor(object):
|
||||
|
||||
def _log_progress(self, state):
|
||||
logger.info(
|
||||
"job=execute_query state=%s query_hash=%s type=%s ds_id=%d "
|
||||
"job=execute_query state=%s query_hash=%s type=%s ds_id=%d "
|
||||
"job_id=%s queue=%s query_id=%s username=%s",
|
||||
state,
|
||||
self.query_hash,
|
||||
|
||||
@@ -15,10 +15,10 @@ from redash.tasks import (
|
||||
empty_schedules,
|
||||
refresh_schemas,
|
||||
cleanup_query_results,
|
||||
purge_failed_jobs,
|
||||
version_check,
|
||||
send_aggregated_errors,
|
||||
Queue,
|
||||
cleanup_waiting_lists,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -71,13 +71,18 @@ def periodic_job_definitions():
|
||||
{
|
||||
"func": refresh_schemas,
|
||||
"interval": timedelta(minutes=settings.SCHEMAS_REFRESH_SCHEDULE),
|
||||
},
|
||||
{
|
||||
"func": sync_user_details,
|
||||
"timeout": 60,
|
||||
"interval": timedelta(minutes=1),
|
||||
"result_ttl": 600,
|
||||
},
|
||||
{"func": sync_user_details, "timeout": 60, "interval": timedelta(minutes=1),},
|
||||
{"func": purge_failed_jobs, "timeout": 3600, "interval": timedelta(days=1)},
|
||||
{
|
||||
"func": send_aggregated_errors,
|
||||
"interval": timedelta(minutes=settings.SEND_FAILURE_EMAIL_INTERVAL),
|
||||
},
|
||||
{"func": cleanup_waiting_lists, "interval": timedelta(minutes=1)},
|
||||
]
|
||||
|
||||
if settings.VERSION_CHECK:
|
||||
|
||||
@@ -2,6 +2,7 @@ import errno
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from redash.tasks.capacity import CapacityQueue, CapacityWorker
|
||||
from redash import statsd_client
|
||||
from rq import Queue as BaseQueue, get_current_job
|
||||
from rq.worker import HerokuWorker # HerokuWorker implements graceful shutdown on SIGTERM
|
||||
@@ -37,7 +38,7 @@ class CancellableQueue(BaseQueue):
|
||||
job_class = CancellableJob
|
||||
|
||||
|
||||
class RedashQueue(StatsdRecordingQueue, CancellableQueue):
|
||||
class RedashQueue(StatsdRecordingQueue, CancellableQueue, CapacityQueue):
|
||||
pass
|
||||
|
||||
|
||||
@@ -101,12 +102,13 @@ class HardLimitingWorker(HerokuWorker):
|
||||
)
|
||||
self.kill_horse()
|
||||
|
||||
def monitor_work_horse(self, job):
|
||||
def monitor_work_horse(self, job, queue):
|
||||
"""The worker will monitor the work horse and make sure that it
|
||||
either executes successfully or the status of the job is set to
|
||||
failed
|
||||
"""
|
||||
self.monitor_started = utcnow()
|
||||
job.started_at = utcnow()
|
||||
while True:
|
||||
try:
|
||||
with UnixSignalDeathPenalty(
|
||||
@@ -158,12 +160,13 @@ class HardLimitingWorker(HerokuWorker):
|
||||
|
||||
self.handle_job_failure(
|
||||
job,
|
||||
queue=queue,
|
||||
exc_string="Work-horse process was terminated unexpectedly "
|
||||
"(waitpid returned %s)" % ret_val,
|
||||
)
|
||||
|
||||
|
||||
class RedashWorker(StatsdRecordingWorker, HardLimitingWorker):
|
||||
class RedashWorker(StatsdRecordingWorker, HardLimitingWorker, CapacityWorker):
|
||||
queue_class = RedashQueue
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class StatsdRecordingJobDecorator(rq_job): # noqa
|
||||
queue_class = RedashQueue
|
||||
|
||||
|
||||
job = partial(StatsdRecordingJobDecorator, connection=rq_redis_connection)
|
||||
job = partial(StatsdRecordingJobDecorator, connection=rq_redis_connection, failure_ttl=settings.JOB_DEFAULT_FAILURE_TTL)
|
||||
|
||||
|
||||
class CurrentJobFilter(logging.Filter):
|
||||
|
||||
@@ -24,7 +24,7 @@ psycopg2==2.8.3
|
||||
python-dateutil==2.8.0
|
||||
pytz>=2019.3
|
||||
PyYAML==5.1.2
|
||||
redis==3.3.11
|
||||
redis==3.5.0
|
||||
requests==2.21.0
|
||||
SQLAlchemy==1.3.10
|
||||
# We can't upgrade SQLAlchemy-Searchable version as newer versions require PostgreSQL > 9.6, but we target older versions at the moment.
|
||||
@@ -34,8 +34,9 @@ pyparsing==2.3.0
|
||||
SQLAlchemy-Utils==0.34.2
|
||||
sqlparse==0.3.0
|
||||
statsd==3.3.0
|
||||
greenlet==0.4.16
|
||||
gunicorn==20.0.4
|
||||
rq==1.1.0
|
||||
rq==1.5.0
|
||||
rq-scheduler==0.9.1
|
||||
jsonschema==3.1.1
|
||||
RestrictedPython==5.0
|
||||
|
||||
@@ -28,3 +28,25 @@ class DashboardTest(BaseTestCase):
|
||||
list(Dashboard.all_tags(self.factory.org, self.factory.user)),
|
||||
[("tag1", 3), ("tag2", 2), ("tag3", 1)],
|
||||
)
|
||||
|
||||
|
||||
class TestDashboardsByUser(BaseTestCase):
|
||||
def test_returns_only_users_dashboards(self):
|
||||
d = self.factory.create_dashboard(user=self.factory.user)
|
||||
d2 = self.factory.create_dashboard(user=self.factory.create_user())
|
||||
|
||||
dashboards = Dashboard.by_user(self.factory.user)
|
||||
|
||||
# not using self.assertIn/NotIn because otherwise this fails :O
|
||||
self.assertTrue(d in list(dashboards))
|
||||
self.assertFalse(d2 in list(dashboards))
|
||||
|
||||
def test_returns_drafts_by_the_user(self):
|
||||
d = self.factory.create_dashboard(is_draft=True)
|
||||
d2 = self.factory.create_dashboard(is_draft=True, user=self.factory.create_user())
|
||||
|
||||
dashboards = Dashboard.by_user(self.factory.user)
|
||||
|
||||
# not using self.assertIn/NotIn because otherwise this fails :O
|
||||
self.assertTrue(d in dashboards)
|
||||
self.assertFalse(d2 in dashboards)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import datetime
|
||||
from unittest import TestCase
|
||||
from mock import patch, call
|
||||
|
||||
from pytz import utc
|
||||
from freezegun import freeze_time
|
||||
|
||||
from redash.query_runner.mongodb import (
|
||||
MongoDB,
|
||||
parse_query_json,
|
||||
parse_results,
|
||||
_get_column_by_name,
|
||||
@@ -12,6 +14,33 @@ from redash.query_runner.mongodb import (
|
||||
from redash.utils import json_dumps, parse_human_time
|
||||
|
||||
|
||||
@patch("redash.query_runner.mongodb.pymongo.MongoClient")
|
||||
class TestUserPassOverride(TestCase):
|
||||
def test_username_password_present_overrides_username_from_uri(self, mongo_client):
|
||||
config = {
|
||||
"connectionString": "mongodb://localhost:27017/test",
|
||||
"username": "test_user",
|
||||
"password": "test_pass",
|
||||
"dbName": "test"
|
||||
}
|
||||
mongo_qr = MongoDB(config)
|
||||
_ = mongo_qr._get_db()
|
||||
|
||||
self.assertIn("username", mongo_client.call_args.kwargs)
|
||||
self.assertIn("password", mongo_client.call_args.kwargs)
|
||||
|
||||
def test_username_password_absent_does_not_pass_args(self, mongo_client):
|
||||
config = {
|
||||
"connectionString": "mongodb://user:pass@localhost:27017/test",
|
||||
"dbName": "test"
|
||||
}
|
||||
mongo_qr = MongoDB(config)
|
||||
_ = mongo_qr._get_db()
|
||||
|
||||
self.assertNotIn("username", mongo_client.call_args.kwargs)
|
||||
self.assertNotIn("password", mongo_client.call_args.kwargs)
|
||||
|
||||
|
||||
class TestParseQueryJson(TestCase):
|
||||
def test_ignores_non_isodate_fields(self):
|
||||
query = {"test": 1, "test_list": ["a", "b", "c"], "test_dict": {"a": 1, "b": 2}}
|
||||
|
||||
184
tests/tasks/test_capacity.py
Normal file
184
tests/tasks/test_capacity.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from mock import MagicMock, patch, call
|
||||
from tests import BaseTestCase
|
||||
|
||||
from rq import push_connection, pop_connection, Queue
|
||||
from rq.job import JobStatus
|
||||
from redash import rq_redis_connection
|
||||
from redash.tasks.capacity import CapacityWorker, CapacityQueue
|
||||
from redash.tasks.worker import Job
|
||||
|
||||
|
||||
def say_hello():
|
||||
return "Hello!"
|
||||
|
||||
|
||||
def create_job(job_id, **meta):
|
||||
meta["is_query_execution"] = True
|
||||
return Job.create(
|
||||
say_hello,
|
||||
id=f"org:{meta['org_id']}:user:{meta['user_id']}:id:{job_id}",
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
class TestCapacityQueue(BaseTestCase):
|
||||
def setUp(self):
|
||||
push_connection(rq_redis_connection)
|
||||
|
||||
def tearDown(self):
|
||||
pop_connection()
|
||||
rq_redis_connection.flushdb()
|
||||
|
||||
@patch("redash.settings.dynamic_settings.capacity_reached_for", return_value=True)
|
||||
def test_redirects_to_user_waiting_list_if_over_capacity(self, _):
|
||||
queue = CapacityQueue()
|
||||
job_1 = queue.enqueue_job(create_job(1, org_id="Acme", user_id="John"))
|
||||
job_2 = queue.enqueue_job(create_job(2, org_id="Acme", user_id="John"))
|
||||
|
||||
self.assertEqual(job_1.origin, "user:John:origin:default:waiting")
|
||||
self.assertEqual(job_2.origin, "user:John:origin:default:waiting")
|
||||
|
||||
@patch(
|
||||
"redash.settings.dynamic_settings.capacity_reached_for",
|
||||
side_effect=[False, True, False, True],
|
||||
)
|
||||
def test_redirects_to_org_waiting_list_if_over_capacity(self, _):
|
||||
queue = CapacityQueue()
|
||||
job_1 = queue.enqueue_job(create_job(1, org_id="Acme", user_id="John"))
|
||||
job_2 = queue.enqueue_job(create_job(2, org_id="Acme", user_id="Mark"))
|
||||
|
||||
self.assertEqual(job_1.origin, "org:Acme:origin:default:waiting")
|
||||
self.assertEqual(job_2.origin, "org:Acme:origin:default:waiting")
|
||||
|
||||
|
||||
class TestCapacityWorker(BaseTestCase):
|
||||
def setUp(self):
|
||||
push_connection(rq_redis_connection)
|
||||
|
||||
def tearDown(self):
|
||||
pop_connection()
|
||||
rq_redis_connection.flushdb()
|
||||
|
||||
@patch("redash.settings.dynamic_settings.capacity_reached_for", return_value=True)
|
||||
def test_always_handles_non_query_execution_jobs(self, _):
|
||||
queue = CapacityQueue()
|
||||
job = queue.enqueue(say_hello)
|
||||
|
||||
worker = CapacityWorker([queue])
|
||||
worker.work(burst=True)
|
||||
|
||||
self.assertEqual(job.get_status(refresh=True), JobStatus.FINISHED)
|
||||
|
||||
def test_handles_job_if_within_capacity(self):
|
||||
queue = CapacityQueue()
|
||||
job = queue.enqueue_job(create_job(1, org_id="Acme", user_id="John"))
|
||||
|
||||
worker = CapacityWorker([queue])
|
||||
worker.work(burst=True)
|
||||
|
||||
self.assertEqual(job.get_status(refresh=True), JobStatus.FINISHED)
|
||||
|
||||
def test_doesnt_handle_job_if_over_user_capacity(self):
|
||||
queue = CapacityQueue()
|
||||
job_1 = queue.enqueue_job(create_job(1, org_id="Acme", user_id="John"))
|
||||
job_2 = queue.enqueue_job(create_job(2, org_id="Acme", user_id="John"))
|
||||
|
||||
worker = CapacityWorker([queue])
|
||||
with patch(
|
||||
"redash.settings.dynamic_settings.capacity_reached_for",
|
||||
side_effect=[False, False, True],
|
||||
):
|
||||
worker.work(burst=True)
|
||||
|
||||
job_1.refresh()
|
||||
self.assertEqual(job_1.get_status(), JobStatus.FINISHED)
|
||||
|
||||
job_2.refresh()
|
||||
self.assertEqual(job_2.get_status(), JobStatus.QUEUED)
|
||||
self.assertEqual(job_2.origin, "user:John:origin:default:waiting")
|
||||
|
||||
def test_doesnt_handle_job_if_over_org_capacity(self):
|
||||
queue = CapacityQueue()
|
||||
job_1 = queue.enqueue_job(create_job(1, org_id="Acme", user_id="John"))
|
||||
job_2 = queue.enqueue_job(create_job(2, org_id="Acme", user_id="John"))
|
||||
|
||||
worker = CapacityWorker([queue])
|
||||
with patch(
|
||||
"redash.settings.dynamic_settings.capacity_reached_for",
|
||||
side_effect=[False, False, False, True],
|
||||
):
|
||||
worker.work(burst=True)
|
||||
|
||||
job_1.refresh()
|
||||
self.assertEqual(job_1.get_status(), JobStatus.FINISHED)
|
||||
|
||||
job_2.refresh()
|
||||
self.assertEqual(job_2.get_status(), JobStatus.QUEUED)
|
||||
self.assertEqual(job_2.origin, "org:Acme:origin:default:waiting")
|
||||
|
||||
def test_isolates_capacity_between_original_queues(self):
|
||||
queries_queue = CapacityQueue("queries")
|
||||
adhoc_query = queries_queue.enqueue_job(
|
||||
create_job(1, org_id="Acme", user_id="John")
|
||||
)
|
||||
|
||||
scheduled_queries_queue = CapacityQueue("scheduled_queries")
|
||||
scheduled_query = scheduled_queries_queue.enqueue_job(
|
||||
create_job(2, org_id="Acme", user_id="John")
|
||||
)
|
||||
|
||||
worker = CapacityWorker([queries_queue, scheduled_queries_queue])
|
||||
with patch(
|
||||
"redash.settings.dynamic_settings.capacity_reached_for", return_value=True
|
||||
):
|
||||
worker.work(burst=True)
|
||||
|
||||
adhoc_query.refresh()
|
||||
self.assertEqual(adhoc_query.get_status(), JobStatus.QUEUED)
|
||||
self.assertEqual(adhoc_query.origin, "user:John:origin:queries:waiting")
|
||||
|
||||
scheduled_query.refresh()
|
||||
self.assertEqual(scheduled_query.get_status(), JobStatus.QUEUED)
|
||||
self.assertEqual(
|
||||
scheduled_query.origin, "user:John:origin:scheduled_queries:waiting"
|
||||
)
|
||||
|
||||
def test_handles_waiting_user_jobs_when_user_slot_opens_up(self):
|
||||
user_waiting_list = Queue("user:John:origin:default:waiting")
|
||||
user_waiting_job = user_waiting_list.enqueue_job(
|
||||
create_job(1, org_id="Acme", user_id="John")
|
||||
)
|
||||
|
||||
org_waiting_list = Queue("org:Acme")
|
||||
org_waiting_job = org_waiting_list.enqueue_job(
|
||||
create_job(2, org_id="Acme", user_id="Mark", original_queue="default")
|
||||
)
|
||||
|
||||
queue = CapacityQueue()
|
||||
job = queue.enqueue_job(create_job(3, org_id="Acme", user_id="John"))
|
||||
|
||||
worker = CapacityWorker([queue])
|
||||
worker.work(max_jobs=2)
|
||||
|
||||
user_waiting_job.refresh()
|
||||
self.assertEqual(user_waiting_job.get_status(), JobStatus.FINISHED)
|
||||
self.assertEqual(user_waiting_job.origin, "default")
|
||||
|
||||
org_waiting_job.refresh()
|
||||
self.assertEqual(org_waiting_job.get_status(), JobStatus.QUEUED)
|
||||
|
||||
def test_handles_waiting_org_jobs_when_org_job_opens_up(self):
|
||||
org_waiting_list = Queue("org:Acme:origin:default:waiting")
|
||||
org_waiting_job = org_waiting_list.enqueue_job(
|
||||
create_job(1, org_id="Acme", user_id="Mark")
|
||||
)
|
||||
|
||||
queue = CapacityQueue()
|
||||
job = queue.enqueue_job(create_job(2, org_id="Acme", user_id="John"))
|
||||
|
||||
worker = CapacityWorker([queue])
|
||||
worker.work(max_jobs=2)
|
||||
|
||||
org_waiting_job.refresh()
|
||||
self.assertEqual(org_waiting_job.get_status(), JobStatus.FINISHED)
|
||||
self.assertEqual(org_waiting_job.origin, "default")
|
||||
20
tests/test_migrations.py
Normal file
20
tests/test_migrations.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
|
||||
def test_only_single_head_revision_in_migrations():
|
||||
"""
|
||||
If multiple developers are working on migrations and one of them is merged before the
|
||||
other you might end up with multiple heads (multiple revisions with the same down_revision).
|
||||
|
||||
This makes sure that there is only a single head revision in the migrations directory.
|
||||
|
||||
Adopted from https://blog.jerrycodes.com/multiple-heads-in-alembic-migrations/.
|
||||
"""
|
||||
config = Config(os.path.join("migrations", 'alembic.ini'))
|
||||
config.set_main_option('script_location', "migrations")
|
||||
script = ScriptDirectory.from_config(config)
|
||||
|
||||
# This will raise if there are multiple heads
|
||||
script.get_current_head()
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
[
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["dist", "lib"]
|
||||
}
|
||||
2161
viz-lib/package-lock.json
generated
2161
viz-lib/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,13 @@
|
||||
"main": "dist/redash-visualizations.js",
|
||||
"scripts": {
|
||||
"clean": "rm -rf lib dist",
|
||||
"build:babel": "babel src --out-dir lib --source-maps --ignore 'src/**/*.test.js' --copy-files --no-copy-ignored",
|
||||
"type-check": "tsc --noEmit",
|
||||
"type-gen": "tsc --emitDeclarationOnly",
|
||||
"build:babel:base": "babel src --out-dir lib --source-maps --ignore 'src/**/*.test.js' --copy-files --no-copy-ignored --extensions .ts,.tsx,.js,.jsx",
|
||||
"build:babel": "npm run type-gen && npm run build:babel:base",
|
||||
"build:webpack": "webpack",
|
||||
"build": " NODE_ENV=production npm-run-all clean build:babel build:webpack",
|
||||
"watch:babel": "babel src --watch --out-dir lib --source-maps --ignore 'src/**/*.test.js' --copy-files --no-copy-ignored",
|
||||
"watch:babel": "npm run build:babel:base -- --watch",
|
||||
"watch:webpack": "webpack --watch",
|
||||
"watch": "npm-run-all --parallel watch:*",
|
||||
"version": "npm run build",
|
||||
@@ -34,6 +37,20 @@
|
||||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||
"@babel/preset-env": "^7.9.0",
|
||||
"@babel/preset-react": "^7.9.4",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@types/chroma-js": "^2.1.2",
|
||||
"@types/d3": "^6.2.0",
|
||||
"@types/d3-cloud": "^1.2.3",
|
||||
"@types/debug": "^4.1.5",
|
||||
"@types/dompurify": "^2.0.4",
|
||||
"@types/enzyme": "^3.10.8",
|
||||
"@types/jest": "^26.0.18",
|
||||
"@types/leaflet": "^1.5.19",
|
||||
"@types/numeral": "0.0.28",
|
||||
"@types/plotly.js": "^1.54.4",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/tinycolor2": "^1.4.2",
|
||||
"babel-loader": "^8.1.0",
|
||||
"babel-plugin-istanbul": "^6.0.0",
|
||||
"babel-plugin-module-resolver": "^4.0.0",
|
||||
@@ -50,6 +67,8 @@
|
||||
"prettier": "^1.19.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"style-loader": "^1.1.4",
|
||||
"ts-migrate": "^0.1.10",
|
||||
"typescript": "^4.1.2",
|
||||
"webpack": "^4.42.1",
|
||||
"webpack-cli": "^3.3.11"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isNil, isArray, chunk, map, filter, toPairs } from "lodash";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import tinycolor from "tinycolor2";
|
||||
import TextInput from "antd/lib/input";
|
||||
import Typography from "antd/lib/typography";
|
||||
@@ -8,7 +7,7 @@ import Swatch from "./Swatch";
|
||||
|
||||
import "./input.less";
|
||||
|
||||
function preparePresets(presetColors, presetColumns) {
|
||||
function preparePresets(presetColors: any, presetColumns: any) {
|
||||
presetColors = isArray(presetColors) ? map(presetColors, v => [null, v]) : toPairs(presetColors);
|
||||
presetColors = map(presetColors, ([title, value]) => {
|
||||
if (isNil(value)) {
|
||||
@@ -23,7 +22,7 @@ function preparePresets(presetColors, presetColumns) {
|
||||
return chunk(filter(presetColors), presetColumns);
|
||||
}
|
||||
|
||||
function validateColor(value, callback, prefix = "#") {
|
||||
function validateColor(value: any, callback: any, prefix = "#") {
|
||||
if (isNil(value)) {
|
||||
callback(null);
|
||||
}
|
||||
@@ -33,13 +32,25 @@ function validateColor(value, callback, prefix = "#") {
|
||||
}
|
||||
}
|
||||
|
||||
export default function Input({ color, presetColors, presetColumns, onChange, onPressEnter }) {
|
||||
type OwnProps = {
|
||||
color?: string;
|
||||
presetColors?: string[] | {
|
||||
[key: string]: string;
|
||||
};
|
||||
presetColumns?: number;
|
||||
onChange?: (...args: any[]) => any;
|
||||
onPressEnter?: (...args: any[]) => any;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof Input.defaultProps;
|
||||
|
||||
export default function Input({ color, presetColors, presetColumns, onChange, onPressEnter }: Props) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
const presets = preparePresets(presetColors, presetColumns);
|
||||
|
||||
function handleInputChange(value) {
|
||||
function handleInputChange(value: any) {
|
||||
setInputValue(value);
|
||||
validateColor(value, onChange);
|
||||
}
|
||||
@@ -55,6 +66,7 @@ export default function Input({ color, presetColors, presetColumns, onChange, on
|
||||
{map(presets, (group, index) => (
|
||||
<div className="color-picker-input-swatches" key={`preset-row-${index}`}>
|
||||
{map(group, ([title, value]) => (
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
||||
<Swatch key={value} color={value} title={title} size={30} onClick={() => validateColor(value, onChange)} />
|
||||
))}
|
||||
</div>
|
||||
@@ -74,17 +86,6 @@ export default function Input({ color, presetColors, presetColumns, onChange, on
|
||||
);
|
||||
}
|
||||
|
||||
Input.propTypes = {
|
||||
color: PropTypes.string,
|
||||
presetColors: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips)
|
||||
PropTypes.objectOf(PropTypes.string), // color name => color value
|
||||
]),
|
||||
presetColumns: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
onPressEnter: PropTypes.func,
|
||||
};
|
||||
|
||||
Input.defaultProps = {
|
||||
color: "#FFFFFF",
|
||||
presetColors: null,
|
||||
@@ -1,11 +1,21 @@
|
||||
import React, { useMemo } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
|
||||
import { validateColor, getColorName } from "./utils";
|
||||
import "./label.less";
|
||||
|
||||
export default function Label({ className, color, presetColors, ...props }) {
|
||||
type OwnProps = {
|
||||
className?: string;
|
||||
color?: string;
|
||||
presetColors?: string[] | {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof Label.defaultProps;
|
||||
|
||||
// @ts-expect-error ts-migrate(2700) FIXME: Rest types may only be created from object types.
|
||||
export default function Label({ className, color, presetColors, ...props }: Props) {
|
||||
const name = useMemo(() => getColorName(validateColor(color), presetColors), [color, presetColors]);
|
||||
|
||||
return (
|
||||
@@ -15,15 +25,6 @@ export default function Label({ className, color, presetColors, ...props }) {
|
||||
);
|
||||
}
|
||||
|
||||
Label.propTypes = {
|
||||
className: PropTypes.string,
|
||||
color: PropTypes.string,
|
||||
presetColors: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips)
|
||||
PropTypes.objectOf(PropTypes.string), // color name => color value
|
||||
]),
|
||||
};
|
||||
|
||||
Label.defaultProps = {
|
||||
className: null,
|
||||
color: "#FFFFFF",
|
||||
@@ -1,15 +1,26 @@
|
||||
import { isString } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
|
||||
import "./swatch.less";
|
||||
|
||||
export default function Swatch({ className, color, title, size, style, ...props }) {
|
||||
type OwnProps = {
|
||||
className?: string;
|
||||
style?: any;
|
||||
title?: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof Swatch.defaultProps;
|
||||
|
||||
// @ts-expect-error ts-migrate(2700) FIXME: Rest types may only be created from object types.
|
||||
export default function Swatch({ className, color, title, size, style, ...props }: Props) {
|
||||
const result = (
|
||||
<span
|
||||
className={cx("color-swatch", className)}
|
||||
// @ts-expect-error ts-migrate(2698) FIXME: Spread types may only be created from object types... Remove this comment to see the full error message
|
||||
style={{ backgroundColor: color, width: size, ...style }}
|
||||
{...props}
|
||||
/>
|
||||
@@ -25,14 +36,6 @@ export default function Swatch({ className, color, title, size, style, ...props
|
||||
return result;
|
||||
}
|
||||
|
||||
Swatch.propTypes = {
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
title: PropTypes.string,
|
||||
color: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
};
|
||||
|
||||
Swatch.defaultProps = {
|
||||
className: null,
|
||||
style: null,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user