Compare commits

...

35 Commits

Author SHA1 Message Date
Omer Lachish
2dce31dd32 add per-org/user capacity 2021-02-15 23:39:57 +02:00
Omer Lachish
46e97a08cc Upgrade RQ to v1.5 (#5207)
* upgrade RQ to v1.5

* set job's started_at

* update healthcheck to match string worker names

* delay worker healthcheck for 5 minutes from start to allow enough time to load in case many workers try to load simultaneously

* log when worker cannot be found
2021-02-15 22:52:53 +02:00
Levko Kravets
640fea5e47 Fix duplicate stylesheets (#5396) 2021-02-14 22:16:06 +02:00
Rafael Wendel
c865293aaa Revert "Updated axios (#5371)" (#5385)
This reverts commit 49536de1ed.
2021-02-02 17:59:53 -03:00
Omer Lachish
3d3f6b1916 extend sync_user_details expiry (#5330) 2021-02-02 16:30:38 +02:00
Justin Talbot
0e1587a068 Add My Dashboards filter option to the Dashboards list (#5375)
* Add My Dashboards filter option to the Dashboards list. Added API endpoint to get the list of a user's dashboards, similar to the My Queries feature.

* Update empty dashboard list state to show an invite to create a new dashboard, like My Queries

* Update to Levko's suggested approach. Clean up some of the formatting for consistency. Put the 'My Queries/Dashboards' item before the Favorites since that organization seems cleaner to me.

* Address Levko's comments
2021-02-02 12:37:48 +02:00
Rafael Wendel
04edf16ed4 Increased waiting time to avoid flakiness (#5370) 2021-01-28 15:02:36 -03:00
Rafael Wendel
49536de1ed Updated axios (#5371) 2021-01-28 14:48:36 -03:00
dependabot[bot]
2f1394a6f4 Bump axios from 0.19.0 to 0.21.1 (#5366)
Bumps [axios](https://github.com/axios/axios) from 0.19.0 to 0.21.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v0.21.1/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.19.0...v0.21.1)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-25 21:49:32 -03:00
dependabot[bot]
911f398006 Bump bl from 1.2.2 to 1.2.3 in /viz-lib (#5257)
Bumps [bl](https://github.com/rvagg/bl) from 1.2.2 to 1.2.3.
- [Release notes](https://github.com/rvagg/bl/releases)
- [Commits](https://github.com/rvagg/bl/compare/v1.2.2...v1.2.3)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-25 16:52:14 -03:00
dependabot[bot]
b0b1d6c81c Bump dompurify from 2.0.8 to 2.0.17 in /viz-lib (#5326)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 2.0.8 to 2.0.17.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/2.0.8...2.0.17)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-25 13:44:11 -03:00
Rafael Wendel
23a279f318 Fix for Cypress flakiness generated by param_spec (#5349) 2021-01-22 21:03:15 -03:00
Arik Fraimovich
e71ccf5de5 Fix: add a merge migration to solve multi head issue (#5364)
* Add unit test to test for multi-head migrations issue

* Add merge migration
2021-01-21 10:55:52 -08:00
Jiajie Zhong
bb42e92cd0 Remove unnecessary space in rq log (#5345) 2021-01-20 19:45:16 -08:00
Patrick Yang
4ec96caac5 Encrypt alert notification destinations (#5317) 2021-01-20 19:40:53 -08:00
Vipul Mathur
829247c2d2 Use legacy resolver in pip to fix broken build (#5309)
Fixes #5300 and fixes #5307 

There have been upstream (`python:37-slim` image) changes that
bring in `pip` version 20.3.1, which makes new `2020-resolver`
the default.  Due to that, un-resolvable dependency conflicts
in  `requirements_all_ds.txt` now cause the build to fail.

This is a workaround until the package versions can be updated
to work with the new pip resolver.
2021-01-20 12:17:39 -08:00
Rafael Wendel
7d33af4343 Fix inconsistent Sankey behavior (#5286)
* added type casting to coerce number string into nuber

* Merge branch 'master' into fix-inconsistent=sankey-behavior

* typed map viz options

* Partially typed what was possible

* reworked data coercion

* improved MapOptionsType types

* readaqueted sankey rows so as to allow strings again
2021-01-12 23:54:14 -03:00
Rafael Wendel
84c2abed59 Add reorder to dashboard parameter widgets (#5267)
* added paramOrder prop

* minor refactor

* moved logic to widget

* Added paramOrder to widget API call

* Update client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* Merge branch 'master' into reorder-dashboard-parameters

* experimental removal of helper element

* cleaner comment

* Added dashboard global params logic

* Added backend logic for dashboard options

* Removed testing leftovers

* removed appending sortable to parent component behavior

* Revert "Added backend logic for dashboard options"

This reverts commit 41ae2ce475.

* Re-structured backend options

* removed temporary edits

* Added dashboard/widget param reorder cypress tests

* Separated edit and sorting permission

* added options to public dashboard serializer

* Removed undesirable events from drag

* Bring back attaching sortable to its parent

This reverts commit 163fb6fef5.

* Added prop to control draggable destination parent

* Removed paramOrder fallback

* WIP (for Netflify preview)

* fixup! Added prop to control draggable destination parent

* Better drag and drop styling and fix for the padding

* Revert "WIP (for Netflify preview)"

This reverts commit 433e11edc3.

* Improved dashboard parameter Cypress test

* Standardized reorder styling

* Changed dashboard param reorder to edit mode only

* fixup! Improved dashboard parameter Cypress test

* fixup! Improved dashboard parameter Cypress test

* Fix for Cypress CI error

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2021-01-11 15:18:50 -03:00
Christopher Grant
8b068dfd0b Truncate large Databricks ODBC result sizes (#5290)
Truncates results sets that exceed a limit taken from an environment
variable called DATABRICKS_ROW_LIMIT.
2021-01-08 15:20:11 -06:00
Rafael Wendel
06eb868120 Bar chart e2e test (#5279)
* created bar-chart e2e test boilerplate

* refactored assertions

* added snapshots and dashboard

* refactored assertions to properly deal with async

* replaced loops with getters for proper workings of cypress

* added a couple other bar charts

* ran prettier

* added a better query for bar charts

* removed leftovers

* moved helpers to support folder

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2021-01-06 15:13:33 -03:00
Patrick Yang
52ae7bedb2 Secret handling for Yandex, TreasureData, & Postgres/CockroachDB SSL (#5312) 2021-01-05 11:47:54 -08:00
Tim Gates
fbe57de53c docs: fix simple typo, possbily -> possibly (#5329)
There is a small typo in redash/settings/__init__.py.

Should read `possibly` rather than `possbily`.
2021-01-05 12:43:14 +02:00
Patrick Yang
db0cb98ed3 Add Username and Password fields to MongoDB config (#5314) 2021-01-04 23:14:16 -08:00
Rafael Wendel
dcdff66e62 Dropdown param search fix (#5304)
* fixed QueryBasedParamterInput optionFilterProp

* added optionFilterProp fallback for SelectWithVirtualScroll

* simplified syntax

* removed optionFilterProp from QueryBasedParameterInput.jsx

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* restricted SelectWithVirtualScroll props

* Added e2e test for parameter filters

* moved filter assertion to more suitable place

* created helper for option filter prop assertion

* moved option filter prop assertion to proper place, added result update assertion

* refactor openAndSearchAntdDropdown helper

* Fix parameter_spec

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-12-17 21:56:46 -03:00
Patrick Yang
d0793c4ba8 Obfuscate non-email alert destinations (#5318) 2020-12-16 15:39:30 -08:00
Lingkai Kong
7b8bcdf356 change item element in system status page (#5323) 2020-12-16 11:22:19 -08:00
Elad Ossadon
c290864ccd Convert viz-lib to TypeScript (#5310)
Co-authored-by: ts-migrate <>
2020-12-15 18:21:37 -08:00
Rafael Wendel
b70e95a323 added eslint no-console (#5305)
* added eslint no-console

* Update client/.eslintrc.js to allow warnings

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-12-14 10:09:43 -03:00
Elad Ossadon
18ee5343aa Sync date format from settings with clientConfig (#5299) 2020-12-10 11:16:31 -08:00
Elad Ossadon
fdf636a393 Fix disabled hot reload flow (#5306) 2020-12-07 16:02:52 -08:00
Rafael Wendel
88c13868a3 removed leftover console.log (#5303) 2020-12-07 17:21:40 -03:00
Elad Ossadon
aab11dc79b Add React Fast Refresh + Hot Module Reloading (#5291) 2020-12-07 11:46:46 -08:00
Elad Ossadon
00c77cf36e Redesign desktop nav bar (#5294) 2020-12-06 12:09:19 -08:00
Rafael Wendel
6e2631dec2 Changed 'Delete Alert' into 'Delete' for consistency (#5287) 2020-11-30 18:48:35 -03:00
Rafael Wendel
4b88959341 Fix QuerySourceDropdown value type (#5284) 2020-11-24 11:42:20 -03:00
306 changed files with 8858 additions and 3094 deletions

View File

@@ -79,6 +79,9 @@ WORKDIR /app
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV PIP_NO_CACHE_DIR=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 # We first copy only the requirements file, to avoid rebuilding on every file
# change. # change.
COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./ COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./

View File

@@ -20,6 +20,7 @@ module.exports = {
// allow debugger during development // allow debugger during development
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0, "no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
"jsx-a11y/anchor-is-valid": "off", "jsx-a11y/anchor-is-valid": "off",
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-restricted-imports": [ "no-restricted-imports": [
"error", "error",
{ {

View File

@@ -1,10 +1,10 @@
import { first } from "lodash"; import React, { useMemo } from "react";
import React, { useState } from "react"; import { first, includes } from "lodash";
import Button from "antd/lib/button";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import Link from "@/components/Link"; import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog"; import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import { useCurrentRoute } from "@/components/ApplicationArea/Router";
import { Auth, currentUser } from "@/services/auth"; import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu"; import settingsMenu from "@/services/settingsMenu";
import logoUrl from "@/assets/images/redash_icon_small.png"; import logoUrl from "@/assets/images/redash_icon_small.png";
@@ -15,37 +15,64 @@ import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined"; import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined"; import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined"; import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined";
import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined";
import VersionInfo from "./VersionInfo"; import VersionInfo from "./VersionInfo";
import "./DesktopNavbar.less"; import "./DesktopNavbar.less";
function NavbarSection({ inlineCollapsed, children, ...props }) { function NavbarSection({ children, ...props }) {
return ( return (
<Menu <Menu selectable={false} mode="vertical" theme="dark" {...props}>
selectable={false}
mode={inlineCollapsed ? "inline" : "vertical"}
inlineCollapsed={inlineCollapsed}
theme="dark"
{...props}>
{children} {children}
</Menu> </Menu>
); );
} }
export default function DesktopNavbar() { function useNavbarActiveState() {
const [collapsed, setCollapsed] = useState(true); const currentRoute = useCurrentRoute();
return useMemo(
() => ({
dashboards: includes(
[
"Dashboards.List",
"Dashboards.Favorites",
"Dashboards.My",
"Dashboards.ViewOrEdit",
"Dashboards.LegacyViewOrEdit",
],
currentRoute.id
),
queries: includes(
[
"Queries.List",
"Queries.Favorites",
"Queries.Archived",
"Queries.My",
"Queries.View",
"Queries.New",
"Queries.Edit",
],
currentRoute.id
),
dataSources: includes(["DataSources.List"], currentRoute.id),
alerts: includes(["Alerts.List", "Alerts.New", "Alerts.View", "Alerts.Edit"], currentRoute.id),
}),
[currentRoute.id]
);
}
export default function DesktopNavbar() {
const firstSettingsTab = first(settingsMenu.getAvailableItems()); const firstSettingsTab = first(settingsMenu.getAvailableItems());
const activeState = useNavbarActiveState();
const canCreateQuery = currentUser.hasPermission("create_query"); const canCreateQuery = currentUser.hasPermission("create_query");
const canCreateDashboard = currentUser.hasPermission("create_dashboard"); const canCreateDashboard = currentUser.hasPermission("create_dashboard");
const canCreateAlert = currentUser.hasPermission("list_alerts"); const canCreateAlert = currentUser.hasPermission("list_alerts");
return ( return (
<div className="desktop-navbar"> <div className="desktop-navbar">
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo"> <NavbarSection className="desktop-navbar-logo">
<div> <div>
<Link href="./"> <Link href="./">
<img src={logoUrl} alt="Redash" /> <img src={logoUrl} alt="Redash" />
@@ -53,45 +80,43 @@ export default function DesktopNavbar() {
</div> </div>
</NavbarSection> </NavbarSection>
<NavbarSection inlineCollapsed={collapsed}> <NavbarSection>
{currentUser.hasPermission("list_dashboards") && ( {currentUser.hasPermission("list_dashboards") && (
<Menu.Item key="dashboards"> <Menu.Item key="dashboards" className={activeState.dashboards ? "navbar-active-item" : null}>
<Link href="dashboards"> <Link href="dashboards">
<DesktopOutlinedIcon /> <DesktopOutlinedIcon />
<span>Dashboards</span> <span className="desktop-navbar-label">Dashboards</span>
</Link> </Link>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("view_query") && ( {currentUser.hasPermission("view_query") && (
<Menu.Item key="queries"> <Menu.Item key="queries" className={activeState.queries ? "navbar-active-item" : null}>
<Link href="queries"> <Link href="queries">
<CodeOutlinedIcon /> <CodeOutlinedIcon />
<span>Queries</span> <span className="desktop-navbar-label">Queries</span>
</Link> </Link>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("list_alerts") && ( {currentUser.hasPermission("list_alerts") && (
<Menu.Item key="alerts"> <Menu.Item key="alerts" className={activeState.alerts ? "navbar-active-item" : null}>
<Link href="alerts"> <Link href="alerts">
<AlertOutlinedIcon /> <AlertOutlinedIcon />
<span>Alerts</span> <span className="desktop-navbar-label">Alerts</span>
</Link> </Link>
</Menu.Item> </Menu.Item>
)} )}
</NavbarSection> </NavbarSection>
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-spacer"> <NavbarSection className="desktop-navbar-spacer">
{(canCreateQuery || canCreateDashboard || canCreateAlert) && <Menu.Divider />}
{(canCreateQuery || canCreateDashboard || canCreateAlert) && ( {(canCreateQuery || canCreateDashboard || canCreateAlert) && (
<Menu.SubMenu <Menu.SubMenu
key="create" key="create"
popupClassName="desktop-navbar-submenu" popupClassName="desktop-navbar-submenu"
data-test="CreateButton"
title={ title={
<React.Fragment> <React.Fragment>
<span data-test="CreateButton">
<PlusOutlinedIcon /> <PlusOutlinedIcon />
<span>Create</span> <span className="desktop-navbar-label">Create</span>
</span>
</React.Fragment> </React.Fragment>
}> }>
{canCreateQuery && ( {canCreateQuery && (
@@ -119,32 +144,30 @@ export default function DesktopNavbar() {
)} )}
</NavbarSection> </NavbarSection>
<NavbarSection inlineCollapsed={collapsed}> <NavbarSection>
<Menu.Item key="help"> <Menu.Item key="help">
<HelpTrigger showTooltip={false} type="HOME"> <HelpTrigger showTooltip={false} type="HOME">
<QuestionCircleOutlinedIcon /> <QuestionCircleOutlinedIcon />
<span>Help</span> <span className="desktop-navbar-label">Help</span>
</HelpTrigger> </HelpTrigger>
</Menu.Item> </Menu.Item>
{firstSettingsTab && ( {firstSettingsTab && (
<Menu.Item key="settings"> <Menu.Item key="settings" className={activeState.dataSources ? "navbar-active-item" : null}>
<Link href={firstSettingsTab.path} data-test="SettingsLink"> <Link href={firstSettingsTab.path} data-test="SettingsLink">
<SettingOutlinedIcon /> <SettingOutlinedIcon />
<span>Settings</span> <span className="desktop-navbar-label">Settings</span>
</Link> </Link>
</Menu.Item> </Menu.Item>
)} )}
<Menu.Divider />
</NavbarSection> </NavbarSection>
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-profile-menu"> <NavbarSection className="desktop-navbar-profile-menu">
<Menu.SubMenu <Menu.SubMenu
key="profile" key="profile"
popupClassName="desktop-navbar-submenu" popupClassName="desktop-navbar-submenu"
title={ title={
<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title"> <span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
<img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} /> <img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} />
<span>{currentUser.name}</span>
</span> </span>
}> }>
<Menu.Item key="profile"> <Menu.Item key="profile">
@@ -167,10 +190,6 @@ export default function DesktopNavbar() {
</Menu.Item> </Menu.Item>
</Menu.SubMenu> </Menu.SubMenu>
</NavbarSection> </NavbarSection>
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
</Button>
</div> </div>
); );
} }

View File

@@ -1,12 +1,17 @@
@backgroundColor: #001529; @backgroundColor: #001529;
@dividerColor: rgba(255, 255, 255, 0.5); @dividerColor: rgba(255, 255, 255, 0.5);
@textColor: rgba(255, 255, 255, 0.75); @textColor: rgba(255, 255, 255, 0.75);
@brandColor: #ff7964; // Redash logo color
@activeItemColor: @brandColor;
@iconSize: 26px;
.desktop-navbar { .desktop-navbar {
background: @backgroundColor; background: @backgroundColor;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
width: 80px;
overflow: hidden;
&-spacer { &-spacer {
flex: 1 1 auto; flex: 1 1 auto;
@@ -21,12 +26,6 @@
height: 40px; height: 40px;
transition: all 270ms; transition: all 270ms;
} }
&.ant-menu-inline-collapsed {
img {
height: 20px;
}
}
} }
.help-trigger { .help-trigger {
@@ -34,26 +33,19 @@
} }
.ant-menu { .ant-menu {
&:not(.ant-menu-inline-collapsed) {
width: 170px;
}
&.ant-menu-inline-collapsed > .ant-menu-submenu-title span img + span,
&.ant-menu-inline-collapsed > .ant-menu-item i + span {
display: inline-block;
max-width: 0;
opacity: 0;
}
.ant-menu-item-divider {
background: @dividerColor;
}
.ant-menu-item, .ant-menu-item,
.ant-menu-submenu { .ant-menu-submenu {
font-weight: 500; font-weight: 500;
color: @textColor; color: @textColor;
&.navbar-active-item {
box-shadow: inset 3px 0 0 @activeItemColor;
.anticon {
color: @activeItemColor;
}
}
&.ant-menu-submenu-open, &.ant-menu-submenu-open,
&.ant-menu-submenu-active, &.ant-menu-submenu-active,
&:hover, &:hover,
@@ -61,6 +53,16 @@
color: #fff; color: #fff;
} }
.anticon {
font-size: @iconSize;
margin: 0;
}
.desktop-navbar-label {
margin-top: 4px;
font-size: 11px;
}
a, a,
span, span,
.anticon { .anticon {
@@ -71,21 +73,33 @@
.ant-menu-submenu-arrow { .ant-menu-submenu-arrow {
display: none; display: none;
} }
.ant-menu-item,
.ant-menu-submenu {
padding: 0;
height: 60px;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
} }
.ant-btn.desktop-navbar-collapse-button { .ant-menu-submenu-title {
background-color: @backgroundColor; width: 100%;
border: 0; padding: 0;
border-radius: 0;
color: @textColor;
&:hover,
&:active {
color: #fff;
} }
&:after { a,
animation: 0s !important; &.ant-menu-vertical > .ant-menu-submenu > .ant-menu-submenu-title,
.ant-menu-submenu-title {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: normal;
height: auto;
background: none;
color: inherit;
} }
} }
@@ -99,37 +113,8 @@
.profile__image_thumb { .profile__image_thumb {
margin: 0; margin: 0;
vertical-align: middle; vertical-align: middle;
} width: @iconSize;
height: @iconSize;
.profile__image_thumb + span {
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-left: 10px;
vertical-align: middle;
display: inline-block;
// styles from Antd
opacity: 1;
transition: opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
margin-left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}
&.ant-menu-inline-collapsed {
.ant-menu-submenu-title {
padding-left: 16px !important;
padding-right: 16px !important;
}
.desktop-navbar-profile-menu-title {
.profile__image_thumb + span {
opacity: 0;
max-width: 0;
margin-left: 0;
}
} }
} }
} }

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react"; 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 ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth } from "@/services/auth"; import { Auth } from "@/services/auth";
import { policy } from "@/services/policy"; import { policy } from "@/services/policy";
@@ -62,9 +61,10 @@ export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserS
return ( return (
<ApplicationLayout> <ApplicationLayout>
<React.Fragment key={currentRoute.key}> <React.Fragment key={currentRoute.key}>
{/* @ts-expect-error FIXME */}
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}> <ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
<ErrorBoundaryContext.Consumer> <ErrorBoundaryContext.Consumer>
{({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] }) => {({ handleError } /* : { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] } FIXME bring back type */) =>
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError }) render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
} }
</ErrorBoundaryContext.Consumer> </ErrorBoundaryContext.Consumer>

View File

@@ -1,4 +1,4 @@
@import '~antd/lib/button/style/index'; @import (reference, less) "~@/assets/less/ant";
.code-block { .code-block {
background: rgba(0, 0, 0, 0.06); background: rgba(0, 0, 0, 0.06);

View File

@@ -45,7 +45,7 @@ export const TYPES = mapValues(
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"], NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"], GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"], 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"], ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
}, },
([url, title]) => [DOMAIN + HELP_PATH + url, title] ([url, title]) => [DOMAIN + HELP_PATH + url, title]

View File

@@ -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 @help-doc-bg: #f7f7f7; // according to https://github.com/getredash/website/blob/13daff2d8b570956565f482236f6245042e8477f/src/scss/_components/_variables.scss#L15

View File

@@ -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 { .parameters-mapping-list {
.keyword { .keyword {
@@ -63,7 +63,8 @@
margin-right: 3px; margin-right: 3px;
} }
&.disabled, .fa { &.disabled,
.fa {
color: #a4a4a4; color: #a4a4a4;
} }

View File

@@ -101,7 +101,6 @@ class ParameterValueInput extends React.Component {
<SelectWithVirtualScroll <SelectWithVirtualScroll
className={this.props.className} className={this.props.className}
mode={parameter.multiValuesOptions ? "multiple" : "default"} mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children"
value={normalize(value)} value={normalize(value)}
onChange={this.onSelect} onChange={this.onSelect}
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))} options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
@@ -120,7 +119,6 @@ class ParameterValueInput extends React.Component {
<QueryBasedParameterInput <QueryBasedParameterInput
className={this.props.className} className={this.props.className}
mode={parameter.multiValuesOptions ? "multiple" : "default"} mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children"
parameter={parameter} parameter={parameter}
value={value} value={value}
queryId={queryId} queryId={queryId}

View File

@@ -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; @input-dirty: #fffce1;

View File

@@ -23,19 +23,23 @@ export default class Parameters extends React.Component {
static propTypes = { static propTypes = {
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)), parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
editable: PropTypes.bool, editable: PropTypes.bool,
sortable: PropTypes.bool,
disableUrlUpdate: PropTypes.bool, disableUrlUpdate: PropTypes.bool,
onValuesChange: PropTypes.func, onValuesChange: PropTypes.func,
onPendingValuesChange: PropTypes.func, onPendingValuesChange: PropTypes.func,
onParametersEdit: PropTypes.func, onParametersEdit: PropTypes.func,
appendSortableToParent: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
parameters: [], parameters: [],
editable: false, editable: false,
sortable: false,
disableUrlUpdate: false, disableUrlUpdate: false,
onValuesChange: () => {}, onValuesChange: () => {},
onPendingValuesChange: () => {}, onPendingValuesChange: () => {},
onParametersEdit: () => {}, onParametersEdit: () => {},
appendSortableToParent: true,
}; };
constructor(props) { constructor(props) {
@@ -85,7 +89,7 @@ export default class Parameters extends React.Component {
if (oldIndex !== newIndex) { if (oldIndex !== newIndex) {
this.setState(({ parameters }) => { this.setState(({ parameters }) => {
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]); parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
onParametersEdit(); onParametersEdit(parameters);
return { parameters }; return { parameters };
}); });
} }
@@ -110,7 +114,7 @@ export default class Parameters extends React.Component {
this.setState(({ parameters }) => { this.setState(({ parameters }) => {
const updatedParameter = extend(parameter, updated); const updatedParameter = extend(parameter, updated);
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId); parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
onParametersEdit(); onParametersEdit(parameters);
return { parameters }; return { parameters };
}); });
}); });
@@ -146,15 +150,17 @@ export default class Parameters extends React.Component {
render() { render() {
const { parameters } = this.state; const { parameters } = this.state;
const { editable } = this.props; const { sortable, appendSortableToParent } = this.props;
const dirtyParamCount = size(filter(parameters, "hasPendingValue")); const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
return ( return (
<SortableContainer <SortableContainer
disabled={!editable} disabled={!sortable}
axis="xy" axis="xy"
useDragHandle useDragHandle
lockToContainerEdges lockToContainerEdges
helperClass="parameter-dragged" helperClass="parameter-dragged"
helperContainer={containerEl => (appendSortableToParent ? containerEl : document.body)}
updateBeforeSortStart={this.onBeforeSortStart} updateBeforeSortStart={this.onBeforeSortStart}
onSortEnd={this.moveParameter} onSortEnd={this.moveParameter}
containerProps={{ containerProps={{
@@ -163,8 +169,11 @@ export default class Parameters extends React.Component {
}}> }}>
{parameters.map((param, index) => ( {parameters.map((param, index) => (
<SortableElement key={param.name} index={index}> <SortableElement key={param.name} index={index}>
<div className="parameter-block" data-editable={editable || null}> <div
{editable && <DragHandle data-test={`DragHandle-${param.name}`} />} className="parameter-block"
data-editable={sortable || null}
data-test={`ParameterBlock-${param.name}`}>
{sortable && <DragHandle data-test={`DragHandle-${param.name}`} />}
{this.renderParameter(param, index)} {this.renderParameter(param, index)}
</div> </div>
</SortableElement> </SortableElement>

View File

@@ -1,4 +1,4 @@
@import "../assets/less/ant"; @import (reference, less) "~@/assets/less/ant";
.parameter-block { .parameter-block {
display: inline-block; display: inline-block;
@@ -21,6 +21,8 @@
&.parameter-dragged { &.parameter-dragged {
z-index: 2; z-index: 2;
margin: 4px 0 0 4px;
padding: 3px 6px 6px;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15); box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
} }
} }

View File

@@ -89,7 +89,6 @@ export default class QueryBasedParameterInput extends React.Component {
value={this.state.value} value={this.state.value}
onChange={onSelect} onChange={onSelect}
options={map(options, ({ value, name }) => ({ label: String(name), value }))} options={map(options, ({ value, name }) => ({ label: String(name), value }))}
optionFilterProp="children"
showSearch showSearch
showArrow showArrow
notFoundContent={isEmpty(options) ? "No options available" : null} notFoundContent={isEmpty(options) ? "No options available" : null}

View File

@@ -9,7 +9,7 @@ interface VirtualScrollLabeledValue extends LabeledValue {
label: string; label: string;
} }
interface VirtualScrollSelectProps extends SelectProps<string> { interface VirtualScrollSelectProps extends Omit<SelectProps<string>, "optionFilterProp" | "children"> {
options: Array<VirtualScrollLabeledValue>; options: Array<VirtualScrollLabeledValue>;
} }
function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element { function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element {
@@ -32,7 +32,14 @@ function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps
return false; return false;
}, [options]); }, [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; export default SelectWithVirtualScroll;

View File

@@ -1,4 +1,4 @@
@import "~@/assets/less/ant"; @import (reference, less) "~@/assets/less/ant";
.tags-list { .tags-list {
.tags-list-title { .tags-list-title {

View File

@@ -35,11 +35,11 @@ CounterCard.defaultProps = {
const queryJobsColumns = [ const queryJobsColumns = [
{ title: "Queue", dataIndex: "origin" }, { title: "Queue", dataIndex: "origin" },
{ title: "Query ID", dataIndex: "meta.query_id" }, { title: "Query ID", dataIndex: ["meta", "query_id"] },
{ title: "Org ID", dataIndex: "meta.org_id" }, { title: "Org ID", dataIndex: ["meta", "org_id"] },
{ title: "Data Source ID", dataIndex: "meta.data_source_id" }, { title: "Data Source ID", dataIndex: ["meta", "data_source_id"] },
{ title: "User ID", dataIndex: "meta.user_id" }, { title: "User ID", dataIndex: ["meta", "user_id"] },
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: "meta.scheduled" }), Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: ["meta", "scheduled"] }),
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }), Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }), Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }),
]; ];

View File

@@ -1,5 +1,4 @@
@import (reference, less) "~@/assets/less/inc/variables";
@import '../../assets/less/inc/variables';
.visual-card-list { .visual-card-list {
width: 100%; width: 100%;
@@ -7,7 +6,7 @@
} }
.visual-card { .visual-card {
background: #FFFFFF; background: #ffffff;
border: 1px solid fade(@redash-gray, 15%); border: 1px solid fade(@redash-gray, 15%);
border-radius: 3px; border-radius: 3px;
margin: 5px; margin: 5px;

View File

@@ -41,6 +41,7 @@ const DashboardWidget = React.memo(
onRefreshWidget, onRefreshWidget,
onRemoveWidget, onRemoveWidget,
onParameterMappingsChange, onParameterMappingsChange,
isEditing,
canEdit, canEdit,
isPublic, isPublic,
isLoading, isLoading,
@@ -57,6 +58,7 @@ const DashboardWidget = React.memo(
widget={widget} widget={widget}
dashboard={dashboard} dashboard={dashboard}
filters={filters} filters={filters}
isEditing={isEditing}
canEdit={canEdit} canEdit={canEdit}
isPublic={isPublic} isPublic={isPublic}
isLoading={isLoading} isLoading={isLoading}
@@ -77,7 +79,8 @@ const DashboardWidget = React.memo(
prevProps.canEdit === nextProps.canEdit && prevProps.canEdit === nextProps.canEdit &&
prevProps.isPublic === nextProps.isPublic && prevProps.isPublic === nextProps.isPublic &&
prevProps.isLoading === nextProps.isLoading && prevProps.isLoading === nextProps.isLoading &&
prevProps.filters === nextProps.filters prevProps.filters === nextProps.filters &&
prevProps.isEditing === nextProps.isEditing
); );
class DashboardGrid extends React.Component { class DashboardGrid extends React.Component {
@@ -223,7 +226,6 @@ class DashboardGrid extends React.Component {
}); });
render() { render() {
const className = cx("dashboard-wrapper", this.props.isEditing ? "editing-mode" : "preview-mode");
const { const {
onLoadWidget, onLoadWidget,
onRefreshWidget, onRefreshWidget,
@@ -232,19 +234,21 @@ class DashboardGrid extends React.Component {
filters, filters,
dashboard, dashboard,
isPublic, isPublic,
isEditing,
widgets, widgets,
} = this.props; } = this.props;
const className = cx("dashboard-wrapper", isEditing ? "editing-mode" : "preview-mode");
return ( return (
<div className={className}> <div className={className}>
<ResponsiveGridLayout <ResponsiveGridLayout
draggableCancel="input" draggableCancel="input,.sortable-container"
className={cx("layout", { "disable-animations": this.state.disableAnimations })} className={cx("layout", { "disable-animations": this.state.disableAnimations })}
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }} cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
rowHeight={cfg.rowHeight - cfg.margins} rowHeight={cfg.rowHeight - cfg.margins}
margin={[cfg.margins, cfg.margins]} margin={[cfg.margins, cfg.margins]}
isDraggable={this.props.isEditing} isDraggable={isEditing}
isResizable={this.props.isEditing} isResizable={isEditing}
onResizeStart={this.autoHeightCtrl.stop} onResizeStart={this.autoHeightCtrl.stop}
onResizeStop={this.onWidgetResize} onResizeStop={this.onWidgetResize}
layouts={this.state.layouts} layouts={this.state.layouts}
@@ -266,6 +270,7 @@ class DashboardGrid extends React.Component {
filters={filters} filters={filters}
isPublic={isPublic} isPublic={isPublic}
isLoading={widget.loading} isLoading={widget.loading}
isEditing={isEditing}
canEdit={dashboard.canEdit()} canEdit={dashboard.canEdit()}
onLoadWidget={onLoadWidget} onLoadWidget={onLoadWidget}
onRefreshWidget={onRefreshWidget} onRefreshWidget={onRefreshWidget}

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { compact, isEmpty, invoke } from "lodash"; import { compact, isEmpty, invoke, map } from "lodash";
import { markdown } from "markdown"; import { markdown } from "markdown";
import cx from "classnames"; import cx from "classnames";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
@@ -84,7 +84,14 @@ function RefreshIndicator({ refreshStartedAt }) {
RefreshIndicator.propTypes = { refreshStartedAt: Moment }; RefreshIndicator.propTypes = { refreshStartedAt: Moment };
RefreshIndicator.defaultProps = { refreshStartedAt: null }; RefreshIndicator.defaultProps = { refreshStartedAt: null };
function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onParametersUpdate }) { function VisualizationWidgetHeader({
widget,
refreshStartedAt,
parameters,
isEditing,
onParametersUpdate,
onParametersEdit,
}) {
const canViewQuery = currentUser.hasPermission("view_query"); const canViewQuery = currentUser.hasPermission("view_query");
return ( return (
@@ -104,7 +111,13 @@ function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onPar
</div> </div>
{!isEmpty(parameters) && ( {!isEmpty(parameters) && (
<div className="m-b-10"> <div className="m-b-10">
<Parameters parameters={parameters} onValuesChange={onParametersUpdate} /> <Parameters
parameters={parameters}
sortable={isEditing}
appendSortableToParent={false}
onValuesChange={onParametersUpdate}
onParametersEdit={onParametersEdit}
/>
</div> </div>
)} )}
</> </>
@@ -115,12 +128,16 @@ VisualizationWidgetHeader.propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
refreshStartedAt: Moment, refreshStartedAt: Moment,
parameters: PropTypes.arrayOf(PropTypes.object), parameters: PropTypes.arrayOf(PropTypes.object),
isEditing: PropTypes.bool,
onParametersUpdate: PropTypes.func, onParametersUpdate: PropTypes.func,
onParametersEdit: PropTypes.func,
}; };
VisualizationWidgetHeader.defaultProps = { VisualizationWidgetHeader.defaultProps = {
refreshStartedAt: null, refreshStartedAt: null,
onParametersUpdate: () => {}, onParametersUpdate: () => {},
onParametersEdit: () => {},
isEditing: false,
parameters: [], parameters: [],
}; };
@@ -190,6 +207,7 @@ class VisualizationWidget extends React.Component {
isPublic: PropTypes.bool, isPublic: PropTypes.bool,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
canEdit: PropTypes.bool, canEdit: PropTypes.bool,
isEditing: PropTypes.bool,
onLoad: PropTypes.func, onLoad: PropTypes.func,
onRefresh: PropTypes.func, onRefresh: PropTypes.func,
onDelete: PropTypes.func, onDelete: PropTypes.func,
@@ -201,6 +219,7 @@ class VisualizationWidget extends React.Component {
isPublic: false, isPublic: false,
isLoading: false, isLoading: false,
canEdit: false, canEdit: false,
isEditing: false,
onLoad: () => {}, onLoad: () => {},
onRefresh: () => {}, onRefresh: () => {},
onDelete: () => {}, onDelete: () => {},
@@ -284,10 +303,15 @@ class VisualizationWidget extends React.Component {
} }
render() { render() {
const { widget, isLoading, isPublic, canEdit, onRefresh } = this.props; const { widget, isLoading, isPublic, canEdit, isEditing, onRefresh } = this.props;
const { localParameters } = this.state; const { localParameters } = this.state;
const widgetQueryResult = widget.getQueryResult(); const widgetQueryResult = widget.getQueryResult();
const isRefreshing = isLoading && !!(widgetQueryResult && widgetQueryResult.getStatus()); const isRefreshing = isLoading && !!(widgetQueryResult && widgetQueryResult.getStatus());
const onParametersEdit = parameters => {
const paramOrder = map(parameters, "name");
widget.options.paramOrder = paramOrder;
widget.save("options", { paramOrder });
};
return ( return (
<Widget <Widget
@@ -303,7 +327,9 @@ class VisualizationWidget extends React.Component {
widget={widget} widget={widget}
refreshStartedAt={isRefreshing ? widget.refreshStartedAt : null} refreshStartedAt={isRefreshing ? widget.refreshStartedAt : null}
parameters={localParameters} parameters={localParameters}
isEditing={isEditing}
onParametersUpdate={onRefresh} onParametersUpdate={onRefresh}
onParametersEdit={onParametersEdit}
/> />
} }
footer={ footer={

View File

@@ -1,4 +1,4 @@
@import "../../../assets/less/inc/variables"; @import (reference, less) "~@/assets/less/inc/variables";
.tile .t-header .th-title a.query-link { .tile .t-header .th-title a.query-link {
color: rgba(0, 0, 0, 0.5); color: rgba(0, 0, 0, 0.5);

View File

@@ -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-bg: fade(@redash-gray, 10%);
@btn-extra-options-border: fade(@redash-gray, 15%); @btn-extra-options-border: fade(@redash-gray, 15%);

View File

@@ -1,4 +1,4 @@
@import "../../assets/less/inc/variables"; @import (reference, less) "~@/assets/less/inc/variables";
.date-range-parameter, .date-range-parameter,
.date-parameter { .date-parameter {

View File

@@ -3,7 +3,7 @@
// Empty states // Empty states
.empty-state { .empty-state {
width: 100%; width: 100%;
margin: 0px auto 10px; margin: 0 auto 10px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
@@ -18,7 +18,7 @@
} }
.empty-state__steps { .empty-state__steps {
padding-left: 0px; padding-left: 0;
} }
.empty-state__summary { .empty-state__summary {

View File

@@ -1,4 +1,4 @@
@import '~antd/lib/button/style/index'; @import (reference, less) "~@/assets/less/ant";
.embed-query-dialog { .embed-query-dialog {
label { label {

View File

@@ -1,4 +1,4 @@
@import (reference, less) '~@/assets/less/main.less'; @import (reference, less) "~@/assets/less/main.less";
.ant-list { .ant-list {
&.add-to-dashboard-dialog-search-results { &.add-to-dashboard-dialog-search-results {
@@ -13,7 +13,8 @@
padding: 12px; padding: 12px;
cursor: pointer; cursor: pointer;
&:hover, &:active { &:hover,
&:active {
@table-row-hover-bg: fade(@redash-gray, 5%); @table-row-hover-bg: fade(@redash-gray, 5%);
background-color: @table-row-hover-bg; background-color: @table-row-hover-bg;
} }

View File

@@ -1,4 +1,4 @@
@import "~@/assets/less/ant"; @import (reference, less) "~@/assets/less/ant";
.databricks-schema-browser { .databricks-schema-browser {
.schema-control { .schema-control {

View File

@@ -9,6 +9,7 @@ function getQueryResultData(queryResult, queryResultStatus = null) {
filters: invoke(queryResult, "getFilters") || [], filters: invoke(queryResult, "getFilters") || [],
updatedAt: invoke(queryResult, "getUpdatedAt") || null, updatedAt: invoke(queryResult, "getUpdatedAt") || null,
retrievedAt: get(queryResult, "query_result.retrieved_at", null), retrievedAt: get(queryResult, "query_result.retrieved_at", null),
truncated: invoke(queryResult, "getTruncated") || null,
log: invoke(queryResult, "getLog") || [], log: invoke(queryResult, "getLog") || [],
error: invoke(queryResult, "getError") || null, error: invoke(queryResult, "getError") || null,
runtime: invoke(queryResult, "getRuntime") || null, runtime: invoke(queryResult, "getRuntime") || null,

View File

@@ -52,7 +52,7 @@ export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
)} )}
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
<a onClick={confirmDelete}>Delete Alert</a> <a onClick={confirmDelete}>Delete</a>
</Menu.Item> </Menu.Item>
</Menu> </Menu>
}> }>

View File

@@ -30,6 +30,13 @@ const sidebarMenu = [
key: "all", key: "all",
href: "dashboards", href: "dashboards",
title: "All 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", key: "favorites",
@@ -157,6 +164,7 @@ const DashboardListPage = itemsList(
getResource({ params: { currentPage } }) { getResource({ params: { currentPage } }) {
return { return {
all: Dashboard.query.bind(Dashboard), all: Dashboard.query.bind(Dashboard),
my: Dashboard.myDashboards.bind(Dashboard),
favorites: Dashboard.favorites.bind(Dashboard), favorites: Dashboard.favorites.bind(Dashboard),
}[currentPage]; }[currentPage];
}, },
@@ -183,3 +191,11 @@ routes.register(
render: pageProps => <DashboardListPage {...pageProps} currentPage="favorites" />, render: pageProps => <DashboardListPage {...pageProps} currentPage="favorites" />,
}) })
); );
routes.register(
"Dashboards.My",
routeWithUserSession({
path: "/dashboards/my",
title: "My Dashboards",
render: pageProps => <DashboardListPage {...pageProps} currentPage="my" />,
})
);

View File

@@ -1,4 +1,4 @@
import { isEmpty } from "lodash"; import { isEmpty, map } from "lodash";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import cx from "classnames"; import cx from "classnames";
@@ -24,8 +24,8 @@ import DashboardHeader from "./components/DashboardHeader";
import "./DashboardPage.less"; import "./DashboardPage.less";
function DashboardSettings({ dashboardOptions }) { function DashboardSettings({ dashboardConfiguration }) {
const { dashboard, updateDashboard } = dashboardOptions; const { dashboard, updateDashboard } = dashboardConfiguration;
return ( return (
<div className="m-b-10 p-15 bg-white tiled"> <div className="m-b-10 p-15 bg-white tiled">
<Checkbox <Checkbox
@@ -39,11 +39,11 @@ function DashboardSettings({ dashboardOptions }) {
} }
DashboardSettings.propTypes = { 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 }) { function AddWidgetContainer({ dashboardConfiguration, className, ...props }) {
const { showAddTextboxDialog, showAddWidgetDialog } = dashboardOptions; const { showAddTextboxDialog, showAddWidgetDialog } = dashboardConfiguration;
return ( return (
<div className={cx("add-widget-container", className)} {...props}> <div className={cx("add-widget-container", className)} {...props}>
<h2> <h2>
@@ -66,12 +66,12 @@ function AddWidgetContainer({ dashboardOptions, className, ...props }) {
} }
AddWidgetContainer.propTypes = { 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, className: PropTypes.string,
}; };
function DashboardComponent(props) { function DashboardComponent(props) {
const dashboardOptions = useDashboard(props.dashboard); const dashboardConfiguration = useDashboard(props.dashboard);
const { const {
dashboard, dashboard,
filters, filters,
@@ -81,14 +81,19 @@ function DashboardComponent(props) {
removeWidget, removeWidget,
saveDashboardLayout, saveDashboardLayout,
globalParameters, globalParameters,
updateDashboard,
refreshDashboard, refreshDashboard,
refreshWidget, refreshWidget,
editingLayout, editingLayout,
setGridDisabled, setGridDisabled,
} = dashboardOptions; } = dashboardConfiguration;
const [pageContainer, setPageContainer] = useState(null); const [pageContainer, setPageContainer] = useState(null);
const [bottomPanelStyles, setBottomPanelStyles] = useState({}); const [bottomPanelStyles, setBottomPanelStyles] = useState({});
const onParametersEdit = parameters => {
const paramOrder = map(parameters, "name");
updateDashboard({ options: { globalParamOrder: paramOrder } });
};
useEffect(() => { useEffect(() => {
if (pageContainer) { if (pageContainer) {
@@ -114,14 +119,23 @@ function DashboardComponent(props) {
return ( return (
<div className="container" ref={setPageContainer} data-test={`DashboardId${dashboard.id}Container`}> <div className="container" ref={setPageContainer} data-test={`DashboardId${dashboard.id}Container`}>
<DashboardHeader <DashboardHeader
dashboardOptions={dashboardOptions} dashboardConfiguration={dashboardConfiguration}
headerExtra={ headerExtra={
<DynamicComponent name="Dashboard.HeaderExtra" dashboard={dashboard} dashboardOptions={dashboardOptions} /> <DynamicComponent
name="Dashboard.HeaderExtra"
dashboard={dashboard}
dashboardConfiguration={dashboardConfiguration}
/>
} }
/> />
{!isEmpty(globalParameters) && ( {!isEmpty(globalParameters) && (
<div className="dashboard-parameters m-b-10 p-15 bg-white tiled" data-test="DashboardParameters"> <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> </div>
)} )}
{!isEmpty(filters) && ( {!isEmpty(filters) && (
@@ -129,7 +143,7 @@ function DashboardComponent(props) {
<Filters filters={filters} onChange={setFilters} /> <Filters filters={filters} onChange={setFilters} />
</div> </div>
)} )}
{editingLayout && <DashboardSettings dashboardOptions={dashboardOptions} />} {editingLayout && <DashboardSettings dashboardConfiguration={dashboardConfiguration} />}
<div id="dashboard-container"> <div id="dashboard-container">
<DashboardGrid <DashboardGrid
dashboard={dashboard} dashboard={dashboard}
@@ -144,7 +158,9 @@ function DashboardComponent(props) {
onParameterMappingsChange={loadDashboard} onParameterMappingsChange={loadDashboard}
/> />
</div> </div>
{editingLayout && <AddWidgetContainer dashboardOptions={dashboardOptions} style={bottomPanelStyles} />} {editingLayout && (
<AddWidgetContainer dashboardConfiguration={dashboardConfiguration} style={bottomPanelStyles} />
)}
</div> </div>
); );
} }

View File

@@ -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 grid bg - based on 6 cols, 35px rows and 15px spacing

View File

@@ -27,8 +27,8 @@ function buttonType(value) {
return value ? "primary" : "default"; return value ? "primary" : "default";
} }
function DashboardPageTitle({ dashboardOptions }) { function DashboardPageTitle({ dashboardConfiguration }) {
const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardOptions; const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardConfiguration;
return ( return (
<div className="title-with-tags"> <div className="title-with-tags">
<div className="page-title"> <div className="page-title">
@@ -58,11 +58,11 @@ function DashboardPageTitle({ dashboardOptions }) {
} }
DashboardPageTitle.propTypes = { 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 }) { function RefreshButton({ dashboardConfiguration }) {
const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardOptions; const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardConfiguration;
const allowedIntervals = policy.getDashboardRefreshIntervals(); const allowedIntervals = policy.getDashboardRefreshIntervals();
const refreshRateOptions = clientConfig.dashboardRefreshIntervals; const refreshRateOptions = clientConfig.dashboardRefreshIntervals;
const onRefreshRateSelected = ({ key }) => { const onRefreshRateSelected = ({ key }) => {
@@ -105,10 +105,10 @@ function RefreshButton({ dashboardOptions }) {
} }
RefreshButton.propTypes = { 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 { const {
dashboard, dashboard,
setEditingLayout, setEditingLayout,
@@ -117,7 +117,7 @@ function DashboardMoreOptionsButton({ dashboardOptions }) {
managePermissions, managePermissions,
gridDisabled, gridDisabled,
isDashboardOwnerOrAdmin, isDashboardOwnerOrAdmin,
} = dashboardOptions; } = dashboardConfiguration;
const archive = () => { const archive = () => {
Modal.confirm({ Modal.confirm({
@@ -163,10 +163,10 @@ function DashboardMoreOptionsButton({ dashboardOptions }) {
} }
DashboardMoreOptionsButton.propTypes = { 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 { const {
dashboard, dashboard,
togglePublished, togglePublished,
@@ -174,7 +174,7 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
fullscreen, fullscreen,
toggleFullscreen, toggleFullscreen,
showShareDashboardDialog, showShareDashboardDialog,
} = dashboardOptions; } = dashboardConfiguration;
const showPublishButton = dashboard.is_draft; const showPublishButton = dashboard.is_draft;
const showRefreshButton = true; const showRefreshButton = true;
const showFullscreenButton = !dashboard.is_draft; const showFullscreenButton = !dashboard.is_draft;
@@ -190,7 +190,7 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
<span className="fa fa-paper-plane m-r-5" /> Publish <span className="fa fa-paper-plane m-r-5" /> Publish
</Button> </Button>
)} )}
{showRefreshButton && <RefreshButton dashboardOptions={dashboardOptions} />} {showRefreshButton && <RefreshButton dashboardConfiguration={dashboardConfiguration} />}
{showFullscreenButton && ( {showFullscreenButton && (
<Tooltip className="hidden-xs" title="Enable/Disable Fullscreen display"> <Tooltip className="hidden-xs" title="Enable/Disable Fullscreen display">
<Button type={buttonType(fullscreen)} className="icon-button m-l-5" onClick={toggleFullscreen}> <Button type={buttonType(fullscreen)} className="icon-button m-l-5" onClick={toggleFullscreen}>
@@ -210,7 +210,7 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
</Button> </Button>
</Tooltip> </Tooltip>
)} )}
{showMoreOptionsButton && <DashboardMoreOptionsButton dashboardOptions={dashboardOptions} />} {showMoreOptionsButton && <DashboardMoreOptionsButton dashboardConfiguration={dashboardConfiguration} />}
</span> </span>
)} )}
</div> </div>
@@ -218,12 +218,17 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
} }
DashboardControl.propTypes = { 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, headerExtra: PropTypes.node,
}; };
function DashboardEditControl({ dashboardOptions, headerExtra }) { function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
const { setEditingLayout, doneBtnClickedWhileSaving, dashboardStatus, retrySaveDashboardLayout } = dashboardOptions; const {
setEditingLayout,
doneBtnClickedWhileSaving,
dashboardStatus,
retrySaveDashboardLayout,
} = dashboardConfiguration;
let status; let status;
if (dashboardStatus === DashboardStatusEnum.SAVED) { if (dashboardStatus === DashboardStatusEnum.SAVED) {
status = <span className="save-status">Saved</span>; status = <span className="save-status">Saved</span>;
@@ -258,23 +263,23 @@ function DashboardEditControl({ dashboardOptions, headerExtra }) {
} }
DashboardEditControl.propTypes = { 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, headerExtra: PropTypes.node,
}; };
export default function DashboardHeader({ dashboardOptions, headerExtra }) { export default function DashboardHeader({ dashboardConfiguration, headerExtra }) {
const { editingLayout } = dashboardOptions; const { editingLayout } = dashboardConfiguration;
const DashboardControlComponent = editingLayout ? DashboardEditControl : DashboardControl; const DashboardControlComponent = editingLayout ? DashboardEditControl : DashboardControl;
return ( return (
<div className="dashboard-header"> <div className="dashboard-header">
<DashboardPageTitle dashboardOptions={dashboardOptions} /> <DashboardPageTitle dashboardConfiguration={dashboardConfiguration} />
<DashboardControlComponent dashboardOptions={dashboardOptions} headerExtra={headerExtra} /> <DashboardControlComponent dashboardConfiguration={dashboardConfiguration} headerExtra={headerExtra} />
</div> </div>
); );
} }
DashboardHeader.propTypes = { 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, headerExtra: PropTypes.node,
}; };

View File

@@ -1,4 +1,4 @@
@import "~@/components/ApplicationArea/ApplicationLayout/index.less"; @import (reference, less) "~@/components/ApplicationArea/ApplicationLayout/index.less";
.dashboard-header { .dashboard-header {
display: flex; display: flex;

View File

@@ -4,6 +4,10 @@ import BigMessage from "@/components/BigMessage";
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound"; import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState"; import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
import DynamicComponent from "@/components/DynamicComponent"; 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 { export interface DashboardListEmptyStateProps {
page: string; page: string;
@@ -21,6 +25,20 @@ export default function DashboardListEmptyState({ page, searchTerm, selectedTags
switch (page) { switch (page) {
case "favorites": case "favorites":
return <BigMessage message="Mark dashboards as Favorite to list them here." icon="fa-star" />; 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: default:
return ( return (
<DynamicComponent name="DashboardList.EmptyState"> <DynamicComponent name="DashboardList.EmptyState">

View File

@@ -33,19 +33,19 @@ const sidebarMenu = [
key: "all", key: "all",
href: "queries", href: "queries",
title: "All Queries", title: "All Queries",
}, icon: () => <Sidebar.MenuIcon icon="fa fa-code" />,
{
key: "favorites",
href: "queries/favorites",
title: "Favorites",
icon: () => <Sidebar.MenuIcon icon="fa fa-star" />,
}, },
{ {
key: "my", key: "my",
href: "queries/my", href: "queries/my",
title: "My Queries", title: "My Queries",
icon: () => <Sidebar.ProfileImage user={currentUser} />, 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", key: "archive",

View File

@@ -5,6 +5,8 @@ import BigMessage from "@/components/BigMessage";
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound"; import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState"; import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
import DynamicComponent from "@/components/DynamicComponent"; import DynamicComponent from "@/components/DynamicComponent";
import { currentUser } from "@/services/auth";
import HelpTrigger from "@/components/HelpTrigger";
export default function QueriesListEmptyState({ page, searchTerm, selectedTags }) { export default function QueriesListEmptyState({ page, searchTerm, selectedTags }) {
if (searchTerm !== "") { if (searchTerm !== "") {
@@ -19,15 +21,19 @@ export default function QueriesListEmptyState({ page, searchTerm, selectedTags }
case "archive": case "archive":
return <BigMessage message="Archived queries will be listed here." icon="fa-archive" />; return <BigMessage message="Archived queries will be listed here." icon="fa-archive" />;
case "my": case "my":
return ( const my_msg = currentUser.hasPermission("create_query") ? (
<div className="tiled bg-white p-15"> <span>
<Link.Button href="queries/new" type="primary" size="small"> <Link.Button href="queries/new" type="primary" size="small">
Create your first query Create your first query!
</Link.Button>{" "} </Link.Button>{" "}
to populate My Queries list. Need help? Check out our{" "} <HelpTrigger className="f-13" type="QUERIES" showTooltip={false}>
<Link href="https://redash.io/help/user-guide/querying/writing-queries">query writing documentation</Link>. Need help?
</div> </HelpTrigger>
</span>
) : (
<span>Sorry, we couldn't find anything.</span>
); );
return <BigMessage icon="fa-search">{my_msg}</BigMessage>;
default: default:
return ( return (
<DynamicComponent name="QueriesList.EmptyState"> <DynamicComponent name="QueriesList.EmptyState">

View File

@@ -336,6 +336,7 @@ function QuerySource(props) {
<div className="query-parameters-wrapper"> <div className="query-parameters-wrapper">
<Parameters <Parameters
editable={queryFlags.canEdit} editable={queryFlags.canEdit}
sortable={queryFlags.canEdit}
disableUrlUpdate={queryFlags.isNew} disableUrlUpdate={queryFlags.isNew}
parameters={parameters} parameters={parameters}
onPendingValuesChange={() => updateParametersDirtyFlag()} onPendingValuesChange={() => updateParametersDirtyFlag()}

View File

@@ -1,6 +1,8 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import WarningTwoTone from "@ant-design/icons/WarningTwoTone";
import TimeAgo from "@/components/TimeAgo"; import TimeAgo from "@/components/TimeAgo";
import Tooltip from "antd/lib/tooltip";
import useAddToDashboardDialog from "../hooks/useAddToDashboardDialog"; import useAddToDashboardDialog from "../hooks/useAddToDashboardDialog";
import useEmbedDialog from "../hooks/useEmbedDialog"; import useEmbedDialog from "../hooks/useEmbedDialog";
import QueryControlDropdown from "@/components/EditVisualizationButton/QueryControlDropdown"; import QueryControlDropdown from "@/components/EditVisualizationButton/QueryControlDropdown";
@@ -42,6 +44,18 @@ export default function QueryExecutionMetadata({
)} )}
<span className="m-l-5 m-r-10"> <span className="m-l-5 m-r-10">
<span> <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)} <strong>{queryResultData.rows.length}</strong> {pluralize("row", queryResultData.rows.length)}
</span> </span>
<span className="m-l-5"> <span className="m-l-5">

View File

@@ -29,7 +29,7 @@ export function QuerySourceDropdown(props) {
QuerySourceDropdown.propTypes = { QuerySourceDropdown.propTypes = {
dataSources: PropTypes.any, dataSources: PropTypes.any,
value: PropTypes.string, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
disabled: PropTypes.bool, disabled: PropTypes.bool,
loading: PropTypes.bool, loading: PropTypes.bool,
onChange: PropTypes.func, onChange: PropTypes.func,

View File

@@ -15,7 +15,7 @@ export function QuerySourceDropdownItem({ dataSource, children }) {
QuerySourceDropdownItem.propTypes = { QuerySourceDropdownItem.propTypes = {
dataSource: PropTypes.shape({ dataSource: PropTypes.shape({
name: PropTypes.string, name: PropTypes.string,
id: PropTypes.string, id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
type: PropTypes.string, type: PropTypes.string,
}).isRequired, }).isRequired,
children: PropTypes.element, children: PropTypes.element,

View File

@@ -26,7 +26,7 @@ function OrganizationSettings({ onError }) {
{isLoading ? ( {isLoading ? (
<Skeleton.Button active /> <Skeleton.Button active />
) : ( ) : (
<Button type="primary" htmlType="submit" loading={isSaving}> <Button type="primary" htmlType="submit" loading={isSaving} data-test="OrganizationSettingsSaveButton">
Save Save
</Button> </Button>
)} )}

View File

@@ -20,7 +20,9 @@ export default function FormatSettings(props) {
onChange={value => onChange({ date_format: value })} onChange={value => onChange({ date_format: value })}
data-test="DateFormatSelect"> data-test="DateFormatSelect">
{clientConfig.dateFormatList.map(dateFormat => ( {clientConfig.dateFormatList.map(dateFormat => (
<Select.Option key={dateFormat}>{dateFormat}</Select.Option> <Select.Option key={dateFormat} data-test={`DateFormatSelect:${dateFormat}`}>
{dateFormat}
</Select.Option>
))} ))}
</Select> </Select>
)} )}

View File

@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from "react";
import recordEvent from "@/services/recordEvent"; import recordEvent from "@/services/recordEvent";
import OrgSettings from "@/services/organizationSettings"; import OrgSettings from "@/services/organizationSettings";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback"; import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import { updateClientConfig } from "@/services/auth";
export default function useOrganizationSettings({ onError }) { export default function useOrganizationSettings({ onError }) {
const [settings, setSettings] = useState({}); const [settings, setSettings] = useState({});
@@ -49,6 +50,11 @@ export default function useOrganizationSettings({ onError }) {
const settings = get(response, "settings"); const settings = get(response, "settings");
setSettings(settings); setSettings(settings);
setCurrentValues({ ...settings }); setCurrentValues({ ...settings });
updateClientConfig({
dateFormat: currentValues.date_format,
timeFormat: currentValues.time_format,
dateTimeFormat: `${currentValues.date_format} ${currentValues.time_format}`,
});
}) })
.catch(handleError) .catch(handleError)
.finally(() => setIsSaving(false)); .finally(() => setIsSaving(false));

View File

@@ -1,19 +1,19 @@
@import "variables"; @import "./variables";
@font-face { @font-face {
font-family: '@{icomoon-font-family}'; 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");
src: url('@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm#iefix') format('embedded-opentype'), 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}.ttf?ehpufm") format("truetype"),
url('@{icomoon-font-path}/@{icomoon-font-family}.woff?ehpufm') format('woff'), 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'); url("@{icomoon-font-path}/@{icomoon-font-family}.svg?ehpufm#@{icomoon-font-family}") format("svg");
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
i.icon { i.icon {
/* use !important to prevent issues with browser extensions that change fonts */ /* 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; speak: none;
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;
@@ -36,4 +36,3 @@ i.icon {
content: @icon-flash; content: @icon-flash;
} }
} }

View File

@@ -44,6 +44,10 @@ const AuthUrls = {
Login: "login", Login: "login",
}; };
export function updateClientConfig(newClientConfig) {
extend(clientConfig, newClientConfig);
}
function updateSession(sessionData) { function updateSession(sessionData) {
logger("Updating session to be:", sessionData); logger("Updating session to be:", sessionData);
extend(session, sessionData, { loaded: true }); extend(session, sessionData, { loaded: true });

View File

@@ -168,6 +168,7 @@ const DashboardService = {
delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse), delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse),
query: params => axios.get("api/dashboards", { params }).then(transformResponse), query: params => axios.get("api/dashboards", { params }).then(transformResponse),
recent: params => axios.get("api/dashboards/recent", { 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), favorites: params => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`), favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
unfavorite: ({ id }) => axios.delete(`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 => { _.each(globalParams, param => {
param.setValue(param.value); // apply global param value to all locals param.setValue(param.value); // apply global param value to all locals
param.fromUrlParams(queryParams); // try to initialize from url (may do nothing) 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 = {}) { Dashboard.prototype.addWidget = function addWidget(textOrVisualization, options = {}) {

View File

@@ -271,6 +271,10 @@ class QueryResult {
return this.getColumnNames().map(col => getColumnFriendlyName(col)); return this.getColumnNames().map(col => getColumnFriendlyName(col));
} }
getTruncated() {
return this.query_result.data ? this.query_result.data.truncated : null;
}
getFilters() { getFilters() {
if (!this.getColumns()) { if (!this.getColumns()) {
return []; return [];

View File

@@ -1,6 +1,21 @@
import moment from "moment"; import moment from "moment";
import { axios } from "@/services/axios"; 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 location from "@/services/location";
import { cloneParameter } from "@/services/parameters"; import { cloneParameter } from "@/services/parameters";
import dashboardGridOptions from "@/config/dashboard-grid-options"; import dashboardGridOptions from "@/config/dashboard-grid-options";
@@ -207,7 +222,7 @@ class Widget {
const queryParams = location.search; const queryParams = location.search;
const localTypes = [Widget.MappingType.WidgetLevel, Widget.MappingType.StaticValue]; const localTypes = [Widget.MappingType.WidgetLevel, Widget.MappingType.StaticValue];
return map( const localParameters = map(
filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0), filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0),
param => { param => {
const mapping = mappings[param.name]; const mapping = mappings[param.name];
@@ -223,6 +238,13 @@ class Widget {
return result; 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() { getParameterMappings() {

View File

@@ -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");
});
});
});

View 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]);
});
});
});

View File

@@ -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", () => { describe("Parameter", () => {
const expectDirtyStateChange = edit => { const expectDirtyStateChange = edit => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter")
@@ -107,11 +115,13 @@ describe("Parameter", () => {
}); });
it("updates the results after selecting a value", () => { it("updates the results after selecting a value", () => {
cy.getByTestId("ParameterName-test-parameter") openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
.find(".ant-select")
.click();
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(); cy.getByTestId("ParameterApplyButton").click();
// ensure that query is being executed // 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", () => { it("supports multi-selection", () => {
cy.clickThrough(` cy.clickThrough(`
ParameterSettings-test-parameter ParameterSettings-test-parameter
@@ -575,16 +601,6 @@ describe("Parameter", () => {
cy.get("body").type("{alt}D"); // hide schema browser 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() { it("is possible to rearrange parameters", function() {
cy.server(); cy.server();
cy.route("POST", "**/api/queries/*").as("QuerySave"); cy.route("POST", "**/api/queries/*").as("QuerySave");

View File

@@ -6,10 +6,40 @@ describe("Settings", () => {
it("renders the page and takes a screenshot", () => { it("renders the page and takes a screenshot", () => {
cy.getByTestId("OrganizationSettings").within(() => { cy.getByTestId("OrganizationSettings").within(() => {
cy.getByTestId("DateFormatSelect").should("contain", "DD/MM/YY");
cy.getByTestId("TimeFormatSelect").should("contain", "HH:mm"); cy.getByTestId("TimeFormatSelect").should("contain", "HH:mm");
}); });
cy.percySnapshot("Organization Settings"); 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");
});
});
}); });

View 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");
});
});

View File

@@ -2,6 +2,8 @@
import "@percy/cypress"; // eslint-disable-line import/no-extraneous-dependencies, import/no-unresolved import "@percy/cypress"; // eslint-disable-line import/no-extraneous-dependencies, import/no-unresolved
import "@testing-library/cypress/add-commands";
const { each } = Cypress._; const { each } = Cypress._;
Cypress.Commands.add("login", (email = "admin@redash.io", password = "password") => { Cypress.Commands.add("login", (email = "admin@redash.io", password = "password") => {

View 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]));
}

View 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);
});
}

View File

@@ -15,21 +15,14 @@
"jsx": "react", "jsx": "react",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@/*": ["./app/*"] "@/*": ["./app/*"]
}
}, },
"include": [ "skipLibCheck": true
"app/**/*" },
], "include": ["app/**/*"],
"exclude": [ "exclude": ["dist"]
"dist"
]
} }

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

View 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

View File

@@ -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

2462
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,7 @@
"@redash/viz": "file:viz-lib", "@redash/viz": "file:viz-lib",
"ace-builds": "^1.4.12", "ace-builds": "^1.4.12",
"antd": "^4.4.3", "antd": "^4.4.3",
"axios": "^0.19.0", "axios": "^0.21.1",
"axios-auth-refresh": "^3.0.0", "axios-auth-refresh": "^3.0.0",
"bootstrap": "^3.3.7", "bootstrap": "^3.3.7",
"classnames": "^2.2.6", "classnames": "^2.2.6",
@@ -67,9 +67,9 @@
"path-to-regexp": "^3.1.0", "path-to-regexp": "^3.1.0",
"prop-types": "^15.6.1", "prop-types": "^15.6.1",
"query-string": "^6.9.0", "query-string": "^6.9.0",
"react": "^16.13.1", "react": "^16.14.0",
"react-ace": "^9.1.1", "react-ace": "^9.1.1",
"react-dom": "^16.13.1", "react-dom": "^16.14.0",
"react-grid-layout": "^0.18.2", "react-grid-layout": "^0.18.2",
"react-resizable": "^1.10.1", "react-resizable": "^1.10.1",
"react-virtualized": "^9.21.2", "react-virtualized": "^9.21.2",
@@ -89,12 +89,14 @@
"@cypress/code-coverage": "^3.8.1", "@cypress/code-coverage": "^3.8.1",
"@percy/agent": "0.24.3", "@percy/agent": "0.24.3",
"@percy/cypress": "^2.3.2", "@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/classnames": "^2.2.10",
"@types/hoist-non-react-statics": "^3.3.1", "@types/hoist-non-react-statics": "^3.3.1",
"@types/lodash": "^4.14.157", "@types/lodash": "^4.14.157",
"@types/prop-types": "^15.7.3", "@types/prop-types": "^15.7.3",
"@types/react": "^16.9.41", "@types/react": "^16.14.2",
"@types/react-dom": "^16.9.8", "@types/react-dom": "^16.9.10",
"@types/sql-formatter": "^2.3.0", "@types/sql-formatter": "^2.3.0",
"@typescript-eslint/eslint-plugin": "^2.10.0", "@typescript-eslint/eslint-plugin": "^2.10.0",
"@typescript-eslint/parser": "^2.10.0", "@typescript-eslint/parser": "^2.10.0",
@@ -137,16 +139,18 @@
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^1.19.1", "prettier": "^1.19.1",
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"react-refresh": "^0.9.0",
"react-test-renderer": "^16.5.2", "react-test-renderer": "^16.5.2",
"request": "^2.88.0", "request": "^2.88.0",
"request-cookies": "^1.1.0", "request-cookies": "^1.1.0",
"typescript": "^3.9.6", "style-loader": "^2.0.0",
"typescript": "^4.1.2",
"url-loader": "^1.1.2", "url-loader": "^1.1.2",
"webpack": "^4.20.2", "webpack": "^4.44.2",
"webpack-build-notifier": "^0.1.30", "webpack-build-notifier": "^0.1.30",
"webpack-bundle-analyzer": "^2.11.1", "webpack-bundle-analyzer": "^2.11.1",
"webpack-cli": "^3.1.2", "webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.9", "webpack-dev-server": "^3.11.0",
"webpack-manifest-plugin": "^2.0.4" "webpack-manifest-plugin": "^2.0.4"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@@ -9,7 +9,7 @@ from sqlalchemy.sql import select
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
from redash import settings 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.models.types import EncryptedConfiguration
from redash.utils.configuration import ConfigurationContainer from redash.utils.configuration import ConfigurationContainer
@@ -86,10 +86,11 @@ def reencrypt(old_secret, new_secret, show_sql):
logging.basicConfig() logging.basicConfig()
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
def _reencrypt_for_table(table_name, orm_name):
table_for_select = sqlalchemy.Table( table_for_select = sqlalchemy.Table(
"data_sources", table_name,
sqlalchemy.MetaData(), sqlalchemy.MetaData(),
Column("id", db.Integer, primary_key=True), Column("id", key_type(orm_name), primary_key=True),
Column( Column(
"encrypted_options", "encrypted_options",
ConfigurationContainer.as_mutable( ConfigurationContainer.as_mutable(
@@ -98,9 +99,9 @@ def reencrypt(old_secret, new_secret, show_sql):
), ),
) )
table_for_update = sqlalchemy.Table( table_for_update = sqlalchemy.Table(
"data_sources", table_name,
sqlalchemy.MetaData(), sqlalchemy.MetaData(),
Column("id", db.Integer, primary_key=True), Column("id", key_type(orm_name), primary_key=True),
Column( Column(
"encrypted_options", "encrypted_options",
ConfigurationContainer.as_mutable( ConfigurationContainer.as_mutable(
@@ -110,12 +111,15 @@ def reencrypt(old_secret, new_secret, show_sql):
) )
update = table_for_update.update() update = table_for_update.update()
data_sources = db.session.execute(select([table_for_select])) selected_items = db.session.execute(select([table_for_select]))
for ds in data_sources: for item in selected_items:
stmt = update.where(table_for_update.c.id == ds["id"]).values( stmt = update.where(table_for_update.c.id == item["id"]).values(
encrypted_options=ds["encrypted_options"] encrypted_options=item["encrypted_options"]
) )
db.session.execute(stmt) db.session.execute(stmt)
data_sources.close() selected_items.close()
db.session.commit() db.session.commit()
_reencrypt_for_table("data_sources", "DataSource")
_reencrypt_for_table("notification_destinations", "NotificationDestination")

View File

@@ -50,30 +50,22 @@ def worker(queues):
class WorkerHealthcheck(base.BaseCheck): class WorkerHealthcheck(base.BaseCheck):
NAME = 'RQ Worker Healthcheck' 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
def __call__(self, process_spec): def __call__(self, process_spec):
pid = process_spec['pid'] pid = process_spec["pid"]
if not self.time_to_check(pid):
return True
all_workers = Worker.all(connection=rq_redis_connection) all_workers = Worker.all(connection=rq_redis_connection)
worker = [w for w in all_workers if w.hostname == socket.gethostname().encode() and workers = [
w.pid == pid].pop() 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 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 is_healthy = is_busy or seen_lately or has_nothing_to_do
self._log("Worker %s healthcheck: Is busy? %s. " self._log(
"Worker %s healthcheck: Is busy? %s. "
"Seen lately? %s (%d seconds ago). " "Seen lately? %s (%d seconds ago). "
"Has nothing to do? %s (%d jobs in watched queues). " "Has nothing to do? %s (%d jobs in watched queues). "
"==> Is healthy? %s", "==> Is healthy? %s",
worker.key, is_busy, seen_lately, time_since_seen.seconds, worker.key,
has_nothing_to_do, total_jobs_in_watched_queues, is_healthy) is_busy,
seen_lately,
time_since_seen.seconds,
has_nothing_to_do,
total_jobs_in_watched_queues,
is_healthy,
)
return is_healthy return is_healthy
@@ -98,4 +97,5 @@ class WorkerHealthcheck(base.BaseCheck):
@manager.command() @manager.command()
def healthcheck(): def healthcheck():
return check_runner.CheckRunner( return check_runner.CheckRunner(
'worker_healthcheck', 'worker', None, [(WorkerHealthcheck, {})]).run() "worker_healthcheck", "worker", None, [(WorkerHealthcheck, {})]
).run()

View File

@@ -22,6 +22,7 @@ class ChatWork(BaseDestination):
"title": "Message Template", "title": "Message Template",
}, },
}, },
"secret": ["api_token"],
"required": ["message_template", "api_token", "room_id"], "required": ["message_template", "api_token", "room_id"],
} }

View File

@@ -28,6 +28,7 @@ class HangoutsChat(BaseDestination):
"title": "Icon URL (32x32 or multiple, png format)", "title": "Icon URL (32x32 or multiple, png format)",
}, },
}, },
"secret": ["url"],
"required": ["url"], "required": ["url"],
} }

View File

@@ -25,6 +25,7 @@ class HipChat(BaseDestination):
"title": "HipChat Notification URL (get it from the Integrations page)", "title": "HipChat Notification URL (get it from the Integrations page)",
} }
}, },
"secret": ["url"],
"required": ["url"], "required": ["url"],
} }

View File

@@ -16,6 +16,7 @@ class Mattermost(BaseDestination):
"icon_url": {"type": "string", "title": "Icon (URL)"}, "icon_url": {"type": "string", "title": "Icon (URL)"},
"channel": {"type": "string", "title": "Channel"}, "channel": {"type": "string", "title": "Channel"},
}, },
"secret": "url"
} }
@classmethod @classmethod

View File

@@ -32,6 +32,7 @@ class PagerDuty(BaseDestination):
"title": "Description for the event, defaults to alert name", "title": "Description for the event, defaults to alert name",
}, },
}, },
"secret": ["integration_key"],
"required": ["integration_key"], "required": ["integration_key"],
} }

View File

@@ -17,6 +17,7 @@ class Slack(BaseDestination):
"icon_url": {"type": "string", "title": "Icon (URL)"}, "icon_url": {"type": "string", "title": "Icon (URL)"},
"channel": {"type": "string", "title": "Channel"}, "channel": {"type": "string", "title": "Channel"},
}, },
"secret": ["url"]
} }
@classmethod @classmethod

View File

@@ -18,7 +18,7 @@ class Webhook(BaseDestination):
"password": {"type": "string"}, "password": {"type": "string"},
}, },
"required": ["url"], "required": ["url"],
"secret": ["password"], "secret": ["password", "url"],
} }
@classmethod @classmethod

View File

@@ -11,6 +11,7 @@ from redash.handlers.alerts import (
) )
from redash.handlers.base import org_scoped_rule from redash.handlers.base import org_scoped_rule
from redash.handlers.dashboards import ( from redash.handlers.dashboards import (
MyDashboardsResource,
DashboardFavoriteListResource, DashboardFavoriteListResource,
DashboardListResource, DashboardListResource,
DashboardResource, DashboardResource,
@@ -209,6 +210,8 @@ api.add_org_resource(
endpoint="dashboard_favorite", 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(QueryTagsResource, "/api/queries/tags", endpoint="query_tags")
api.add_org_resource( api.add_org_resource(
DashboardTagsResource, "/api/dashboards/tags", endpoint="dashboard_tags" DashboardTagsResource, "/api/dashboards/tags", endpoint="dashboard_tags"

View File

@@ -113,6 +113,43 @@ class DashboardListResource(BaseResource):
return DashboardSerializer(dashboard).serialize() 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): class DashboardResource(BaseResource):
@require_permission("list_dashboards") @require_permission("list_dashboards")
def get(self, dashboard_id=None): 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 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 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 array widgets: Array of arrays containing :ref:`widget <widget-response-label>` data
:>json object options: Dashboard options
.. _widget-response-label: .. _widget-response-label:
@@ -205,6 +243,7 @@ class DashboardResource(BaseResource):
"is_draft", "is_draft",
"is_archived", "is_archived",
"dashboard_filters_enabled", "dashboard_filters_enabled",
"options",
), ),
) )

View File

@@ -1099,6 +1099,9 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
tags = Column( tags = Column(
"tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True "tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True
) )
options = Column(
MutableDict.as_mutable(postgresql.JSON), server_default="{}", default={}
)
__tablename__ = "dashboards" __tablename__ = "dashboards"
__mapper_args__ = {"version_id_col": version} __mapper_args__ = {"version_id_col": version}
@@ -1132,7 +1135,6 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
), ),
Dashboard.org == org, Dashboard.org == org,
) )
.distinct()
) )
query = query.filter( query = query.filter(
@@ -1148,6 +1150,10 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
cls.name.ilike("%{}%".format(search_term)) 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 @classmethod
def all_tags(cls, org, user): def all_tags(cls, org, user):
dashboards = cls.all(org, user.group_ids, user.id) 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) ).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 @classmethod
def get_by_slug_and_org(cls, slug, org): def get_by_slug_and_org(cls, slug, org):
return cls.query.filter(cls.slug == slug, cls.org == org).one() 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") user = db.relationship(User, backref="notification_destinations")
name = Column(db.String(255)) name = Column(db.String(255))
type = 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()) created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "notification_destinations" __tablename__ = "notification_destinations"

View File

@@ -1,4 +1,6 @@
import datetime import datetime
import logging
import os
import sqlparse import sqlparse
from redash.query_runner import ( from redash.query_runner import (
NotSupported, NotSupported,
@@ -11,8 +13,9 @@ from redash.query_runner import (
TYPE_INTEGER, TYPE_INTEGER,
TYPE_FLOAT, TYPE_FLOAT,
) )
from redash.settings import cast_int_or_default
from redash.utils import json_dumps, json_loads from redash.utils import json_dumps, json_loads
from redash import __version__ from redash import __version__, settings, statsd_client
try: try:
import pyodbc import pyodbc
@@ -30,6 +33,9 @@ TYPES_MAP = {
float: TYPE_FLOAT, 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): def _build_odbc_connection_string(**kwargs):
return ";".join([f"{k}={v}" for k, v in kwargs.items()]) 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 idx = len(stmt.tokens) - 1
while idx >= 0: while idx >= 0:
tok = stmt.tokens[idx] tok = stmt.tokens[idx]
if tok.is_whitespace or sqlparse.utils.imt(tok, i=sqlparse.sql.Comment, t=sqlparse.tokens.Comment): if tok.is_whitespace or sqlparse.utils.imt(
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, ' ') tok, i=sqlparse.sql.Comment, t=sqlparse.tokens.Comment
):
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, " ")
else: else:
break break
idx -= 1 idx -= 1
@@ -53,8 +61,13 @@ def split_sql_statements(query):
tok = stmt.tokens[idx] tok = stmt.tokens[idx]
# we expect that trailing comments already are removed # we expect that trailing comments already are removed
if not tok.is_whitespace: if not tok.is_whitespace:
if sqlparse.utils.imt(tok, t=sqlparse.tokens.Punctuation) and tok.value == ";": if (
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, ' ') sqlparse.utils.imt(tok, t=sqlparse.tokens.Punctuation)
and tok.value == ";"
):
stmt.tokens[idx] = sqlparse.sql.Token(
sqlparse.tokens.Whitespace, " "
)
break break
idx -= 1 idx -= 1
return stmt return stmt
@@ -74,7 +87,11 @@ def split_sql_statements(query):
result = [stmt for stmt in stack.run(query)] result = [stmt for stmt in stack.run(query)]
result = [strip_trailing_comments(stmt) for stmt in result] result = [strip_trailing_comments(stmt) for stmt in result]
result = [strip_trailing_semicolon(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: if len(result) > 0:
return result return result
@@ -147,7 +164,7 @@ class Databricks(BaseSQLQueryRunner):
cursor.execute(stmt) cursor.execute(stmt)
if cursor.description is not None: if cursor.description is not None:
data = cursor.fetchall() result_set = cursor.fetchmany(ROW_LIMIT)
columns = self.fetch_columns( columns = self.fetch_columns(
[ [
(i[0], TYPES_MAP.get(i[1], TYPE_STRING)) (i[0], TYPES_MAP.get(i[1], TYPE_STRING))
@@ -157,10 +174,18 @@ class Databricks(BaseSQLQueryRunner):
rows = [ rows = [
dict(zip((column["name"] for column in columns), row)) dict(zip((column["name"] for column in columns), row))
for row in data for row in result_set
] ]
data = {"columns": columns, "rows": rows} 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) json_data = json_dumps(data)
error = None error = None
else: else:

View File

@@ -133,6 +133,8 @@ class MongoDB(BaseQueryRunner):
"type": "object", "type": "object",
"properties": { "properties": {
"connectionString": {"type": "string", "title": "Connection String"}, "connectionString": {"type": "string", "title": "Connection String"},
"username": {"type": "string"},
"password": {"type": "string"},
"dbName": {"type": "string", "title": "Database Name"}, "dbName": {"type": "string", "title": "Database Name"},
"replicaSetName": {"type": "string", "title": "Replica Set Name"}, "replicaSetName": {"type": "string", "title": "Replica Set Name"},
"readPreference": { "readPreference": {
@@ -147,6 +149,7 @@ class MongoDB(BaseQueryRunner):
"title": "Replica Set Read Preference", "title": "Replica Set Read Preference",
}, },
}, },
"secret": ["password"],
"required": ["connectionString", "dbName"], "required": ["connectionString", "dbName"],
} }
@@ -176,6 +179,12 @@ class MongoDB(BaseQueryRunner):
if readPreference: if readPreference:
kwargs["readPreference"] = 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( db_connection = pymongo.MongoClient(
self.configuration["connectionString"], **kwargs self.configuration["connectionString"], **kwargs
) )

View File

@@ -169,7 +169,7 @@ class PostgreSQL(BaseSQLQueryRunner):
}, },
"order": ["host", "port", "user", "password"], "order": ["host", "port", "user", "password"],
"required": ["dbname"], "required": ["dbname"],
"secret": ["password"], "secret": ["password", "sslrootcertFile", "sslcertFile", "sslkeyFile"],
"extra_options": [ "extra_options": [
"sslmode", "sslmode",
"sslrootcertFile", "sslrootcertFile",

View File

@@ -53,6 +53,7 @@ class TreasureData(BaseQueryRunner):
"default": False, "default": False,
}, },
}, },
"secret": ["apikey"],
"required": ["apikey", "db"], "required": ["apikey", "db"],
} }

View File

@@ -89,6 +89,7 @@ class YandexMetrica(BaseSQLQueryRunner):
return { return {
"type": "object", "type": "object",
"properties": {"token": {"type": "string", "title": "OAuth Token"}}, "properties": {"token": {"type": "string", "title": "OAuth Token"}},
"secret": ["token"],
"required": ["token"], "required": ["token"],
} }

View File

@@ -55,7 +55,7 @@ def public_widget(widget):
def public_dashboard(dashboard): def public_dashboard(dashboard):
dashboard_dict = project( dashboard_dict = project(
serialize_dashboard(dashboard, with_favorite_state=False), 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 = ( widget_list = (
@@ -257,6 +257,7 @@ def serialize_dashboard(obj, with_widgets=False, user=None, with_favorite_state=
"layout": layout, "layout": layout,
"dashboard_filters_enabled": obj.dashboard_filters_enabled, "dashboard_filters_enabled": obj.dashboard_filters_enabled,
"widgets": widgets, "widgets": widgets,
"options": obj.options,
"is_archived": obj.is_archived, "is_archived": obj.is_archived,
"is_draft": obj.is_draft, "is_draft": obj.is_draft,
"tags": obj.tags or [], "tags": obj.tags or [],

View File

@@ -11,6 +11,7 @@ from .helpers import (
int_or_none, int_or_none,
set_from_string, set_from_string,
add_decode_responses_to_redis_url, add_decode_responses_to_redis_url,
cast_int_or_default
) )
from .organization import DATE_FORMAT, TIME_FORMAT # noqa 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") THROTTLE_LOGIN_PATTERN = os.environ.get("REDASH_THROTTLE_LOGIN_PATTERN", "50/hour")
LIMITER_STORAGE = os.environ.get("REDASH_LIMITER_STORAGE", REDIS_URL) 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 # 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). # to the calling domain (or domains in a comma separated list).
ACCESS_CONTROL_ALLOW_ORIGIN = set_from_string( ACCESS_CONTROL_ALLOW_ORIGIN = set_from_string(
@@ -511,4 +512,6 @@ ENFORCE_CSRF = parse_boolean(
os.environ.get("REDASH_ENFORCE_CSRF", "false") os.environ.get("REDASH_ENFORCE_CSRF", "false")
) )
# Databricks
CSRF_TIME_LIMIT = int(os.environ.get("REDASH_CSRF_TIME_LIMIT", 3600 * 6)) CSRF_TIME_LIMIT = int(os.environ.get("REDASH_CSRF_TIME_LIMIT", 3600 * 6))

View File

@@ -61,3 +61,14 @@ 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. # 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. # 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

View File

@@ -29,6 +29,11 @@ def parse_boolean(s):
else: else:
raise ValueError("Invalid boolean value %r" % s) 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): def int_or_none(value):
if value is None: if value is None:

View File

@@ -3,7 +3,6 @@ from .general import (
version_check, version_check,
send_mail, send_mail,
sync_user_details, sync_user_details,
purge_failed_jobs,
) )
from .queries import ( from .queries import (
enqueue_query, enqueue_query,
@@ -17,6 +16,7 @@ from .queries import (
from .alerts import check_alerts_for_query from .alerts import check_alerts_for_query
from .failure_report import send_aggregated_errors from .failure_report import send_aggregated_errors
from .worker import Worker, Queue, Job from .worker import Worker, Queue, Job
from .capacity import cleanup_waiting_lists
from .schedule import rq_scheduler, schedule_periodic_jobs, periodic_job_definitions from .schedule import rq_scheduler, schedule_periodic_jobs, periodic_job_definitions
from redash import rq_redis_connection from redash import rq_redis_connection

135
redash/tasks/capacity.py Normal file
View 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)

View File

@@ -2,13 +2,10 @@ import requests
from datetime import datetime from datetime import datetime
from flask_mail import Message from flask_mail import Message
from rq import Connection, Queue from redash import mail, models, settings
from rq.registry import FailedJobRegistry
from rq.job import Job
from redash import mail, models, settings, rq_redis_connection
from redash.models import users from redash.models import users
from redash.version_check import run_version_check 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.tasks.worker import Queue
from redash.query_runner import NotSupported from redash.query_runner import NotSupported
@@ -94,35 +91,3 @@ def get_schema(data_source_id, refresh):
def sync_user_details(): def sync_user_details():
users.sync_last_active_at() 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,
)

View File

@@ -1,6 +1,7 @@
import signal import signal
import time import time
import redis import redis
from uuid import uuid4
from rq import get_current_job from rq import get_current_job
from rq.job import JobStatus from rq.job import JobStatus
@@ -86,11 +87,14 @@ def enqueue_query(
queue = Queue(queue_name) queue = Queue(queue_name)
enqueue_kwargs = { enqueue_kwargs = {
"job_id": f"org:{data_source.org_id}:user:{user_id}:id:{uuid4()}",
"user_id": user_id, "user_id": user_id,
"scheduled_query_id": scheduled_query_id, "scheduled_query_id": scheduled_query_id,
"is_api_key": is_api_key, "is_api_key": is_api_key,
"job_timeout": time_limit, "job_timeout": time_limit,
"failure_ttl": settings.JOB_DEFAULT_FAILURE_TTL,
"meta": { "meta": {
"is_query_execution": True,
"data_source_id": data_source.id, "data_source_id": data_source.id,
"org_id": data_source.org_id, "org_id": data_source.org_id,
"scheduled": scheduled_query_id is not None, "scheduled": scheduled_query_id is not None,

View File

@@ -15,10 +15,10 @@ from redash.tasks import (
empty_schedules, empty_schedules,
refresh_schemas, refresh_schemas,
cleanup_query_results, cleanup_query_results,
purge_failed_jobs,
version_check, version_check,
send_aggregated_errors, send_aggregated_errors,
Queue, Queue,
cleanup_waiting_lists,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -72,12 +72,17 @@ def periodic_job_definitions():
"func": refresh_schemas, "func": refresh_schemas,
"interval": timedelta(minutes=settings.SCHEMAS_REFRESH_SCHEDULE), "interval": timedelta(minutes=settings.SCHEMAS_REFRESH_SCHEDULE),
}, },
{"func": sync_user_details, "timeout": 60, "interval": timedelta(minutes=1),}, {
{"func": purge_failed_jobs, "timeout": 3600, "interval": timedelta(days=1)}, "func": sync_user_details,
"timeout": 60,
"interval": timedelta(minutes=1),
"result_ttl": 600,
},
{ {
"func": send_aggregated_errors, "func": send_aggregated_errors,
"interval": timedelta(minutes=settings.SEND_FAILURE_EMAIL_INTERVAL), "interval": timedelta(minutes=settings.SEND_FAILURE_EMAIL_INTERVAL),
}, },
{"func": cleanup_waiting_lists, "interval": timedelta(minutes=1)},
] ]
if settings.VERSION_CHECK: if settings.VERSION_CHECK:

View File

@@ -2,6 +2,7 @@ import errno
import os import os
import signal import signal
import time import time
from redash.tasks.capacity import CapacityQueue, CapacityWorker
from redash import statsd_client from redash import statsd_client
from rq import Queue as BaseQueue, get_current_job from rq import Queue as BaseQueue, get_current_job
from rq.worker import HerokuWorker # HerokuWorker implements graceful shutdown on SIGTERM from rq.worker import HerokuWorker # HerokuWorker implements graceful shutdown on SIGTERM
@@ -37,7 +38,7 @@ class CancellableQueue(BaseQueue):
job_class = CancellableJob job_class = CancellableJob
class RedashQueue(StatsdRecordingQueue, CancellableQueue): class RedashQueue(StatsdRecordingQueue, CancellableQueue, CapacityQueue):
pass pass
@@ -101,12 +102,13 @@ class HardLimitingWorker(HerokuWorker):
) )
self.kill_horse() 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 """The worker will monitor the work horse and make sure that it
either executes successfully or the status of the job is set to either executes successfully or the status of the job is set to
failed failed
""" """
self.monitor_started = utcnow() self.monitor_started = utcnow()
job.started_at = utcnow()
while True: while True:
try: try:
with UnixSignalDeathPenalty( with UnixSignalDeathPenalty(
@@ -158,12 +160,13 @@ class HardLimitingWorker(HerokuWorker):
self.handle_job_failure( self.handle_job_failure(
job, job,
queue=queue,
exc_string="Work-horse process was terminated unexpectedly " exc_string="Work-horse process was terminated unexpectedly "
"(waitpid returned %s)" % ret_val, "(waitpid returned %s)" % ret_val,
) )
class RedashWorker(StatsdRecordingWorker, HardLimitingWorker): class RedashWorker(StatsdRecordingWorker, HardLimitingWorker, CapacityWorker):
queue_class = RedashQueue queue_class = RedashQueue

View File

@@ -30,7 +30,7 @@ class StatsdRecordingJobDecorator(rq_job): # noqa
queue_class = RedashQueue 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): class CurrentJobFilter(logging.Filter):

View File

@@ -24,7 +24,7 @@ psycopg2==2.8.3
python-dateutil==2.8.0 python-dateutil==2.8.0
pytz>=2019.3 pytz>=2019.3
PyYAML==5.1.2 PyYAML==5.1.2
redis==3.3.11 redis==3.5.0
requests==2.21.0 requests==2.21.0
SQLAlchemy==1.3.10 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. # 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 SQLAlchemy-Utils==0.34.2
sqlparse==0.3.0 sqlparse==0.3.0
statsd==3.3.0 statsd==3.3.0
greenlet==0.4.16
gunicorn==20.0.4 gunicorn==20.0.4
rq==1.1.0 rq==1.5.0
rq-scheduler==0.9.1 rq-scheduler==0.9.1
jsonschema==3.1.1 jsonschema==3.1.1
RestrictedPython==5.0 RestrictedPython==5.0

View File

@@ -28,3 +28,25 @@ class DashboardTest(BaseTestCase):
list(Dashboard.all_tags(self.factory.org, self.factory.user)), list(Dashboard.all_tags(self.factory.org, self.factory.user)),
[("tag1", 3), ("tag2", 2), ("tag3", 1)], [("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)

View File

@@ -1,10 +1,12 @@
import datetime import datetime
from unittest import TestCase from unittest import TestCase
from mock import patch, call
from pytz import utc from pytz import utc
from freezegun import freeze_time from freezegun import freeze_time
from redash.query_runner.mongodb import ( from redash.query_runner.mongodb import (
MongoDB,
parse_query_json, parse_query_json,
parse_results, parse_results,
_get_column_by_name, _get_column_by_name,
@@ -12,6 +14,33 @@ from redash.query_runner.mongodb import (
from redash.utils import json_dumps, parse_human_time 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): class TestParseQueryJson(TestCase):
def test_ignores_non_isodate_fields(self): def test_ignores_non_isodate_fields(self):
query = {"test": 1, "test_list": ["a", "b", "c"], "test_dict": {"a": 1, "b": 2}} query = {"test": 1, "test_list": ["a", "b", "c"], "test_dict": {"a": 1, "b": 2}}

View 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
View 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()

View File

@@ -1,5 +1,5 @@
{ {
"presets": ["@babel/preset-env", "@babel/preset-react"], "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
"plugins": [ "plugins": [
"@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-class-properties",
[ [

View File

@@ -1,9 +0,0 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["dist", "lib"]
}

2161
viz-lib/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,13 @@
"main": "dist/redash-visualizations.js", "main": "dist/redash-visualizations.js",
"scripts": { "scripts": {
"clean": "rm -rf lib dist", "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:webpack": "webpack",
"build": " NODE_ENV=production npm-run-all clean build:babel build: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:webpack": "webpack --watch",
"watch": "npm-run-all --parallel watch:*", "watch": "npm-run-all --parallel watch:*",
"version": "npm run build", "version": "npm run build",
@@ -34,6 +37,20 @@
"@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/preset-env": "^7.9.0", "@babel/preset-env": "^7.9.0",
"@babel/preset-react": "^7.9.4", "@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-loader": "^8.1.0",
"babel-plugin-istanbul": "^6.0.0", "babel-plugin-istanbul": "^6.0.0",
"babel-plugin-module-resolver": "^4.0.0", "babel-plugin-module-resolver": "^4.0.0",
@@ -50,6 +67,8 @@
"prettier": "^1.19.1", "prettier": "^1.19.1",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"style-loader": "^1.1.4", "style-loader": "^1.1.4",
"ts-migrate": "^0.1.10",
"typescript": "^4.1.2",
"webpack": "^4.42.1", "webpack": "^4.42.1",
"webpack-cli": "^3.3.11" "webpack-cli": "^3.3.11"
}, },

Some files were not shown because too many files have changed in this diff Show More