mirror of
https://github.com/getredash/redash.git
synced 2025-12-20 01:47:39 -05:00
Compare commits
35 Commits
query-base
...
user-and-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dce31dd32 | ||
|
|
46e97a08cc | ||
|
|
640fea5e47 | ||
|
|
c865293aaa | ||
|
|
3d3f6b1916 | ||
|
|
0e1587a068 | ||
|
|
04edf16ed4 | ||
|
|
49536de1ed | ||
|
|
2f1394a6f4 | ||
|
|
911f398006 | ||
|
|
b0b1d6c81c | ||
|
|
23a279f318 | ||
|
|
e71ccf5de5 | ||
|
|
bb42e92cd0 | ||
|
|
4ec96caac5 | ||
|
|
829247c2d2 | ||
|
|
7d33af4343 | ||
|
|
84c2abed59 | ||
|
|
8b068dfd0b | ||
|
|
06eb868120 | ||
|
|
52ae7bedb2 | ||
|
|
fbe57de53c | ||
|
|
db0cb98ed3 | ||
|
|
dcdff66e62 | ||
|
|
d0793c4ba8 | ||
|
|
7b8bcdf356 | ||
|
|
c290864ccd | ||
|
|
b70e95a323 | ||
|
|
18ee5343aa | ||
|
|
fdf636a393 | ||
|
|
88c13868a3 | ||
|
|
aab11dc79b | ||
|
|
00c77cf36e | ||
|
|
6e2631dec2 | ||
|
|
4b88959341 |
@@ -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 ./
|
||||||
|
|||||||
@@ -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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import '~antd/lib/modal/style/index'; // for ant @vars
|
@import (reference, less) "~@/assets/less/ant"; // for ant @vars
|
||||||
|
|
||||||
.parameters-mapping-list {
|
.parameters-mapping-list {
|
||||||
.keyword {
|
.keyword {
|
||||||
@@ -63,7 +63,8 @@
|
|||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled, .fa {
|
&.disabled,
|
||||||
|
.fa {
|
||||||
color: #a4a4a4;
|
color: #a4a4a4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -23,19 +23,23 @@ export default class Parameters extends React.Component {
|
|||||||
static propTypes = {
|
static propTypes = {
|
||||||
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
|
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
|
||||||
editable: PropTypes.bool,
|
editable: PropTypes.bool,
|
||||||
|
sortable: PropTypes.bool,
|
||||||
disableUrlUpdate: PropTypes.bool,
|
disableUrlUpdate: PropTypes.bool,
|
||||||
onValuesChange: PropTypes.func,
|
onValuesChange: PropTypes.func,
|
||||||
onPendingValuesChange: PropTypes.func,
|
onPendingValuesChange: PropTypes.func,
|
||||||
onParametersEdit: PropTypes.func,
|
onParametersEdit: PropTypes.func,
|
||||||
|
appendSortableToParent: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
parameters: [],
|
parameters: [],
|
||||||
editable: false,
|
editable: false,
|
||||||
|
sortable: false,
|
||||||
disableUrlUpdate: false,
|
disableUrlUpdate: false,
|
||||||
onValuesChange: () => {},
|
onValuesChange: () => {},
|
||||||
onPendingValuesChange: () => {},
|
onPendingValuesChange: () => {},
|
||||||
onParametersEdit: () => {},
|
onParametersEdit: () => {},
|
||||||
|
appendSortableToParent: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -85,7 +89,7 @@ export default class Parameters extends React.Component {
|
|||||||
if (oldIndex !== newIndex) {
|
if (oldIndex !== newIndex) {
|
||||||
this.setState(({ parameters }) => {
|
this.setState(({ parameters }) => {
|
||||||
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
|
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
|
||||||
onParametersEdit();
|
onParametersEdit(parameters);
|
||||||
return { parameters };
|
return { parameters };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -110,7 +114,7 @@ export default class Parameters extends React.Component {
|
|||||||
this.setState(({ parameters }) => {
|
this.setState(({ parameters }) => {
|
||||||
const updatedParameter = extend(parameter, updated);
|
const updatedParameter = extend(parameter, updated);
|
||||||
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
||||||
onParametersEdit();
|
onParametersEdit(parameters);
|
||||||
return { parameters };
|
return { parameters };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -146,15 +150,17 @@ export default class Parameters extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { parameters } = this.state;
|
const { parameters } = this.state;
|
||||||
const { editable } = this.props;
|
const { sortable, appendSortableToParent } = this.props;
|
||||||
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
|
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableContainer
|
<SortableContainer
|
||||||
disabled={!editable}
|
disabled={!sortable}
|
||||||
axis="xy"
|
axis="xy"
|
||||||
useDragHandle
|
useDragHandle
|
||||||
lockToContainerEdges
|
lockToContainerEdges
|
||||||
helperClass="parameter-dragged"
|
helperClass="parameter-dragged"
|
||||||
|
helperContainer={containerEl => (appendSortableToParent ? containerEl : document.body)}
|
||||||
updateBeforeSortStart={this.onBeforeSortStart}
|
updateBeforeSortStart={this.onBeforeSortStart}
|
||||||
onSortEnd={this.moveParameter}
|
onSortEnd={this.moveParameter}
|
||||||
containerProps={{
|
containerProps={{
|
||||||
@@ -163,8 +169,11 @@ export default class Parameters extends React.Component {
|
|||||||
}}>
|
}}>
|
||||||
{parameters.map((param, index) => (
|
{parameters.map((param, index) => (
|
||||||
<SortableElement key={param.name} index={index}>
|
<SortableElement key={param.name} index={index}>
|
||||||
<div className="parameter-block" data-editable={editable || null}>
|
<div
|
||||||
{editable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
className="parameter-block"
|
||||||
|
data-editable={sortable || null}
|
||||||
|
data-test={`ParameterBlock-${param.name}`}>
|
||||||
|
{sortable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
||||||
{this.renderParameter(param, index)}
|
{this.renderParameter(param, index)}
|
||||||
</div>
|
</div>
|
||||||
</SortableElement>
|
</SortableElement>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
value={this.state.value}
|
value={this.state.value}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
options={map(options, ({ value, name }) => ({ label: String(name), value }))}
|
options={map(options, ({ value, name }) => ({ label: String(name), value }))}
|
||||||
optionFilterProp="children"
|
|
||||||
showSearch
|
showSearch
|
||||||
showArrow
|
showArrow
|
||||||
notFoundContent={isEmpty(options) ? "No options available" : null}
|
notFoundContent={isEmpty(options) ? "No options available" : null}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "~@/assets/less/ant";
|
@import (reference, less) "~@/assets/less/ant";
|
||||||
|
|
||||||
.tags-list {
|
.tags-list {
|
||||||
.tags-list-title {
|
.tags-list-title {
|
||||||
|
|||||||
@@ -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" }),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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%);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import '~antd/lib/button/style/index';
|
@import (reference, less) "~@/assets/less/ant";
|
||||||
|
|
||||||
.embed-query-dialog {
|
.embed-query-dialog {
|
||||||
label {
|
label {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "~@/assets/less/ant";
|
@import (reference, less) "~@/assets/less/ant";
|
||||||
|
|
||||||
.databricks-schema-browser {
|
.databricks-schema-browser {
|
||||||
.schema-control {
|
.schema-control {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -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" />,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
@import "variables";
|
@import "./variables";
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: '@{icomoon-font-family}';
|
font-family: "@{icomoon-font-family}";
|
||||||
src: url('@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm');
|
src: url("@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm");
|
||||||
src: url('@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm#iefix') format('embedded-opentype'),
|
src: url("@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm#iefix") format("embedded-opentype"),
|
||||||
url('@{icomoon-font-path}/@{icomoon-font-family}.ttf?ehpufm') format('truetype'),
|
url("@{icomoon-font-path}/@{icomoon-font-family}.ttf?ehpufm") format("truetype"),
|
||||||
url('@{icomoon-font-path}/@{icomoon-font-family}.woff?ehpufm') format('woff'),
|
url("@{icomoon-font-path}/@{icomoon-font-family}.woff?ehpufm") format("woff"),
|
||||||
url('@{icomoon-font-path}/@{icomoon-font-family}.svg?ehpufm#@{icomoon-font-family}') format('svg');
|
url("@{icomoon-font-path}/@{icomoon-font-family}.svg?ehpufm#@{icomoon-font-family}") format("svg");
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
i.icon {
|
i.icon {
|
||||||
/* use !important to prevent issues with browser extensions that change fonts */
|
/* use !important to prevent issues with browser extensions that change fonts */
|
||||||
font-family: '@{icomoon-font-family}' !important;
|
font-family: "@{icomoon-font-family}" !important;
|
||||||
speak: none;
|
speak: none;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@@ -36,4 +36,3 @@ i.icon {
|
|||||||
content: @icon-flash;
|
content: @icon-flash;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 = {}) {
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { createQueryAndAddWidget } from "../../support/dashboard";
|
|
||||||
|
|
||||||
describe("Parameter Mapping", () => {
|
|
||||||
beforeEach(function() {
|
|
||||||
cy.login();
|
|
||||||
cy.createDashboard("Foo Bar")
|
|
||||||
.then(({ id }) => {
|
|
||||||
this.dashboardId = id;
|
|
||||||
this.dashboardUrl = `/dashboards/${id}`;
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
const queryData = {
|
|
||||||
name: "Text Parameter",
|
|
||||||
query: "SELECT '{{test-parameter}}' AS parameter",
|
|
||||||
options: {
|
|
||||||
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "text", value: "example" }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
|
|
||||||
createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(widgetTestId => {
|
|
||||||
cy.visit(this.dashboardUrl);
|
|
||||||
this.widgetTestId = widgetTestId;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const openMappingOptions = (widgetTestId, paramName) => {
|
|
||||||
cy.getByTestId(widgetTestId).within(() => {
|
|
||||||
cy.getByTestId("WidgetDropdownButton").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.getByTestId("WidgetDropdownButtonMenu")
|
|
||||||
.contains("Edit Parameters")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.getByTestId(`EditParamMappingButton-${paramName}`).click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveMappingOptions = () => {
|
|
||||||
cy.getByTestId("EditParamMappingPopover").within(() => {
|
|
||||||
cy.contains("button", "OK").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.contains("button", "OK").click();
|
|
||||||
};
|
|
||||||
|
|
||||||
it("supports widget parameters", function() {
|
|
||||||
// widget parameter mapping is the default for the API
|
|
||||||
cy.getByTestId(this.widgetTestId).within(() => {
|
|
||||||
cy.getByTestId("TableVisualization").should("contain", "example");
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
|
||||||
.find("input")
|
|
||||||
.type("{selectall}Redash");
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
|
||||||
|
|
||||||
cy.getByTestId("TableVisualization").should("contain", "Redash");
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.getByTestId("DashboardParameters").should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports dashboard parameters", function() {
|
|
||||||
openMappingOptions(this.widgetTestId, "test-parameter");
|
|
||||||
|
|
||||||
cy.getByTestId("NewDashboardParameterOption").click();
|
|
||||||
|
|
||||||
saveMappingOptions();
|
|
||||||
|
|
||||||
cy.getByTestId(this.widgetTestId).within(() => {
|
|
||||||
cy.getByTestId("ParameterName-test-parameter").should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.getByTestId("DashboardParameters").within(() => {
|
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
|
||||||
.find("input")
|
|
||||||
.type("{selectall}DashboardParam");
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.getByTestId(this.widgetTestId).within(() => {
|
|
||||||
cy.getByTestId("TableVisualization").should("contain", "DashboardParam");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports static values for parameters", function() {
|
|
||||||
openMappingOptions(this.widgetTestId, "test-parameter");
|
|
||||||
|
|
||||||
cy.getByTestId("StaticValueOption").click();
|
|
||||||
|
|
||||||
cy.getByTestId("EditParamMappingPopover").within(() => {
|
|
||||||
cy.getByTestId("ParameterValueInput")
|
|
||||||
.find("input")
|
|
||||||
.type("{selectall}StaticValue");
|
|
||||||
});
|
|
||||||
|
|
||||||
saveMappingOptions();
|
|
||||||
|
|
||||||
cy.getByTestId(this.widgetTestId).within(() => {
|
|
||||||
cy.getByTestId("ParameterName-test-parameter").should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.getByTestId("DashboardParameters").should("not.exist");
|
|
||||||
|
|
||||||
cy.getByTestId(this.widgetTestId).within(() => {
|
|
||||||
cy.getByTestId("TableVisualization").should("contain", "StaticValue");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
164
client/cypress/integration/dashboard/parameter_spec.js
Normal file
164
client/cypress/integration/dashboard/parameter_spec.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { createQueryAndAddWidget, editDashboard } from "../../support/dashboard";
|
||||||
|
import { dragParam, expectParamOrder } from "../../support/parameters";
|
||||||
|
|
||||||
|
describe("Dashboard Parameters", () => {
|
||||||
|
const parameters = [
|
||||||
|
{ name: "param1", title: "Parameter 1", type: "text", value: "example1" },
|
||||||
|
{ name: "param2", title: "Parameter 2", type: "text", value: "example2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(function() {
|
||||||
|
cy.login();
|
||||||
|
cy.createDashboard("Foo Bar")
|
||||||
|
.then(({ id }) => {
|
||||||
|
this.dashboardId = id;
|
||||||
|
this.dashboardUrl = `/dashboards/${id}`;
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
const queryData = {
|
||||||
|
name: "Text Parameter",
|
||||||
|
query: "SELECT '{{param1}}', '{{param2}}' AS parameter",
|
||||||
|
options: {
|
||||||
|
parameters,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
|
||||||
|
createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(widgetTestId => {
|
||||||
|
cy.visit(this.dashboardUrl);
|
||||||
|
this.widgetTestId = widgetTestId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const openMappingOptions = widgetTestId => {
|
||||||
|
cy.getByTestId(widgetTestId).within(() => {
|
||||||
|
cy.getByTestId("WidgetDropdownButton").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.getByTestId("WidgetDropdownButtonMenu")
|
||||||
|
.contains("Edit Parameters")
|
||||||
|
.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMappingOptions = (closeMappingMenu = false) => {
|
||||||
|
return cy
|
||||||
|
.getByTestId("EditParamMappingPopover")
|
||||||
|
.filter(":visible")
|
||||||
|
.as("Popover")
|
||||||
|
.within(() => {
|
||||||
|
// This is needed to grant the element will have finished loading
|
||||||
|
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||||
|
cy.wait(500);
|
||||||
|
cy.contains("button", "OK").click();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (closeMappingMenu) {
|
||||||
|
cy.contains("button", "OK").click();
|
||||||
|
}
|
||||||
|
return cy.get("@Popover").should("not.be.visible");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setWidgetParametersToDashboard = parameters => {
|
||||||
|
cy.wrap(parameters).each(({ name: paramName }, i) => {
|
||||||
|
cy.getByTestId(`EditParamMappingButton-${paramName}`).click();
|
||||||
|
cy.getByTestId("NewDashboardParameterOption")
|
||||||
|
.filter(":visible")
|
||||||
|
.click();
|
||||||
|
return saveMappingOptions(i === parameters.length - 1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("supports widget parameters", function() {
|
||||||
|
// widget parameter mapping is the default for the API
|
||||||
|
cy.getByTestId(this.widgetTestId).within(() => {
|
||||||
|
cy.getByTestId("TableVisualization").should("contain", "example1");
|
||||||
|
|
||||||
|
cy.getByTestId("ParameterName-param1")
|
||||||
|
.find("input")
|
||||||
|
.type("{selectall}Redash");
|
||||||
|
|
||||||
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
|
cy.getByTestId("TableVisualization").should("contain", "Redash");
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.getByTestId("DashboardParameters").should("not.exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports dashboard parameters", function() {
|
||||||
|
openMappingOptions(this.widgetTestId);
|
||||||
|
setWidgetParametersToDashboard(parameters);
|
||||||
|
|
||||||
|
cy.getByTestId(this.widgetTestId).within(() => {
|
||||||
|
cy.getByTestId("ParameterName-param1").should("not.exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.getByTestId("DashboardParameters").within(() => {
|
||||||
|
cy.getByTestId("ParameterName-param1")
|
||||||
|
.find("input")
|
||||||
|
.type("{selectall}DashboardParam");
|
||||||
|
|
||||||
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.getByTestId(this.widgetTestId).within(() => {
|
||||||
|
cy.getByTestId("TableVisualization").should("contain", "DashboardParam");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports static values for parameters", function() {
|
||||||
|
openMappingOptions(this.widgetTestId);
|
||||||
|
cy.getByTestId("EditParamMappingButton-param1").click();
|
||||||
|
|
||||||
|
cy.getByTestId("StaticValueOption").click();
|
||||||
|
|
||||||
|
cy.getByTestId("EditParamMappingPopover").within(() => {
|
||||||
|
cy.getByTestId("ParameterValueInput")
|
||||||
|
.find("input")
|
||||||
|
.type("{selectall}StaticValue");
|
||||||
|
});
|
||||||
|
|
||||||
|
saveMappingOptions(true);
|
||||||
|
|
||||||
|
cy.getByTestId(this.widgetTestId).within(() => {
|
||||||
|
cy.getByTestId("ParameterName-param1").should("not.exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.getByTestId("DashboardParameters").should("not.exist");
|
||||||
|
|
||||||
|
cy.getByTestId(this.widgetTestId).within(() => {
|
||||||
|
cy.getByTestId("TableVisualization").should("contain", "StaticValue");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders parameters", function() {
|
||||||
|
// Reorder is only available in edit mode
|
||||||
|
editDashboard();
|
||||||
|
|
||||||
|
const [param1, param2] = parameters;
|
||||||
|
|
||||||
|
cy.getByTestId("ParameterBlock-param1")
|
||||||
|
.invoke("width")
|
||||||
|
.then(paramWidth => {
|
||||||
|
cy.server();
|
||||||
|
cy.route("POST", `**/api/dashboards/*`).as("SaveDashboard");
|
||||||
|
cy.route("POST", `**/api/widgets/*`).as("SaveWidget");
|
||||||
|
|
||||||
|
// Asserts widget param order
|
||||||
|
dragParam(param1.name, paramWidth, 1);
|
||||||
|
cy.wait("@SaveWidget");
|
||||||
|
cy.reload();
|
||||||
|
expectParamOrder([param2.title, param1.title]);
|
||||||
|
|
||||||
|
// Asserts dashboard param order
|
||||||
|
openMappingOptions(this.widgetTestId);
|
||||||
|
setWidgetParametersToDashboard(parameters);
|
||||||
|
cy.wait("@SaveWidget");
|
||||||
|
dragParam(param1.name, paramWidth, 1);
|
||||||
|
cy.wait("@SaveDashboard");
|
||||||
|
cy.reload();
|
||||||
|
expectParamOrder([param2.title, param1.title]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import { dragParam } from "../../support/parameters";
|
||||||
|
|
||||||
|
function openAndSearchAntdDropdown(testId, paramOption) {
|
||||||
|
cy.getByTestId(testId)
|
||||||
|
.find(".ant-select-selection-search-input")
|
||||||
|
.type(paramOption, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
describe("Parameter", () => {
|
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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
110
client/cypress/integration/visualizations/chart_spec.js
Normal file
110
client/cypress/integration/visualizations/chart_spec.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/* global cy */
|
||||||
|
|
||||||
|
import { getWidgetTestId } from "../../support/dashboard";
|
||||||
|
import {
|
||||||
|
assertAxesAndAddLabels,
|
||||||
|
assertPlotPreview,
|
||||||
|
assertTabbedEditor,
|
||||||
|
createChartThroughUI,
|
||||||
|
createDashboardWithCharts,
|
||||||
|
} from "../../support/visualizations/chart";
|
||||||
|
|
||||||
|
const SQL = `
|
||||||
|
SELECT 'a' AS stage, 11 AS value1, 22 AS value2 UNION ALL
|
||||||
|
SELECT 'a' AS stage, 12 AS value1, 41 AS value2 UNION ALL
|
||||||
|
SELECT 'a' AS stage, 45 AS value1, 93 AS value2 UNION ALL
|
||||||
|
SELECT 'a' AS stage, 54 AS value1, 79 AS value2 UNION ALL
|
||||||
|
SELECT 'b' AS stage, 33 AS value1, 65 AS value2 UNION ALL
|
||||||
|
SELECT 'b' AS stage, 73 AS value1, 50 AS value2 UNION ALL
|
||||||
|
SELECT 'b' AS stage, 90 AS value1, 40 AS value2 UNION ALL
|
||||||
|
SELECT 'c' AS stage, 19 AS value1, 33 AS value2 UNION ALL
|
||||||
|
SELECT 'c' AS stage, 92 AS value1, 14 AS value2 UNION ALL
|
||||||
|
SELECT 'c' AS stage, 63 AS value1, 65 AS value2 UNION ALL
|
||||||
|
SELECT 'c' AS stage, 44 AS value1, 27 AS value2\
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("Chart", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login();
|
||||||
|
cy.createQuery({ name: "Chart Visualization", query: SQL })
|
||||||
|
.its("id")
|
||||||
|
.as("queryId");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates Bar charts", function() {
|
||||||
|
cy.visit(`queries/${this.queryId}/source`);
|
||||||
|
cy.getByTestId("ExecuteButton").click();
|
||||||
|
|
||||||
|
const getBarChartAssertionFunction = (specificBarChartAssertionFn = () => {}) => () => {
|
||||||
|
// checks for TabbedEditor standard tabs
|
||||||
|
assertTabbedEditor();
|
||||||
|
|
||||||
|
// standard chart should be bar
|
||||||
|
cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar");
|
||||||
|
|
||||||
|
// checks the plot canvas exists and is empty
|
||||||
|
assertPlotPreview("not.exist");
|
||||||
|
|
||||||
|
// creates a chart and checks it is plotted
|
||||||
|
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
|
||||||
|
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
|
||||||
|
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2");
|
||||||
|
assertPlotPreview("exist");
|
||||||
|
|
||||||
|
specificBarChartAssertionFn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartTests = [
|
||||||
|
{
|
||||||
|
name: "Basic Bar Chart",
|
||||||
|
alias: "basicBarChart",
|
||||||
|
assertionFn: () => {
|
||||||
|
assertAxesAndAddLabels("Stage", "Value");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Horizontal Bar Chart",
|
||||||
|
alias: "horizontalBarChart",
|
||||||
|
assertionFn: () => {
|
||||||
|
cy.getByTestId("Chart.SwappedAxes").check();
|
||||||
|
cy.getByTestId("VisualizationEditor.Tabs.XAxis").should("have.text", "Y Axis");
|
||||||
|
cy.getByTestId("VisualizationEditor.Tabs.YAxis").should("have.text", "X Axis");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Stacked Bar Chart",
|
||||||
|
alias: "stackedBarChart",
|
||||||
|
assertionFn: () => {
|
||||||
|
cy.getByTestId("Chart.Stacking").selectAntdOption("Chart.Stacking.Stack");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Normalized Bar Chart",
|
||||||
|
alias: "normalizedBarChart",
|
||||||
|
assertionFn: () => {
|
||||||
|
cy.getByTestId("Chart.NormalizeValues").check();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
chartTests.forEach(({ name, alias, assertionFn }) => {
|
||||||
|
createChartThroughUI(name, getBarChartAssertionFunction(assertionFn)).as(alias);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartGetters = chartTests.map(({ alias }) => alias);
|
||||||
|
|
||||||
|
const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => {
|
||||||
|
cy.visit(dashboardUrl);
|
||||||
|
widgetGetters.forEach(widgetGetter => {
|
||||||
|
cy.get(`@${widgetGetter}`).then(widget => {
|
||||||
|
cy.getByTestId(getWidgetTestId(widget)).within(() => {
|
||||||
|
cy.get("g.points").should("exist");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn);
|
||||||
|
cy.percySnapshot("Visualizations - Charts - Bar");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import "@percy/cypress"; // eslint-disable-line import/no-extraneous-dependencies, import/no-unresolved
|
import "@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") => {
|
||||||
|
|||||||
13
client/cypress/support/parameters.js
Normal file
13
client/cypress/support/parameters.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function dragParam(paramName, offsetLeft, offsetTop) {
|
||||||
|
cy.getByTestId(`DragHandle-${paramName}`)
|
||||||
|
.trigger("mouseover")
|
||||||
|
.trigger("mousedown");
|
||||||
|
|
||||||
|
cy.get(".parameter-dragged .drag-handle")
|
||||||
|
.trigger("mousemove", offsetLeft, offsetTop, { force: true })
|
||||||
|
.trigger("mouseup", { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectParamOrder(expectedOrder) {
|
||||||
|
cy.get(".parameter-container label").each(($label, index) => expect($label).to.have.text(expectedOrder[index]));
|
||||||
|
}
|
||||||
100
client/cypress/support/visualizations/chart.js
Normal file
100
client/cypress/support/visualizations/chart.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Asserts the preview canvas exists, then captures the g.points element, which should be generated by plotly and asserts whether it exists
|
||||||
|
* @param should Passed to should expression after plot points are captured
|
||||||
|
*/
|
||||||
|
export function assertPlotPreview(should = "exist") {
|
||||||
|
cy.getByTestId("VisualizationPreview")
|
||||||
|
.find("g.plot")
|
||||||
|
.should("exist")
|
||||||
|
.find("g.points")
|
||||||
|
.should(should);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChartThroughUI(chartName, chartSpecificAssertionFn = () => {}) {
|
||||||
|
cy.getByTestId("NewVisualization").click();
|
||||||
|
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.CHART");
|
||||||
|
cy.getByTestId("VisualizationName")
|
||||||
|
.clear()
|
||||||
|
.type(chartName);
|
||||||
|
|
||||||
|
chartSpecificAssertionFn();
|
||||||
|
|
||||||
|
cy.server();
|
||||||
|
cy.route("POST", "**/api/visualizations").as("SaveVisualization");
|
||||||
|
|
||||||
|
cy.getByTestId("EditVisualizationDialog")
|
||||||
|
.contains("button", "Save")
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.getByTestId("QueryPageVisualizationTabs")
|
||||||
|
.contains("span", chartName)
|
||||||
|
.should("exist");
|
||||||
|
|
||||||
|
cy.wait("@SaveVisualization").should("have.property", "status", 200);
|
||||||
|
|
||||||
|
return cy.get("@SaveVisualization").then(xhr => {
|
||||||
|
const { id, name, options } = xhr.response.body;
|
||||||
|
return cy.wrap({ id, name, options });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () => {}) {
|
||||||
|
cy.getByTestId("Chart.GlobalSeriesType").should("exist");
|
||||||
|
|
||||||
|
cy.getByTestId("VisualizationEditor.Tabs.Series").click();
|
||||||
|
cy.getByTestId("VisualizationEditor")
|
||||||
|
.find("table")
|
||||||
|
.should("exist");
|
||||||
|
|
||||||
|
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
|
||||||
|
cy.getByTestId("VisualizationEditor")
|
||||||
|
.find("table")
|
||||||
|
.should("exist");
|
||||||
|
|
||||||
|
cy.getByTestId("VisualizationEditor.Tabs.DataLabels").click();
|
||||||
|
cy.getByTestId("VisualizationEditor")
|
||||||
|
.getByTestId("Chart.DataLabels.ShowDataLabels")
|
||||||
|
.should("exist");
|
||||||
|
|
||||||
|
chartSpecificTabbedEditorAssertionFn();
|
||||||
|
|
||||||
|
cy.getByTestId("VisualizationEditor.Tabs.General").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertAxesAndAddLabels(xaxisLabel, yaxisLabel) {
|
||||||
|
cy.getByTestId("VisualizationEditor.Tabs.XAxis").click();
|
||||||
|
cy.getByTestId("Chart.XAxis.Type")
|
||||||
|
.contains(".ant-select-selection-item", "Auto Detect")
|
||||||
|
.should("exist");
|
||||||
|
|
||||||
|
cy.getByTestId("Chart.XAxis.Name")
|
||||||
|
.clear()
|
||||||
|
.type(xaxisLabel);
|
||||||
|
|
||||||
|
cy.getByTestId("VisualizationEditor.Tabs.YAxis").click();
|
||||||
|
cy.getByTestId("Chart.LeftYAxis.Type")
|
||||||
|
.contains(".ant-select-selection-item", "Linear")
|
||||||
|
.should("exist");
|
||||||
|
|
||||||
|
cy.getByTestId("Chart.LeftYAxis.Name")
|
||||||
|
.clear()
|
||||||
|
.type(yaxisLabel);
|
||||||
|
|
||||||
|
cy.getByTestId("VisualizationEditor.Tabs.General").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDashboardWithCharts(title, chartGetters, widgetsAssertionFn = () => {}) {
|
||||||
|
cy.createDashboard(title).then(dashboard => {
|
||||||
|
const dashboardUrl = `/dashboards/${dashboard.id}`;
|
||||||
|
const widgetGetters = chartGetters.map(chartGetter => `${chartGetter}Widget`);
|
||||||
|
|
||||||
|
chartGetters.forEach((chartGetter, i) => {
|
||||||
|
const position = { autoHeight: false, sizeY: 8, sizeX: 3, col: (i % 2) * 3 };
|
||||||
|
cy.get(`@${chartGetter}`)
|
||||||
|
.then(chart => cy.addWidget(dashboard.id, chart.id, { position }))
|
||||||
|
.as(widgetGetters[i]);
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetsAssertionFn(widgetGetters, dashboardUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["cypress","@percy/cypress","@testing-library/cypress"]
|
"types": ["cypress", "@percy/cypress", "@testing-library/cypress"]
|
||||||
},
|
},
|
||||||
"include": ["./**/*.ts"]
|
"include": ["./**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
28
migrations/versions/0ec979123ba4_.py
Normal file
28
migrations/versions/0ec979123ba4_.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 0ec979123ba4
|
||||||
|
Revises: e5c7a4e2df4d
|
||||||
|
Create Date: 2020-12-23 21:35:32.766354
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0ec979123ba4'
|
||||||
|
down_revision = 'e5c7a4e2df4d'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('dashboards', sa.Column('options', postgresql.JSON(astext_type=sa.Text()), server_default='{}', nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('dashboards', 'options')
|
||||||
|
# ### end Alembic commands ###
|
||||||
24
migrations/versions/89bc7873a3e0_fix_multiple_heads.py
Normal file
24
migrations/versions/89bc7873a3e0_fix_multiple_heads.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""fix_multiple_heads
|
||||||
|
|
||||||
|
Revision ID: 89bc7873a3e0
|
||||||
|
Revises: 0ec979123ba4, d7d747033183
|
||||||
|
Create Date: 2021-01-21 18:11:04.312259
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '89bc7873a3e0'
|
||||||
|
down_revision = ('0ec979123ba4', 'd7d747033183')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""encrypt alert destinations
|
||||||
|
|
||||||
|
Revision ID: d7d747033183
|
||||||
|
Revises: e5c7a4e2df4d
|
||||||
|
Create Date: 2020-12-14 21:42:48.661684
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
from sqlalchemy.sql import table
|
||||||
|
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
|
||||||
|
|
||||||
|
from redash import settings
|
||||||
|
from redash.utils.configuration import ConfigurationContainer
|
||||||
|
from redash.models.base import key_type
|
||||||
|
from redash.models.types import (
|
||||||
|
EncryptedConfiguration,
|
||||||
|
Configuration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd7d747033183'
|
||||||
|
down_revision = 'e5c7a4e2df4d'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column(
|
||||||
|
"notification_destinations",
|
||||||
|
sa.Column("encrypted_options", postgresql.BYTEA(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# copy values
|
||||||
|
notification_destinations = table(
|
||||||
|
"notification_destinations",
|
||||||
|
sa.Column("id", key_type("NotificationDestination"), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"encrypted_options",
|
||||||
|
ConfigurationContainer.as_mutable(
|
||||||
|
EncryptedConfiguration(
|
||||||
|
sa.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
sa.Column("options", ConfigurationContainer.as_mutable(Configuration)),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
for dest in conn.execute(notification_destinations.select()):
|
||||||
|
conn.execute(
|
||||||
|
notification_destinations.update()
|
||||||
|
.where(notification_destinations.c.id == dest.id)
|
||||||
|
.values(encrypted_options=dest.options)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column("notification_destinations", "options")
|
||||||
|
op.alter_column("notification_destinations", "encrypted_options", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
2466
package-lock.json
generated
2466
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -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": {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.sql import select
|
|||||||
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
|
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
|
||||||
|
|
||||||
from redash import settings
|
from redash import settings
|
||||||
from redash.models.base import Column
|
from redash.models.base import Column, key_type
|
||||||
from redash.models.types import EncryptedConfiguration
|
from redash.models.types import EncryptedConfiguration
|
||||||
from redash.utils.configuration import ConfigurationContainer
|
from redash.utils.configuration import ConfigurationContainer
|
||||||
|
|
||||||
@@ -86,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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class Webhook(BaseDestination):
|
|||||||
"password": {"type": "string"},
|
"password": {"type": "string"},
|
||||||
},
|
},
|
||||||
"required": ["url"],
|
"required": ["url"],
|
||||||
"secret": ["password"],
|
"secret": ["password", "url"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class TreasureData(BaseQueryRunner):
|
|||||||
"default": False,
|
"default": False,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"secret": ["apikey"],
|
||||||
"required": ["apikey", "db"],
|
"required": ["apikey", "db"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 [],
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -61,3 +61,14 @@ def database_key_definitions(default):
|
|||||||
# Since you can define custom primary key types using `database_key_definitions`, you may want to load certain extensions when creating the database.
|
# Since you can define custom primary key types using `database_key_definitions`, you may want to load certain extensions when creating the database.
|
||||||
# To do so, simply add the name of the extension you'd like to load to this list.
|
# To do so, simply add the name of the extension you'd like to load to this list.
|
||||||
database_extensions = []
|
database_extensions = []
|
||||||
|
|
||||||
|
|
||||||
|
# If you'd like to limit the amount of concurrent query executions made by a certain org or user,
|
||||||
|
# implement this method by returning a boolean which would indicate if the limit has reached.
|
||||||
|
# If you return `True`, the query execution would move to a waiting list and would only be executed
|
||||||
|
# when a spot clears up for it within the defined capacity.
|
||||||
|
# `entity` is either "user" or "org".
|
||||||
|
# `executions` is the number of currently running query execution jobs for the specific user/org.
|
||||||
|
# `meta` is the query execution job's meta attribute.
|
||||||
|
def capacity_reached_for(entity, executions, meta):
|
||||||
|
return False
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
135
redash/tasks/capacity.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import re
|
||||||
|
import itertools
|
||||||
|
import logging
|
||||||
|
from rq import Queue, Worker
|
||||||
|
from rq.job import Job
|
||||||
|
from redash import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_waiting_lists():
|
||||||
|
"""
|
||||||
|
When a job is enqueued/dequeued to/from a CapacityQueue and it exceeds the org/user capacity, it is entered into a waiting list.
|
||||||
|
Later on, when a CapacityWorker finishes work on a job and a slot for a job on the waiting list becomes available, the worker will trigger the corresponding job
|
||||||
|
on the waiting list and re-queue it back to the original queue.
|
||||||
|
|
||||||
|
However, if a (non-horse) worker dies in the middle of execution, it will not trigger the next item on the waiting list. If there is any other
|
||||||
|
job for that org or user queued or executing, they will trigger those jobs eventually, but if no other jobs are queued or executing, the jobs
|
||||||
|
on the waiting list may never execute.
|
||||||
|
|
||||||
|
This periodic task looks at all waiting lists and sees if there are no triggers for any of them. In case no triggers are found, we can assume that
|
||||||
|
their worker died and re-enqueue them back into their original queues.
|
||||||
|
|
||||||
|
If a waiting list is empty, it can be deleted.
|
||||||
|
"""
|
||||||
|
queues = set(Queue.all())
|
||||||
|
waiting_lists = set([q for q in queues if q.name.endswith(":waiting")])
|
||||||
|
wip = itertools.chain(
|
||||||
|
*[
|
||||||
|
queue.started_job_registry.get_job_ids()
|
||||||
|
for queue in (queues - waiting_lists)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for waiting_list in waiting_lists:
|
||||||
|
trigger = next(
|
||||||
|
(j for j in wip if waiting_list.name.split(":origin")[0] in j), None
|
||||||
|
)
|
||||||
|
|
||||||
|
if trigger is None:
|
||||||
|
if waiting_list.is_empty():
|
||||||
|
logger.warning(
|
||||||
|
f"Waiting list {waiting_list.name} is empty and will be deleted."
|
||||||
|
)
|
||||||
|
waiting_list.delete()
|
||||||
|
else:
|
||||||
|
origin_name = re.findall(r"origin:(.*?):", waiting_list.name)[0]
|
||||||
|
logger.warning(
|
||||||
|
f"Waiting list {waiting_list.name} has no executing job to trigger it. Returning all jobs from the waiting list back to their original queue ({origin_name})."
|
||||||
|
)
|
||||||
|
origin = CapacityQueue(origin_name)
|
||||||
|
|
||||||
|
while waiting_list.count > 0:
|
||||||
|
job_id = waiting_list.pop_job_id()
|
||||||
|
job = Job.fetch(job_id)
|
||||||
|
origin.enqueue_job(job, at_front=True)
|
||||||
|
|
||||||
|
|
||||||
|
entity_key = lambda entity, job: f"{entity}:{job.meta[f'{entity}_id']}"
|
||||||
|
|
||||||
|
waiting_list_key = (
|
||||||
|
lambda entity, job, origin_name: f"{entity_key(entity, job)}:origin:{origin_name}:waiting"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CapacityQueue(Queue):
|
||||||
|
def find_waiting_list(self, job_ids, entity, job):
|
||||||
|
executions = sum(map(lambda job_id: entity_key(entity, job) in job_id, job_ids))
|
||||||
|
if settings.dynamic_settings.capacity_reached_for(entity, executions, job.meta):
|
||||||
|
waiting_list = waiting_list_key(entity, job, self.name)
|
||||||
|
logger.warning(
|
||||||
|
f"Moving job {job.id} to the {entity}'s waiting list ({waiting_list}) since {entity_key(entity, job)} is currently executing {executions} jobs and has reached the {entity} capacity."
|
||||||
|
)
|
||||||
|
return waiting_list
|
||||||
|
|
||||||
|
def enter_waiting_list(self, job, pipeline=None):
|
||||||
|
if job.meta.get("is_query_execution", False):
|
||||||
|
job_ids = self.started_job_registry.get_job_ids()
|
||||||
|
|
||||||
|
waiting_list = self.find_waiting_list(
|
||||||
|
job_ids, "user", job
|
||||||
|
) or self.find_waiting_list(job_ids, "org", job)
|
||||||
|
|
||||||
|
if waiting_list:
|
||||||
|
return Queue(waiting_list).enqueue_job(job, pipeline=pipeline)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def dequeue_any(cls, *args, **kwargs):
|
||||||
|
result = super(CapacityQueue, cls).dequeue_any(*args, **kwargs)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
job, queue = result
|
||||||
|
|
||||||
|
if queue.enter_waiting_list(job):
|
||||||
|
return cls.dequeue_any(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
return job, queue
|
||||||
|
|
||||||
|
def enqueue_job(self, job, pipeline=None, at_front=False):
|
||||||
|
return self.enter_waiting_list(job, pipeline) or super().enqueue_job(
|
||||||
|
job, pipeline=pipeline, at_front=at_front
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CapacityWorker(Worker):
|
||||||
|
queue_class = CapacityQueue
|
||||||
|
|
||||||
|
def _process_waiting_lists(self, queue, job):
|
||||||
|
if job.meta.get("is_query_execution", False):
|
||||||
|
waiting_lists = [
|
||||||
|
Queue(waiting_list_key("user", job, queue.name)),
|
||||||
|
Queue(waiting_list_key("org", job, queue.name)),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = Queue.dequeue_any(waiting_lists, None, job_class=self.job_class)
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
waiting_job, _ = result
|
||||||
|
logger.warning(
|
||||||
|
f"Moving job {waiting_job.id} from waiting list ({waiting_job.origin}) back to the original queue ({queue.name}) since an execution slot opened up for it."
|
||||||
|
)
|
||||||
|
queue.enqueue_job(waiting_job)
|
||||||
|
|
||||||
|
def handle_job_success(self, job, queue, started_job_registry):
|
||||||
|
try:
|
||||||
|
super().handle_job_success(job, queue, started_job_registry)
|
||||||
|
finally:
|
||||||
|
self._process_waiting_lists(queue, job)
|
||||||
|
|
||||||
|
def handle_job_failure(self, job, queue, started_job_registry=None, exc_string=""):
|
||||||
|
try:
|
||||||
|
super().handle_job_failure(job, queue, started_job_registry, exc_string)
|
||||||
|
finally:
|
||||||
|
self._process_waiting_lists(queue, job)
|
||||||
@@ -2,13 +2,10 @@ import requests
|
|||||||
from datetime import datetime
|
from 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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ from redash.tasks import (
|
|||||||
empty_schedules,
|
empty_schedules,
|
||||||
refresh_schemas,
|
refresh_schemas,
|
||||||
cleanup_query_results,
|
cleanup_query_results,
|
||||||
purge_failed_jobs,
|
|
||||||
version_check,
|
version_check,
|
||||||
send_aggregated_errors,
|
send_aggregated_errors,
|
||||||
Queue,
|
Queue,
|
||||||
|
cleanup_waiting_lists,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -72,12 +72,17 @@ def periodic_job_definitions():
|
|||||||
"func": refresh_schemas,
|
"func": refresh_schemas,
|
||||||
"interval": timedelta(minutes=settings.SCHEMAS_REFRESH_SCHEDULE),
|
"interval": timedelta(minutes=settings.SCHEMAS_REFRESH_SCHEDULE),
|
||||||
},
|
},
|
||||||
{"func": sync_user_details, "timeout": 60, "interval": timedelta(minutes=1),},
|
{
|
||||||
{"func": purge_failed_jobs, "timeout": 3600, "interval": timedelta(days=1)},
|
"func": sync_user_details,
|
||||||
|
"timeout": 60,
|
||||||
|
"interval": timedelta(minutes=1),
|
||||||
|
"result_ttl": 600,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"func": send_aggregated_errors,
|
"func": send_aggregated_errors,
|
||||||
"interval": timedelta(minutes=settings.SEND_FAILURE_EMAIL_INTERVAL),
|
"interval": timedelta(minutes=settings.SEND_FAILURE_EMAIL_INTERVAL),
|
||||||
},
|
},
|
||||||
|
{"func": cleanup_waiting_lists, "interval": timedelta(minutes=1)},
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.VERSION_CHECK:
|
if settings.VERSION_CHECK:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import errno
|
|||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import time
|
import time
|
||||||
|
from redash.tasks.capacity import CapacityQueue, CapacityWorker
|
||||||
from redash import statsd_client
|
from redash import statsd_client
|
||||||
from rq import Queue as BaseQueue, get_current_job
|
from rq import Queue as BaseQueue, get_current_job
|
||||||
from rq.worker import HerokuWorker # HerokuWorker implements graceful shutdown on SIGTERM
|
from rq.worker import HerokuWorker # HerokuWorker implements graceful shutdown on SIGTERM
|
||||||
@@ -37,7 +38,7 @@ class CancellableQueue(BaseQueue):
|
|||||||
job_class = CancellableJob
|
job_class = CancellableJob
|
||||||
|
|
||||||
|
|
||||||
class RedashQueue(StatsdRecordingQueue, CancellableQueue):
|
class RedashQueue(StatsdRecordingQueue, CancellableQueue, CapacityQueue):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -101,12 +102,13 @@ class HardLimitingWorker(HerokuWorker):
|
|||||||
)
|
)
|
||||||
self.kill_horse()
|
self.kill_horse()
|
||||||
|
|
||||||
def monitor_work_horse(self, job):
|
def monitor_work_horse(self, job, queue):
|
||||||
"""The worker will monitor the work horse and make sure that it
|
"""The worker will monitor the work horse and make sure that it
|
||||||
either executes successfully or the status of the job is set to
|
either executes successfully or the status of the job is set to
|
||||||
failed
|
failed
|
||||||
"""
|
"""
|
||||||
self.monitor_started = utcnow()
|
self.monitor_started = utcnow()
|
||||||
|
job.started_at = utcnow()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
with UnixSignalDeathPenalty(
|
with UnixSignalDeathPenalty(
|
||||||
@@ -158,12 +160,13 @@ class HardLimitingWorker(HerokuWorker):
|
|||||||
|
|
||||||
self.handle_job_failure(
|
self.handle_job_failure(
|
||||||
job,
|
job,
|
||||||
|
queue=queue,
|
||||||
exc_string="Work-horse process was terminated unexpectedly "
|
exc_string="Work-horse process was terminated unexpectedly "
|
||||||
"(waitpid returned %s)" % ret_val,
|
"(waitpid returned %s)" % ret_val,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RedashWorker(StatsdRecordingWorker, HardLimitingWorker):
|
class RedashWorker(StatsdRecordingWorker, HardLimitingWorker, CapacityWorker):
|
||||||
queue_class = RedashQueue
|
queue_class = RedashQueue
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class StatsdRecordingJobDecorator(rq_job): # noqa
|
|||||||
queue_class = RedashQueue
|
queue_class = RedashQueue
|
||||||
|
|
||||||
|
|
||||||
job = partial(StatsdRecordingJobDecorator, connection=rq_redis_connection)
|
job = partial(StatsdRecordingJobDecorator, connection=rq_redis_connection, failure_ttl=settings.JOB_DEFAULT_FAILURE_TTL)
|
||||||
|
|
||||||
|
|
||||||
class CurrentJobFilter(logging.Filter):
|
class CurrentJobFilter(logging.Filter):
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ psycopg2==2.8.3
|
|||||||
python-dateutil==2.8.0
|
python-dateutil==2.8.0
|
||||||
pytz>=2019.3
|
pytz>=2019.3
|
||||||
PyYAML==5.1.2
|
PyYAML==5.1.2
|
||||||
redis==3.3.11
|
redis==3.5.0
|
||||||
requests==2.21.0
|
requests==2.21.0
|
||||||
SQLAlchemy==1.3.10
|
SQLAlchemy==1.3.10
|
||||||
# We can't upgrade SQLAlchemy-Searchable version as newer versions require PostgreSQL > 9.6, but we target older versions at the moment.
|
# We can't upgrade SQLAlchemy-Searchable version as newer versions require PostgreSQL > 9.6, but we target older versions at the moment.
|
||||||
@@ -34,8 +34,9 @@ pyparsing==2.3.0
|
|||||||
SQLAlchemy-Utils==0.34.2
|
SQLAlchemy-Utils==0.34.2
|
||||||
sqlparse==0.3.0
|
sqlparse==0.3.0
|
||||||
statsd==3.3.0
|
statsd==3.3.0
|
||||||
|
greenlet==0.4.16
|
||||||
gunicorn==20.0.4
|
gunicorn==20.0.4
|
||||||
rq==1.1.0
|
rq==1.5.0
|
||||||
rq-scheduler==0.9.1
|
rq-scheduler==0.9.1
|
||||||
jsonschema==3.1.1
|
jsonschema==3.1.1
|
||||||
RestrictedPython==5.0
|
RestrictedPython==5.0
|
||||||
|
|||||||
@@ -28,3 +28,25 @@ class DashboardTest(BaseTestCase):
|
|||||||
list(Dashboard.all_tags(self.factory.org, self.factory.user)),
|
list(Dashboard.all_tags(self.factory.org, self.factory.user)),
|
||||||
[("tag1", 3), ("tag2", 2), ("tag3", 1)],
|
[("tag1", 3), ("tag2", 2), ("tag3", 1)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboardsByUser(BaseTestCase):
|
||||||
|
def test_returns_only_users_dashboards(self):
|
||||||
|
d = self.factory.create_dashboard(user=self.factory.user)
|
||||||
|
d2 = self.factory.create_dashboard(user=self.factory.create_user())
|
||||||
|
|
||||||
|
dashboards = Dashboard.by_user(self.factory.user)
|
||||||
|
|
||||||
|
# not using self.assertIn/NotIn because otherwise this fails :O
|
||||||
|
self.assertTrue(d in list(dashboards))
|
||||||
|
self.assertFalse(d2 in list(dashboards))
|
||||||
|
|
||||||
|
def test_returns_drafts_by_the_user(self):
|
||||||
|
d = self.factory.create_dashboard(is_draft=True)
|
||||||
|
d2 = self.factory.create_dashboard(is_draft=True, user=self.factory.create_user())
|
||||||
|
|
||||||
|
dashboards = Dashboard.by_user(self.factory.user)
|
||||||
|
|
||||||
|
# not using self.assertIn/NotIn because otherwise this fails :O
|
||||||
|
self.assertTrue(d in dashboards)
|
||||||
|
self.assertFalse(d2 in dashboards)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
from mock import patch, call
|
||||||
|
|
||||||
from pytz import utc
|
from pytz import utc
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
|
|
||||||
from redash.query_runner.mongodb import (
|
from redash.query_runner.mongodb import (
|
||||||
|
MongoDB,
|
||||||
parse_query_json,
|
parse_query_json,
|
||||||
parse_results,
|
parse_results,
|
||||||
_get_column_by_name,
|
_get_column_by_name,
|
||||||
@@ -12,6 +14,33 @@ from redash.query_runner.mongodb import (
|
|||||||
from redash.utils import json_dumps, parse_human_time
|
from redash.utils import json_dumps, parse_human_time
|
||||||
|
|
||||||
|
|
||||||
|
@patch("redash.query_runner.mongodb.pymongo.MongoClient")
|
||||||
|
class TestUserPassOverride(TestCase):
|
||||||
|
def test_username_password_present_overrides_username_from_uri(self, mongo_client):
|
||||||
|
config = {
|
||||||
|
"connectionString": "mongodb://localhost:27017/test",
|
||||||
|
"username": "test_user",
|
||||||
|
"password": "test_pass",
|
||||||
|
"dbName": "test"
|
||||||
|
}
|
||||||
|
mongo_qr = MongoDB(config)
|
||||||
|
_ = mongo_qr._get_db()
|
||||||
|
|
||||||
|
self.assertIn("username", mongo_client.call_args.kwargs)
|
||||||
|
self.assertIn("password", mongo_client.call_args.kwargs)
|
||||||
|
|
||||||
|
def test_username_password_absent_does_not_pass_args(self, mongo_client):
|
||||||
|
config = {
|
||||||
|
"connectionString": "mongodb://user:pass@localhost:27017/test",
|
||||||
|
"dbName": "test"
|
||||||
|
}
|
||||||
|
mongo_qr = MongoDB(config)
|
||||||
|
_ = mongo_qr._get_db()
|
||||||
|
|
||||||
|
self.assertNotIn("username", mongo_client.call_args.kwargs)
|
||||||
|
self.assertNotIn("password", mongo_client.call_args.kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TestParseQueryJson(TestCase):
|
class TestParseQueryJson(TestCase):
|
||||||
def test_ignores_non_isodate_fields(self):
|
def test_ignores_non_isodate_fields(self):
|
||||||
query = {"test": 1, "test_list": ["a", "b", "c"], "test_dict": {"a": 1, "b": 2}}
|
query = {"test": 1, "test_list": ["a", "b", "c"], "test_dict": {"a": 1, "b": 2}}
|
||||||
|
|||||||
184
tests/tasks/test_capacity.py
Normal file
184
tests/tasks/test_capacity.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
from mock import MagicMock, patch, call
|
||||||
|
from tests import BaseTestCase
|
||||||
|
|
||||||
|
from rq import push_connection, pop_connection, Queue
|
||||||
|
from rq.job import JobStatus
|
||||||
|
from redash import rq_redis_connection
|
||||||
|
from redash.tasks.capacity import CapacityWorker, CapacityQueue
|
||||||
|
from redash.tasks.worker import Job
|
||||||
|
|
||||||
|
|
||||||
|
def say_hello():
|
||||||
|
return "Hello!"
|
||||||
|
|
||||||
|
|
||||||
|
def create_job(job_id, **meta):
|
||||||
|
meta["is_query_execution"] = True
|
||||||
|
return Job.create(
|
||||||
|
say_hello,
|
||||||
|
id=f"org:{meta['org_id']}:user:{meta['user_id']}:id:{job_id}",
|
||||||
|
meta=meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapacityQueue(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
push_connection(rq_redis_connection)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
pop_connection()
|
||||||
|
rq_redis_connection.flushdb()
|
||||||
|
|
||||||
|
@patch("redash.settings.dynamic_settings.capacity_reached_for", return_value=True)
|
||||||
|
def test_redirects_to_user_waiting_list_if_over_capacity(self, _):
|
||||||
|
queue = CapacityQueue()
|
||||||
|
job_1 = queue.enqueue_job(create_job(1, org_id="Acme", user_id="John"))
|
||||||
|
job_2 = queue.enqueue_job(create_job(2, org_id="Acme", user_id="John"))
|
||||||
|
|
||||||
|
self.assertEqual(job_1.origin, "user:John:origin:default:waiting")
|
||||||
|
self.assertEqual(job_2.origin, "user:John:origin:default:waiting")
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"redash.settings.dynamic_settings.capacity_reached_for",
|
||||||
|
side_effect=[False, True, False, True],
|
||||||
|
)
|
||||||
|
def test_redirects_to_org_waiting_list_if_over_capacity(self, _):
|
||||||
|
queue = CapacityQueue()
|
||||||
|
job_1 = queue.enqueue_job(create_job(1, org_id="Acme", user_id="John"))
|
||||||
|
job_2 = queue.enqueue_job(create_job(2, org_id="Acme", user_id="Mark"))
|
||||||
|
|
||||||
|
self.assertEqual(job_1.origin, "org:Acme:origin:default:waiting")
|
||||||
|
self.assertEqual(job_2.origin, "org:Acme:origin:default:waiting")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapacityWorker(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
push_connection(rq_redis_connection)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
pop_connection()
|
||||||
|
rq_redis_connection.flushdb()
|
||||||
|
|
||||||
|
@patch("redash.settings.dynamic_settings.capacity_reached_for", return_value=True)
|
||||||
|
def test_always_handles_non_query_execution_jobs(self, _):
|
||||||
|
queue = CapacityQueue()
|
||||||
|
job = queue.enqueue(say_hello)
|
||||||
|
|
||||||
|
worker = CapacityWorker([queue])
|
||||||
|
worker.work(burst=True)
|
||||||
|
|
||||||
|
self.assertEqual(job.get_status(refresh=True), JobStatus.FINISHED)
|
||||||
|
|
||||||
|
def test_handles_job_if_within_capacity(self):
|
||||||
|
queue = CapacityQueue()
|
||||||
|
job = queue.enqueue_job(create_job(1, org_id="Acme", user_id="John"))
|
||||||
|
|
||||||
|
worker = CapacityWorker([queue])
|
||||||
|
worker.work(burst=True)
|
||||||
|
|
||||||
|
self.assertEqual(job.get_status(refresh=True), JobStatus.FINISHED)
|
||||||
|
|
||||||
|
def test_doesnt_handle_job_if_over_user_capacity(self):
|
||||||
|
queue = CapacityQueue()
|
||||||
|
job_1 = queue.enqueue_job(create_job(1, org_id="Acme", user_id="John"))
|
||||||
|
job_2 = queue.enqueue_job(create_job(2, org_id="Acme", user_id="John"))
|
||||||
|
|
||||||
|
worker = CapacityWorker([queue])
|
||||||
|
with patch(
|
||||||
|
"redash.settings.dynamic_settings.capacity_reached_for",
|
||||||
|
side_effect=[False, False, True],
|
||||||
|
):
|
||||||
|
worker.work(burst=True)
|
||||||
|
|
||||||
|
job_1.refresh()
|
||||||
|
self.assertEqual(job_1.get_status(), JobStatus.FINISHED)
|
||||||
|
|
||||||
|
job_2.refresh()
|
||||||
|
self.assertEqual(job_2.get_status(), JobStatus.QUEUED)
|
||||||
|
self.assertEqual(job_2.origin, "user:John:origin:default:waiting")
|
||||||
|
|
||||||
|
def test_doesnt_handle_job_if_over_org_capacity(self):
|
||||||
|
queue = CapacityQueue()
|
||||||
|
job_1 = queue.enqueue_job(create_job(1, org_id="Acme", user_id="John"))
|
||||||
|
job_2 = queue.enqueue_job(create_job(2, org_id="Acme", user_id="John"))
|
||||||
|
|
||||||
|
worker = CapacityWorker([queue])
|
||||||
|
with patch(
|
||||||
|
"redash.settings.dynamic_settings.capacity_reached_for",
|
||||||
|
side_effect=[False, False, False, True],
|
||||||
|
):
|
||||||
|
worker.work(burst=True)
|
||||||
|
|
||||||
|
job_1.refresh()
|
||||||
|
self.assertEqual(job_1.get_status(), JobStatus.FINISHED)
|
||||||
|
|
||||||
|
job_2.refresh()
|
||||||
|
self.assertEqual(job_2.get_status(), JobStatus.QUEUED)
|
||||||
|
self.assertEqual(job_2.origin, "org:Acme:origin:default:waiting")
|
||||||
|
|
||||||
|
def test_isolates_capacity_between_original_queues(self):
|
||||||
|
queries_queue = CapacityQueue("queries")
|
||||||
|
adhoc_query = queries_queue.enqueue_job(
|
||||||
|
create_job(1, org_id="Acme", user_id="John")
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled_queries_queue = CapacityQueue("scheduled_queries")
|
||||||
|
scheduled_query = scheduled_queries_queue.enqueue_job(
|
||||||
|
create_job(2, org_id="Acme", user_id="John")
|
||||||
|
)
|
||||||
|
|
||||||
|
worker = CapacityWorker([queries_queue, scheduled_queries_queue])
|
||||||
|
with patch(
|
||||||
|
"redash.settings.dynamic_settings.capacity_reached_for", return_value=True
|
||||||
|
):
|
||||||
|
worker.work(burst=True)
|
||||||
|
|
||||||
|
adhoc_query.refresh()
|
||||||
|
self.assertEqual(adhoc_query.get_status(), JobStatus.QUEUED)
|
||||||
|
self.assertEqual(adhoc_query.origin, "user:John:origin:queries:waiting")
|
||||||
|
|
||||||
|
scheduled_query.refresh()
|
||||||
|
self.assertEqual(scheduled_query.get_status(), JobStatus.QUEUED)
|
||||||
|
self.assertEqual(
|
||||||
|
scheduled_query.origin, "user:John:origin:scheduled_queries:waiting"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_handles_waiting_user_jobs_when_user_slot_opens_up(self):
|
||||||
|
user_waiting_list = Queue("user:John:origin:default:waiting")
|
||||||
|
user_waiting_job = user_waiting_list.enqueue_job(
|
||||||
|
create_job(1, org_id="Acme", user_id="John")
|
||||||
|
)
|
||||||
|
|
||||||
|
org_waiting_list = Queue("org:Acme")
|
||||||
|
org_waiting_job = org_waiting_list.enqueue_job(
|
||||||
|
create_job(2, org_id="Acme", user_id="Mark", original_queue="default")
|
||||||
|
)
|
||||||
|
|
||||||
|
queue = CapacityQueue()
|
||||||
|
job = queue.enqueue_job(create_job(3, org_id="Acme", user_id="John"))
|
||||||
|
|
||||||
|
worker = CapacityWorker([queue])
|
||||||
|
worker.work(max_jobs=2)
|
||||||
|
|
||||||
|
user_waiting_job.refresh()
|
||||||
|
self.assertEqual(user_waiting_job.get_status(), JobStatus.FINISHED)
|
||||||
|
self.assertEqual(user_waiting_job.origin, "default")
|
||||||
|
|
||||||
|
org_waiting_job.refresh()
|
||||||
|
self.assertEqual(org_waiting_job.get_status(), JobStatus.QUEUED)
|
||||||
|
|
||||||
|
def test_handles_waiting_org_jobs_when_org_job_opens_up(self):
|
||||||
|
org_waiting_list = Queue("org:Acme:origin:default:waiting")
|
||||||
|
org_waiting_job = org_waiting_list.enqueue_job(
|
||||||
|
create_job(1, org_id="Acme", user_id="Mark")
|
||||||
|
)
|
||||||
|
|
||||||
|
queue = CapacityQueue()
|
||||||
|
job = queue.enqueue_job(create_job(2, org_id="Acme", user_id="John"))
|
||||||
|
|
||||||
|
worker = CapacityWorker([queue])
|
||||||
|
worker.work(max_jobs=2)
|
||||||
|
|
||||||
|
org_waiting_job.refresh()
|
||||||
|
self.assertEqual(org_waiting_job.get_status(), JobStatus.FINISHED)
|
||||||
|
self.assertEqual(org_waiting_job.origin, "default")
|
||||||
20
tests/test_migrations.py
Normal file
20
tests/test_migrations.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import os
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_single_head_revision_in_migrations():
|
||||||
|
"""
|
||||||
|
If multiple developers are working on migrations and one of them is merged before the
|
||||||
|
other you might end up with multiple heads (multiple revisions with the same down_revision).
|
||||||
|
|
||||||
|
This makes sure that there is only a single head revision in the migrations directory.
|
||||||
|
|
||||||
|
Adopted from https://blog.jerrycodes.com/multiple-heads-in-alembic-migrations/.
|
||||||
|
"""
|
||||||
|
config = Config(os.path.join("migrations", 'alembic.ini'))
|
||||||
|
config.set_main_option('script_location', "migrations")
|
||||||
|
script = ScriptDirectory.from_config(config)
|
||||||
|
|
||||||
|
# This will raise if there are multiple heads
|
||||||
|
script.get_current_head()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@babel/plugin-proposal-class-properties",
|
"@babel/plugin-proposal-class-properties",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": "./",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exclude": ["dist", "lib"]
|
|
||||||
}
|
|
||||||
2161
viz-lib/package-lock.json
generated
2161
viz-lib/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user