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
318 changed files with 9005 additions and 3768 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 className="desktop-navbar-label">Create</span>
<span>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-btn.desktop-navbar-collapse-button { .ant-menu-item,
background-color: @backgroundColor; .ant-menu-submenu {
border: 0; padding: 0;
border-radius: 0; height: 60px;
color: @textColor; display: flex;
align-items: center;
&:hover, flex-direction: column;
&:active { justify-content: center;
color: #fff;
} }
&:after { .ant-menu-submenu-title {
animation: 0s !important; width: 100%;
padding: 0;
}
a,
&.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

@@ -1,5 +1,5 @@
import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash"; import { includes, words, capitalize, clone, isNull } from "lodash";
import React, { useState, useEffect, useRef, useMemo } from "react"; import React, { useState, useEffect } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Checkbox from "antd/lib/checkbox"; import Checkbox from "antd/lib/checkbox";
import Modal from "antd/lib/modal"; import Modal from "antd/lib/modal";
@@ -11,8 +11,6 @@ import Divider from "antd/lib/divider";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import QuerySelector from "@/components/QuerySelector"; import QuerySelector from "@/components/QuerySelector";
import { Query } from "@/services/query"; import { Query } from "@/services/query";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
const { Option } = Select; const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } }; const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
@@ -71,27 +69,17 @@ NameInput.propTypes = {
function EditParameterSettingsDialog(props) { function EditParameterSettingsDialog(props) {
const [param, setParam] = useState(clone(props.parameter)); const [param, setParam] = useState(clone(props.parameter));
const [isNameValid, setIsNameValid] = useState(true); const [isNameValid, setIsNameValid] = useState(true);
const [paramQuery, setParamQuery] = useState(); const [initialQuery, setInitialQuery] = useState();
const mappingParameters = useMemo(
() =>
map(paramQuery && paramQuery.getParametersDefs(), mappingParam => ({
mappingParam,
existingMapping: get(param.parameterMapping, mappingParam.name, {
mappingType: QueryBasedParameterMappingType.UNDEFINED,
}),
})),
[param.parameterMapping, paramQuery]
);
const isNew = !props.parameter.name; const isNew = !props.parameter.name;
// fetch query by id // fetch query by id
const initialQueryId = useRef(props.parameter.queryId);
useEffect(() => { useEffect(() => {
if (initialQueryId.current) { const queryId = props.parameter.queryId;
Query.get({ id: initialQueryId.current }).then(setParamQuery); if (queryId) {
Query.get({ id: queryId }).then(setInitialQuery);
} }
}, []); }, [props.parameter.queryId]);
function isFulfilled() { function isFulfilled() {
// name // name
@@ -105,14 +93,8 @@ function EditParameterSettingsDialog(props) {
} }
// query // query
if (param.type === "query") { if (param.type === "query" && !param.queryId) {
if (!param.queryId) { return false;
return false;
}
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
return false;
}
} }
return true; return true;
@@ -205,28 +187,14 @@ function EditParameterSettingsDialog(props) {
</Form.Item> </Form.Item>
)} )}
{param.type === "query" && ( {param.type === "query" && (
<Form.Item label="Query" help="Select query to load dropdown values from" required {...formItemProps}> <Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
<QuerySelector <QuerySelector
selectedQuery={paramQuery} selectedQuery={initialQuery}
onChange={q => { onChange={q => setParam({ ...param, queryId: q && q.id })}
if (q) {
setParamQuery(q);
setParam({ ...param, queryId: q.id, parameterMapping: {} });
}
}}
type="select" type="select"
/> />
</Form.Item> </Form.Item>
)} )}
{param.type === "query" && paramQuery && paramQuery.hasParameters() && (
<Form.Item className="m-t-15 m-b-5" label="Parameters" required {...formItemProps}>
<QueryBasedParameterMappingTable
param={param}
mappingParameters={mappingParameters}
onChangeParam={setParam}
/>
</Form.Item>
)}
{(param.type === "enum" || param.type === "query") && ( {(param.type === "enum" || param.type === "query") && (
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}> <Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
<Checkbox <Checkbox

View File

@@ -1,134 +0,0 @@
import React, { useState, useEffect, useRef, useReducer } from "react";
import PropTypes from "prop-types";
import { values } from "lodash";
import Button from "antd/lib/button";
import Tooltip from "antd/lib/tooltip";
import Radio from "antd/lib/radio";
import Typography from "antd/lib/typography/Typography";
import ParameterValueInput from "@/components/ParameterValueInput";
import InputPopover from "@/components/InputPopover";
import Form from "antd/lib/form";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
const { Text } = Typography;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
export default function QueryBasedParameterMappingEditor({ parameter, mapping, searchAvailable, onChange }) {
const [showPopover, setShowPopover] = useState(false);
const [newMapping, setNewMapping] = useReducer((prevState, updates) => ({ ...prevState, ...updates }), mapping);
const newMappingRef = useRef(newMapping);
useEffect(() => {
if (
mapping.mappingType !== newMappingRef.current.mappingType ||
mapping.staticValue !== newMappingRef.current.staticValue
) {
setNewMapping(mapping);
}
}, [mapping]);
const parameterRef = useRef(parameter);
useEffect(() => {
parameterRef.current.setValue(mapping.staticValue);
}, [mapping.staticValue]);
const onCancel = () => {
setNewMapping(mapping);
setShowPopover(false);
};
const onOk = () => {
onChange(newMapping);
setShowPopover(false);
};
let currentState = <Text type="secondary">Pick a type</Text>;
if (mapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH) {
currentState = "Dropdown Search";
} else if (mapping.mappingType === QueryBasedParameterMappingType.STATIC) {
currentState = `Value: ${mapping.staticValue}`;
}
return (
<>
{currentState}
<InputPopover
placement="left"
trigger="click"
header="Edit Parameter Source"
okButtonProps={{
disabled: newMapping.mappingType === QueryBasedParameterMappingType.STATIC && parameter.isEmpty,
}}
onOk={onOk}
onCancel={onCancel}
content={
<Form>
<Form.Item className="m-b-15" label="Source" {...formItemProps}>
<Radio.Group
value={newMapping.mappingType}
onChange={({ target }) => setNewMapping({ mappingType: target.value })}>
<Radio
className="radio"
value={QueryBasedParameterMappingType.DROPDOWN_SEARCH}
disabled={!searchAvailable || parameter.type !== "text"}>
Dropdown Search{" "}
{(!searchAvailable || parameter.type !== "text") && (
<Tooltip
title={
parameter.type !== "text"
? "Dropdown Search is only available for Text Parameters"
: "There is already a parameter mapped with the Dropdown Search type."
}>
<QuestionCircleFilledIcon />
</Tooltip>
)}
</Radio>
<Radio className="radio" value={QueryBasedParameterMappingType.STATIC}>
Static Value
</Radio>
</Radio.Group>
</Form.Item>
{newMapping.mappingType === QueryBasedParameterMappingType.STATIC && (
<Form.Item label="Value" required {...formItemProps}>
<ParameterValueInput
type={parameter.type}
value={parameter.normalizedValue}
enumOptions={parameter.enumOptions}
queryId={parameter.queryId}
parameter={parameter}
onSelect={value => {
parameter.setValue(value);
setNewMapping({ staticValue: parameter.getExecutionValue({ joinListValues: true }) });
}}
/>
</Form.Item>
)}
</Form>
}
visible={showPopover}
onVisibleChange={setShowPopover}>
<Button className="m-l-5" size="small" type="dashed">
<EditOutlinedIcon />
</Button>
</InputPopover>
</>
);
}
QueryBasedParameterMappingEditor.propTypes = {
parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
mapping: PropTypes.shape({
mappingType: PropTypes.oneOf(values(QueryBasedParameterMappingType)),
staticValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}),
searchAvailable: PropTypes.bool,
onChange: PropTypes.func,
};
QueryBasedParameterMappingEditor.defaultProps = {
mapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED, staticValue: undefined },
searchAvailable: false,
onChange: () => {},
};

View File

@@ -1,56 +0,0 @@
import React from "react";
import { findKey } from "lodash";
import PropTypes from "prop-types";
import Table from "antd/lib/table";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QueryBasedParameterMappingEditor from "./QueryBasedParameterMappingEditor";
export default function QueryBasedParameterMappingTable({ param, mappingParameters, onChangeParam }) {
return (
<Table
dataSource={mappingParameters}
size="middle"
pagination={false}
rowKey={({ mappingParam }) => `param${mappingParam.name}`}>
<Table.Column title="Title" key="title" render={({ mappingParam }) => mappingParam.getTitle()} />
<Table.Column
title="Keyword"
key="keyword"
className="keyword"
render={({ mappingParam }) => <code>{`{{ ${mappingParam.name} }}`}</code>}
/>
<Table.Column
title="Value Source"
key="source"
render={({ mappingParam, existingMapping }) => (
<QueryBasedParameterMappingEditor
parameter={mappingParam.setValue(existingMapping.staticValue)}
mapping={existingMapping}
searchAvailable={
!findKey(param.parameterMapping, {
mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH,
}) || existingMapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH
}
onChange={mapping =>
onChangeParam({
...param,
parameterMapping: { ...param.parameterMapping, [mappingParam.name]: mapping },
})
}
/>
)}
/>
</Table>
);
}
QueryBasedParameterMappingTable.propTypes = {
param: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
mappingParameters: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
onChangeParam: PropTypes.func,
};
QueryBasedParameterMappingTable.defaultProps = {
mappingParameters: [],
onChangeParam: () => {},
};

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,57 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Popover from "antd/lib/popover";
import "./index.less";
export default function InputPopover({
header,
content,
children,
okButtonProps,
cancelButtonProps,
onCancel,
onOk,
...props
}) {
return (
<Popover
{...props}
content={
<div className="input-popover-content" data-test="InputPopoverContent">
{header && <header>{header}</header>}
{content}
<footer>
<Button onClick={onCancel} {...cancelButtonProps}>
Cancel
</Button>
<Button onClick={onOk} type="primary" {...okButtonProps}>
OK
</Button>
</footer>
</div>
}>
{children}
</Popover>
);
}
InputPopover.propTypes = {
header: PropTypes.node,
content: PropTypes.node,
children: PropTypes.node,
okButtonProps: PropTypes.object,
cancelButtonProps: PropTypes.object,
onOk: PropTypes.func,
onCancel: PropTypes.func,
};
InputPopover.defaultProps = {
header: null,
children: null,
okButtonProps: null,
cancelButtonProps: null,
onOk: () => {},
onCancel: () => {},
};

View File

@@ -1,37 +0,0 @@
@import "~antd/lib/modal/style/index"; // for ant @vars
.input-popover-content {
width: 390px;
.radio {
display: block;
height: 30px;
line-height: 30px;
}
.form-item {
margin-bottom: 10px;
}
header {
padding: 0 16px 10px;
margin: 0 -16px 20px;
border-bottom: @border-width-base @border-style-base @border-color-split;
font-size: @font-size-lg;
font-weight: 500;
color: @heading-color;
display: flex;
justify-content: space-between;
}
footer {
border-top: @border-width-base @border-style-base @border-color-split;
padding: 10px 16px 0;
margin: 0 -16px;
text-align: right;
button {
margin-left: 8px;
}
}
}

View File

@@ -17,7 +17,6 @@ import ParameterValueInput from "@/components/ParameterValueInput";
import { ParameterMappingType } from "@/services/widget"; import { ParameterMappingType } from "@/services/widget";
import { Parameter, cloneParameter } from "@/services/parameters"; import { Parameter, cloneParameter } from "@/services/parameters";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import InputPopover from "@/components/InputPopover";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled"; import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined"; import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
@@ -314,34 +313,43 @@ class MappingEditor extends React.Component {
this.setState({ visible: false }); this.setState({ visible: false });
}; };
render() { renderContent() {
const { visible, mapping, inputError } = this.state; const { mapping, inputError } = this.state;
return ( return (
<InputPopover <div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
<header>
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
</header>
<ParameterMappingInput
mapping={mapping}
existingParamNames={this.props.existingParamNames}
onChange={this.onChange}
inputError={inputError}
/>
<footer>
<Button onClick={this.hide}>Cancel</Button>
<Button onClick={this.save} disabled={!!inputError} type="primary">
OK
</Button>
</footer>
</div>
);
}
render() {
const { visible, mapping } = this.state;
return (
<Popover
placement="left" placement="left"
trigger="click" trigger="click"
header={ content={this.renderContent()}
<>
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
</>
}
content={
<ParameterMappingInput
mapping={mapping}
existingParamNames={this.props.existingParamNames}
onChange={this.onChange}
inputError={inputError}
/>
}
onOk={this.save}
onCancel={this.hide}
okButtonProps={{ disabled: !!inputError }}
visible={visible} visible={visible}
onVisibleChange={this.onVisibleChange}> onVisibleChange={this.onVisibleChange}>
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}> <Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
<EditOutlinedIcon /> <EditOutlinedIcon />
</Button> </Button>
</InputPopover> </Popover>
); );
} }
} }

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 {
@@ -22,6 +22,42 @@
} }
} }
.parameter-mapping-editor {
width: 390px;
.radio {
display: block;
height: 30px;
line-height: 30px;
}
.form-item {
margin-bottom: 10px;
}
header {
padding: 0 16px 10px;
margin: 0 -16px 20px;
border-bottom: @border-width-base @border-style-base @border-color-split;
font-size: @font-size-lg;
font-weight: 500;
color: @heading-color;
display: flex;
justify-content: space-between;
}
footer {
border-top: @border-width-base @border-style-base @border-color-split;
padding: 10px 16px 0;
margin: 0 -16px;
text-align: right;
button {
margin-left: 8px;
}
}
}
.parameter-mapping-title { .parameter-mapping-title {
.text { .text {
margin-right: 3px; margin-right: 3px;

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;
@@ -21,7 +21,7 @@
.@{ant-prefix}-input-number, .@{ant-prefix}-input-number,
.@{ant-prefix}-select-selector, .@{ant-prefix}-select-selector,
.@{ant-prefix}-picker { .@{ant-prefix}-picker {
background-color: @input-dirty !important; background-color: @input-dirty;
} }
} }
} }

View File

@@ -7,6 +7,7 @@ import { Parameter, createParameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton"; import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput"; import ParameterValueInput from "@/components/ParameterValueInput";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog"; import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
import { toHuman } from "@/lib/utils";
import "./Parameters.less"; import "./Parameters.less";
@@ -22,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) {
@@ -84,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 };
}); });
} }
@@ -109,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 };
}); });
}); });
@@ -120,7 +125,7 @@ export default class Parameters extends React.Component {
return ( return (
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}> <div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
<div className="parameter-heading"> <div className="parameter-heading">
<label>{param.getTitle()}</label> <label>{param.title || toHuman(param.name)}</label>
{editable && ( {editable && (
<button <button
className="btn btn-default btn-xs m-l-5" className="btn btn-default btn-xs m-l-5"
@@ -145,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={{
@@ -162,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

@@ -1,19 +1,8 @@
import { find, isArray, get, first, map, intersection, isEqual, isEmpty, trim, debounce, isNil } from "lodash"; import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll"; import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
const SEARCH_DEBOUNCE_TIME = 300;
function filterValuesThatAreNotInOptions(value, options) {
if (isArray(value)) {
const optionValues = map(options, option => option.value);
return intersection(value, optionValues);
}
const found = find(options, option => option.value === value) !== undefined;
return found ? value : get(first(options), "value");
}
export default class QueryBasedParameterInput extends React.Component { export default class QueryBasedParameterInput extends React.Component {
static propTypes = { static propTypes = {
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
@@ -39,7 +28,6 @@ export default class QueryBasedParameterInput extends React.Component {
options: [], options: [],
value: null, value: null,
loading: false, loading: false,
currentSearchTerm: null,
}; };
} }
@@ -48,10 +36,9 @@ export default class QueryBasedParameterInput extends React.Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.queryId !== prevProps.queryId || this.props.parameter !== prevProps.parameter) { if (this.props.queryId !== prevProps.queryId) {
this._loadOptions(this.props.queryId); this._loadOptions(this.props.queryId);
} }
if (this.props.value !== prevProps.value) { if (this.props.value !== prevProps.value) {
this.setValue(this.props.value); this.setValue(this.props.value);
} }
@@ -59,85 +46,53 @@ export default class QueryBasedParameterInput extends React.Component {
setValue(value) { setValue(value) {
const { options } = this.state; const { options } = this.state;
const { mode, parameter } = this.props; if (this.props.mode === "multiple") {
if (mode === "multiple") {
if (isNil(value)) {
value = [];
}
value = isArray(value) ? value : [value]; value = isArray(value) ? value : [value];
const optionValues = map(options, option => option.value);
const validValues = intersection(value, optionValues);
this.setState({ value: validValues });
return validValues;
} }
const found = find(options, option => option.value === this.props.value) !== undefined;
// parameters with search don't have options available, so we trust what we get value = found ? value : get(first(options), "value");
if (!parameter.searchFunction) {
value = filterValuesThatAreNotInOptions(value, options);
}
this.setState({ value }); this.setState({ value });
return value; return value;
} }
updateOptions(options) {
this.setState({ options, loading: false }, () => {
const updatedValue = this.setValue(this.props.value);
if (!isEqual(updatedValue, this.props.value)) {
this.props.onSelect(updatedValue);
}
});
}
async _loadOptions(queryId) { async _loadOptions(queryId) {
if (queryId && queryId !== this.state.queryId) { if (queryId && queryId !== this.state.queryId) {
this.setState({ loading: true }); this.setState({ loading: true });
const options = await this.props.parameter.loadDropdownValues(this.state.currentSearchTerm); const options = await this.props.parameter.loadDropdownValues();
// stale queryId check // stale queryId check
if (this.props.queryId === queryId) { if (this.props.queryId === queryId) {
this.updateOptions(options); this.setState({ options, loading: false }, () => {
const updatedValue = this.setValue(this.props.value);
if (!isEqual(updatedValue, this.props.value)) {
this.props.onSelect(updatedValue);
}
});
} }
} }
} }
searchFunction = debounce(searchTerm => {
const { parameter } = this.props;
if (parameter.searchFunction && trim(searchTerm)) {
this.setState({ loading: true, currentSearchTerm: searchTerm });
parameter.searchFunction(searchTerm).then(options => {
if (this.state.currentSearchTerm === searchTerm) {
this.updateOptions(options);
}
});
}
}, SEARCH_DEBOUNCE_TIME);
render() { render() {
const { parameter, className, mode, onSelect, queryId, value, ...otherProps } = this.props; const { className, mode, onSelect, queryId, value, ...otherProps } = this.props;
const { loading, options } = this.state; const { loading, options } = this.state;
const selectProps = { ...otherProps };
if (parameter.searchColumn) {
selectProps.filterOption = false;
selectProps.onSearch = this.searchFunction;
selectProps.onChange = value => onSelect(parameter.normalizeValue(value));
selectProps.notFoundContent = null;
selectProps.labelInValue = true;
}
return ( return (
<span> <span>
<SelectWithVirtualScroll <SelectWithVirtualScroll
className={className} className={className}
disabled={!parameter.searchFunction && loading} disabled={loading}
loading={loading} loading={loading}
mode={mode} mode={mode}
value={this.state.value || undefined} value={this.state.value}
onChange={onSelect} onChange={onSelect}
options={options} 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}
{...selectProps} {...otherProps}
/> />
</span> </span>
); );

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;
@@ -74,4 +73,4 @@
height: 48px; height: 48px;
} }
} }
} }

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;
@@ -28,12 +28,11 @@ i.icon {
.icon-flash-off { .icon-flash-off {
&:before { &:before {
content: @icon-flash-off; content: @icon-flash-off;
} }
} }
.icon-flash { .icon-flash {
&:before { &:before {
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

@@ -1,5 +1,4 @@
import { isNull, isObject, isFunction, isUndefined, isEqual, has, omit, isArray, each } from "lodash"; import { isNull, isObject, isFunction, isUndefined, isEqual, has, omit, isArray, each } from "lodash";
import { toHuman } from "@/lib/utils";
class Parameter { class Parameter {
constructor(parameter, parentQueryId) { constructor(parameter, parentQueryId) {
@@ -45,10 +44,6 @@ class Parameter {
return this.$$value; return this.$$value;
} }
getTitle() {
return this.title || toHuman(this.name);
}
isEmptyValue(value) { isEmptyValue(value) {
return isNull(this.normalizeValue(value)); return isNull(this.normalizeValue(value));
} }

View File

@@ -1,70 +1,15 @@
import { import { isNull, isUndefined, isArray, isEmpty, get, map, join, has } from "lodash";
isNull,
isUndefined,
isArray,
isEmpty,
get,
map,
join,
has,
toString,
findKey,
mapValues,
pickBy,
filter,
omit,
} from "lodash";
import { Query } from "@/services/query"; import { Query } from "@/services/query";
import QueryResult from "@/services/query-result";
import Parameter from "./Parameter"; import Parameter from "./Parameter";
function mapQueryResultToDropdownOptions(options) {
return map(options, ({ label, name, value }) => ({ label: label || name, value: toString(value) }));
}
export const QueryBasedParameterMappingType = {
DROPDOWN_SEARCH: "search",
STATIC: "static",
UNDEFINED: "undefined",
};
function extractOptionLabelsFromValues(values) {
if (!isArray(values)) {
values = [values];
}
const optionLabels = {};
values.forEach(val => {
if (has(val, "label") && has(val, "value")) {
optionLabels[val.value] = val.label;
}
});
return optionLabels;
}
class QueryBasedDropdownParameter extends Parameter { class QueryBasedDropdownParameter extends Parameter {
constructor(parameter, parentQueryId) { constructor(parameter, parentQueryId) {
super(parameter, parentQueryId); super(parameter, parentQueryId);
this.queryId = parameter.queryId; this.queryId = parameter.queryId;
this.multiValuesOptions = parameter.multiValuesOptions; this.multiValuesOptions = parameter.multiValuesOptions;
this.parameterMapping = parameter.parameterMapping;
this.$$optionLabels = extractOptionLabelsFromValues(parameter.value);
this.setValue(parameter.value); this.setValue(parameter.value);
} }
get searchColumn() {
return findKey(this.parameterMapping, { mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH });
}
get staticParams() {
const staticParams = pickBy(
this.parameterMapping,
mapping => mapping.mappingType === QueryBasedParameterMappingType.STATIC
);
return mapValues(staticParams, value => value.staticValue);
}
normalizeValue(value) { normalizeValue(value) {
if (isUndefined(value) || isNull(value) || (isArray(value) && isEmpty(value))) { if (isUndefined(value) || isNull(value) || (isArray(value) && isEmpty(value))) {
return null; return null;
@@ -75,48 +20,24 @@ class QueryBasedDropdownParameter extends Parameter {
} else { } else {
value = isArray(value) ? value[0] : value; value = isArray(value) ? value[0] : value;
} }
if (this.searchColumn) {
value = this._getLabeledValue(value);
}
return value; return value;
} }
setValue(value) {
if (this.searchColumn) {
value = this._getLabeledValue(value);
}
return super.setValue(value);
}
getExecutionValue(extra = {}) { getExecutionValue(extra = {}) {
const { joinListValues } = extra; const { joinListValues } = extra;
let executionValue = this.value; if (joinListValues && isArray(this.value)) {
if (isArray(executionValue)) { const separator = get(this.multiValuesOptions, "separator", ",");
executionValue = map(executionValue, value => get(value, "value", value)); const prefix = get(this.multiValuesOptions, "prefix", "");
const suffix = get(this.multiValuesOptions, "suffix", "");
if (joinListValues) { const parameterValues = map(this.value, v => `${prefix}${v}${suffix}`);
const separator = get(this.multiValuesOptions, "separator", ","); return join(parameterValues, separator);
const prefix = get(this.multiValuesOptions, "prefix", "");
const suffix = get(this.multiValuesOptions, "suffix", "");
const parameterValues = map(executionValue, v => `${prefix}${v}${suffix}`);
executionValue = join(parameterValues, separator);
}
return executionValue;
} }
return this.value;
executionValue = get(executionValue, "value", executionValue);
return executionValue;
} }
toUrlParams() { toUrlParams() {
const prefix = this.urlPrefix; const prefix = this.urlPrefix;
if (this.searchColumn) {
return;
}
let urlParam = this.value; let urlParam = this.value;
if (this.multiValuesOptions && isArray(this.value)) { if (this.multiValuesOptions && isArray(this.value)) {
urlParam = JSON.stringify(this.value); urlParam = JSON.stringify(this.value);
@@ -130,80 +51,28 @@ class QueryBasedDropdownParameter extends Parameter {
fromUrlParams(query) { fromUrlParams(query) {
const prefix = this.urlPrefix; const prefix = this.urlPrefix;
const key = `${prefix}${this.name}`; const key = `${prefix}${this.name}`;
if (this.searchColumn) {
return;
}
if (has(query, key)) { if (has(query, key)) {
const queryKey = query[key];
if (this.multiValuesOptions) { if (this.multiValuesOptions) {
try { try {
const valueFromJson = JSON.parse(queryKey); const valueFromJson = JSON.parse(query[key]);
this.setValue(isArray(valueFromJson) ? valueFromJson : queryKey); this.setValue(isArray(valueFromJson) ? valueFromJson : query[key]);
} catch (e) { } catch (e) {
this.setValue(queryKey); this.setValue(query[key]);
} }
} else { } else {
this.setValue(queryKey); this.setValue(query[key]);
} }
} }
} }
_saveLabeledValuesFromOptions(options) { loadDropdownValues() {
this.$$optionLabels = { ...this.$$optionLabels, ...extractOptionLabelsFromValues(options) }; if (this.parentQueryId) {
return options; return Query.associatedDropdown({ queryId: this.parentQueryId, dropdownQueryId: this.queryId }).catch(() =>
} Promise.resolve([])
);
_getLabeledValue(value) {
const getSingleLabeledValue = value => {
value = get(value, "value", value);
if (!(value in this.$$optionLabels)) {
return null;
}
return { value, label: this.$$optionLabels[value] };
};
if (isArray(value)) {
value = map(value, getSingleLabeledValue);
return filter(value); // remove values without label
} }
return getSingleLabeledValue(value);
}
loadDropdownValues(initialSearchTerm = null) { return Query.asDropdown({ id: this.queryId }).catch(Promise.resolve([]));
return Query.get({ id: this.queryId })
.then(query => {
const queryHasParameters = query.hasParameters();
if (queryHasParameters && this.searchColumn) {
this.searchFunction = searchTerm =>
QueryResult.getByQueryId(query.id, { ...this.staticParams, [this.searchColumn]: searchTerm }, -1)
.toPromise()
.then(result => get(result, "query_result.data.rows"))
.then(mapQueryResultToDropdownOptions)
.then(options => this._saveLabeledValuesFromOptions(options))
.catch(() => Promise.resolve([]));
return initialSearchTerm ? this.searchFunction(initialSearchTerm) : Promise.resolve([]);
} else {
this.searchFunction = null;
}
if (queryHasParameters) {
return QueryResult.getByQueryId(query.id, { ...this.staticParams }, -1)
.toPromise()
.then(result => get(result, "query_result.data.rows"));
} else if (this.parentQueryId) {
return Query.associatedDropdown({ queryId: this.parentQueryId, dropdownQueryId: this.queryId });
}
return Query.asDropdown({ id: this.queryId });
})
.then(mapQueryResultToDropdownOptions)
.catch(() => Promise.resolve([]));
}
toSaveableObject() {
const saveableObject = super.toSaveableObject();
return omit(saveableObject, ["$$optionLabels"]);
} }
} }

View File

@@ -31,18 +31,6 @@ describe("QueryBasedDropdownParameter", () => {
}); });
}); });
describe("getExecutionValue", () => {
test("returns value when stored value doesn't contain its label", () => {
param.setValue("test");
expect(param.getExecutionValue()).toBe("test");
});
test("returns value from object when stored value contains its label", () => {
param.setValue({ label: "Test Label", value: "test" });
expect(param.getExecutionValue()).toBe("test");
});
});
describe("Multi-valued", () => { describe("Multi-valued", () => {
beforeAll(() => { beforeAll(() => {
multiValuesOptions = { prefix: '"', suffix: '"', separator: "," }; multiValuesOptions = { prefix: '"', suffix: '"', separator: "," };
@@ -56,19 +44,6 @@ describe("QueryBasedDropdownParameter", () => {
}); });
describe("getExecutionValue", () => { describe("getExecutionValue", () => {
test("returns value when stored value doesn't contain its label", () => {
param.setValue(["test1", "test2"]);
expect(param.getExecutionValue()).toEqual(["test1", "test2"]);
});
test("returns value from object when stored value contains its label", () => {
param.setValue([
{ label: "Test Label 1", value: "test1" },
{ label: "Test Label 2", value: "test2" },
]);
expect(param.getExecutionValue()).toEqual(["test1", "test2"]);
});
test("joins values when joinListValues is truthy", () => { test("joins values when joinListValues is truthy", () => {
param.setValue(["value1", "value3"]); param.setValue(["value1", "value3"]);
const executionValue = param.getExecutionValue({ joinListValues: true }); const executionValue = param.getExecutionValue({ joinListValues: true });

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("InputPopoverContent").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("InputPopoverContent").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

@@ -1,7 +1,7 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"types": ["cypress","@percy/cypress","@testing-library/cypress"] "types": ["cypress", "@percy/cypress", "@testing-library/cypress"]
}, },
"include": ["./**/*.ts"] "include": ["./**/*.ts"]
} }

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/*"]
} },
"skipLibCheck": true
}, },
"include": [ "include": ["app/**/*"],
"app/**/*" "exclude": ["dist"]
],
"exclude": [
"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

2466
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
@@ -27,7 +27,7 @@ def _wait_for_db_connection(db):
retried = True retried = True
def is_db_empty(): def is_db_empty():
from redash.models import db from redash.models import db
@@ -86,36 +86,40 @@ 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)
table_for_select = sqlalchemy.Table( def _reencrypt_for_table(table_name, orm_name):
"data_sources", table_for_select = sqlalchemy.Table(
sqlalchemy.MetaData(), table_name,
Column("id", db.Integer, primary_key=True), sqlalchemy.MetaData(),
Column( Column("id", key_type(orm_name), primary_key=True),
"encrypted_options", Column(
ConfigurationContainer.as_mutable( "encrypted_options",
EncryptedConfiguration(db.Text, old_secret, FernetEngine) ConfigurationContainer.as_mutable(
EncryptedConfiguration(db.Text, old_secret, FernetEngine)
),
),
)
table_for_update = sqlalchemy.Table(
table_name,
sqlalchemy.MetaData(),
Column("id", key_type(orm_name), primary_key=True),
Column(
"encrypted_options",
ConfigurationContainer.as_mutable(
EncryptedConfiguration(db.Text, new_secret, FernetEngine)
),
), ),
),
)
table_for_update = sqlalchemy.Table(
"data_sources",
sqlalchemy.MetaData(),
Column("id", db.Integer, primary_key=True),
Column(
"encrypted_options",
ConfigurationContainer.as_mutable(
EncryptedConfiguration(db.Text, new_secret, FernetEngine)
),
),
)
update = table_for_update.update()
data_sources = db.session.execute(select([table_for_select]))
for ds in data_sources:
stmt = update.where(table_for_update.c.id == ds["id"]).values(
encrypted_options=ds["encrypted_options"]
) )
db.session.execute(stmt)
data_sources.close() update = table_for_update.update()
db.session.commit() selected_items = db.session.execute(select([table_for_select]))
for item in selected_items:
stmt = update.where(table_for_update.c.id == item["id"]).values(
encrypted_options=item["encrypted_options"]
)
db.session.execute(stmt)
selected_items.close()
db.session.commit()
_reencrypt_for_table("data_sources", "DataSource")
_reencrypt_for_table("notification_destinations", "NotificationDestination")

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(
"Seen lately? %s (%d seconds ago). " "Worker %s healthcheck: Is busy? %s. "
"Has nothing to do? %s (%d jobs in watched queues). " "Seen lately? %s (%d seconds ago). "
"==> Is healthy? %s", "Has nothing to do? %s (%d jobs in watched queues). "
worker.key, is_busy, seen_lately, time_since_seen.seconds, "==> Is healthy? %s",
has_nothing_to_do, total_jobs_in_watched_queues, is_healthy) worker.key,
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

@@ -209,7 +209,7 @@ class QueryResultDropdownResource(BaseResource):
) )
require_access(query.data_source, current_user, view_only) require_access(query.data_source, current_user, view_only)
try: try:
return dropdown_values(query, self.current_org) return dropdown_values(query_id, self.current_org)
except QueryDetachedFromDataSourceError as e: except QueryDetachedFromDataSourceError as e:
abort(400, message=str(e)) abort(400, message=str(e))
@@ -224,14 +224,13 @@ class QueryDropdownsResource(BaseResource):
related_queries_ids = [ related_queries_ids = [
p["queryId"] for p in query.parameters if p["type"] == "query" p["queryId"] for p in query.parameters if p["type"] == "query"
] ]
dropdown_query = get_object_or_404(
models.Query.get_by_id_and_org, dropdown_query_id, self.current_org
)
if int(dropdown_query_id) not in related_queries_ids: if int(dropdown_query_id) not in related_queries_ids:
dropdown_query = get_object_or_404(
models.Query.get_by_id_and_org, dropdown_query_id, self.current_org
)
require_access(dropdown_query.data_source, current_user, view_only) require_access(dropdown_query.data_source, current_user, view_only)
return dropdown_values(dropdown_query, self.current_org) return dropdown_values(dropdown_query_id, self.current_org)
class QueryResultResource(BaseResource): class QueryResultResource(BaseResource):

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

@@ -5,7 +5,6 @@ from redash.utils import mustache_render, json_loads
from redash.permissions import require_access, view_only from redash.permissions import require_access, view_only
from funcy import distinct from funcy import distinct
from dateutil.parser import parse from dateutil.parser import parse
from redash import models
def _pluck_name_and_value(default_column, row): def _pluck_name_and_value(default_column, row):
@@ -16,18 +15,22 @@ def _pluck_name_and_value(default_column, row):
return {"name": row[name_column], "value": str(row[value_column])} return {"name": row[name_column], "value": str(row[value_column])}
def _load_result(query, org): def _load_result(query_id, org):
from redash import models
query = models.Query.get_by_id_and_org(query_id, org)
if query.data_source: if query.data_source:
query_result = models.QueryResult.get_by_id_and_org( query_result = models.QueryResult.get_by_id_and_org(
query.latest_query_data_id, org query.latest_query_data_id, org
) )
return query_result.data return query_result.data
else: else:
raise QueryDetachedFromDataSourceError(query.id) raise QueryDetachedFromDataSourceError(query_id)
def dropdown_values(query, org): def dropdown_values(query_id, org):
data = _load_result(query, org) data = _load_result(query_id, org)
first_column = data["columns"][0]["name"] first_column = data["columns"][0]["name"]
pluck = partial(_pluck_name_and_value, first_column) pluck = partial(_pluck_name_and_value, first_column)
return list(map(pluck, data["rows"])) return list(map(pluck, data["rows"]))
@@ -152,12 +155,6 @@ class ParameterizedQuery(object):
query_id = definition.get("queryId") query_id = definition.get("queryId")
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict) allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)
if definition["type"] == "query":
try:
query = models.Query.get_by_id_and_org(query_id, self.org)
except (models.NoResultFound):
return False
if isinstance(enum_options, str): if isinstance(enum_options, str):
enum_options = enum_options.split("\n") enum_options = enum_options.split("\n")
@@ -169,11 +166,9 @@ class ParameterizedQuery(object):
), ),
"query": lambda value: _is_value_within_options( "query": lambda value: _is_value_within_options(
value, value,
[v["value"] for v in dropdown_values(query, self.org)], [v["value"] for v in dropdown_values(query_id, self.org)],
allow_multiple_values, allow_multiple_values,
) ),
if not query.parameters
else True,
"date": _is_date, "date": _is_date,
"datetime-local": _is_date, "datetime-local": _is_date,
"datetime-with-seconds": _is_date, "datetime-with-seconds": _is_date,
@@ -188,18 +183,8 @@ class ParameterizedQuery(object):
@property @property
def is_safe(self): def is_safe(self):
for param in self.schema: text_parameters = [param for param in self.schema if param["type"] == "text"]
if param["type"] == "text": return not any(text_parameters)
return False
if param["type"] == "query":
try:
query = models.Query.get_by_id_and_org(
param.get("queryId"), self.org
)
return not query.parameters
except (models.NoResultFound):
return True
return True
@property @property
def missing_params(self): def missing_params(self):

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

@@ -60,4 +60,15 @@ def database_key_definitions(default):
# Since you can define custom primary key types using `database_key_definitions`, you may want to load certain extensions when creating the database. # 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,
@@ -248,7 +252,7 @@ class QueryExecutor(object):
def _log_progress(self, state): def _log_progress(self, state):
logger.info( logger.info(
"job=execute_query state=%s query_hash=%s type=%s ds_id=%d " "job=execute_query state=%s query_hash=%s type=%s ds_id=%d "
"job_id=%s queue=%s query_id=%s username=%s", "job_id=%s queue=%s query_id=%s username=%s",
state, state,
self.query_hash, self.query_hash,

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