Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions client/app/lib/hooks/useRefreshCooldown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { currentUser, clientConfig } from "@/services/auth";
import notification from "@/services/notification";

function getCooldownSeconds() {
return currentUser.isAdmin ? 0 : clientConfig.nonAdminRefreshCooldown || 0;
}

function warnCooldown(seconds) {
notification.warning(`Refresh is on cooldown. Please wait ${seconds} seconds.`, null, { duration: 3 });
}

// Remaining cooldown in seconds for a given retrieval time. Pure — no React state — so the
// execution/refresh path can gate on click without subscribing to a per-second timer.
export function getRefreshCooldownRemaining(retrievedAt) {
const cooldownSeconds = getCooldownSeconds();
if (cooldownSeconds <= 0 || !retrievedAt) return 0;
const retrievedTime = new Date(retrievedAt).getTime();
if (!retrievedTime) return 0;
const elapsed = Math.floor((Date.now() - retrievedTime) / 1000);
return Math.max(0, cooldownSeconds - elapsed);
}

// Shows the cooldown notification when active. Returns true if on cooldown (caller should abort).
export function notifyRefreshCooldown(retrievedAt) {
const remaining = getRefreshCooldownRemaining(retrievedAt);
if (remaining > 0) {
warnCooldown(remaining);
return true;
}
return false;
}

// Ticking countdown for display. Mount inside a small leaf component (see RefreshCooldownLabel)
// so the per-second state update only re-renders that label, not the whole page.
export default function useRefreshCooldown(retrievedAt) {
const [remainingTime, setRemainingTime] = useState(() => getRefreshCooldownRemaining(retrievedAt));
const timerRef = useRef(null);

useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current);

const remaining = getRefreshCooldownRemaining(retrievedAt);
setRemainingTime(remaining);

if (remaining > 0) {
timerRef.current = setInterval(() => {
const r = getRefreshCooldownRemaining(retrievedAt);
setRemainingTime(r);
if (r <= 0) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, 1000);
}

return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [retrievedAt]);
Comment thread
seungoh-lee marked this conversation as resolved.

const isCooldownActive = remainingTime > 0;

const notifyCooldown = useCallback(() => {
if (isCooldownActive) {
warnCooldown(remainingTime);
}
}, [isCooldownActive, remainingTime]);

return {
isCooldownActive,
remainingTime,
notifyCooldown,
};
}
37 changes: 31 additions & 6 deletions client/app/pages/dashboards/components/DashboardHeader.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";
import React, { useMemo } from "react";
import cx from "classnames";
import PropTypes from "prop-types";
import { map, includes } from "lodash";
import { map, includes, compact, max } from "lodash";
import Button from "antd/lib/button";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
Expand All @@ -18,6 +18,7 @@ import { policy } from "@/services/policy";
import recordEvent from "@/services/recordEvent";
import { durationHumanize } from "@/lib/utils";
import { DashboardStatusEnum } from "../hooks/useDashboard";
import useRefreshCooldown from "@/lib/hooks/useRefreshCooldown";

import "./DashboardHeader.less";

Expand Down Expand Up @@ -64,24 +65,48 @@ DashboardPageTitle.propTypes = {
};

function RefreshButton({ dashboardConfiguration }) {
const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardConfiguration;
const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard, dashboard } = dashboardConfiguration;

const latestRetrievedAt = useMemo(() => {
const timestamps = compact(
map(dashboard.widgets, (w) => {
const qr = w.getQueryResult();
return qr ? qr.getUpdatedAt() : null;
})
);
return timestamps.length > 0 ? max(timestamps) : null;
}, [dashboard.widgets, refreshing]); // eslint-disable-line react-hooks/exhaustive-deps

const { isCooldownActive, remainingTime, notifyCooldown } = useRefreshCooldown(latestRetrievedAt);
const allowedIntervals = policy.getDashboardRefreshIntervals();
const refreshRateOptions = clientConfig.dashboardRefreshIntervals;
const handleRefresh = () => {
if (isCooldownActive) {
notifyCooldown();
return;
}
refreshDashboard();
};
const onRefreshRateSelected = ({ key }) => {
const parsedRefreshRate = parseFloat(key);
if (parsedRefreshRate) {
setRefreshRate(parsedRefreshRate);
refreshDashboard();
handleRefresh();
} else {
disableRefreshRate();
}
};
const refreshLabel = isCooldownActive
? `Refresh (${remainingTime}s)`
: refreshRate
? durationHumanize(refreshRate)
: "Refresh";
return (
<Button.Group>
<Tooltip title={refreshRate ? `Auto Refreshing every ${durationHumanize(refreshRate)}` : null}>
<Button type={buttonType(refreshRate)} onClick={() => refreshDashboard()}>
<Button type={buttonType(refreshRate)} onClick={handleRefresh}>
<i className={cx("zmdi zmdi-refresh m-r-5", { "zmdi-hc-spin": refreshing })} aria-hidden="true" />
{refreshRate ? durationHumanize(refreshRate) : "Refresh"}
{refreshLabel}
</Button>
</Tooltip>
<Dropdown
Expand Down
19 changes: 16 additions & 3 deletions client/app/pages/queries/QuerySource.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ import useUpdateQuery from "./hooks/useUpdateQuery";
import useUpdateQueryDescription from "./hooks/useUpdateQueryDescription";
import useUnsavedChangesAlert from "./hooks/useUnsavedChangesAlert";

import { notifyRefreshCooldown } from "@/lib/hooks/useRefreshCooldown";
import RefreshCooldownLabel from "./components/RefreshCooldownLabel";

import "./components/QuerySourceDropdown"; // register QuerySourceDropdown
import "./QuerySource.less";

Expand Down Expand Up @@ -76,6 +79,7 @@ function QuerySource(props) {
} = useQueryExecute(query);

const queryResultData = useQueryResultData(queryResult);
const retrievedAt = queryResult ? queryResult.getUpdatedAt() : null;

const editorRef = useRef(null);
const [autocompleteAvailable, autocompleteEnabled, toggleAutocomplete] = useAutocompleteFlags(schema);
Expand Down Expand Up @@ -164,6 +168,9 @@ function QuerySource(props) {
if (!queryFlags.canExecute || (!skipParametersDirtyFlag && (areParametersDirty || isQueryExecuting))) {
return;
}
if (notifyRefreshCooldown(retrievedAt)) {
Comment thread
seungoh-lee marked this conversation as resolved.
return;
}
if (isDirty || !isEmpty(selectedText)) {
executeQuery(null, () => {
return query.getQueryResultByText(0, selectedText);
Expand All @@ -172,7 +179,7 @@ function QuerySource(props) {
executeQuery();
}
},
[query, queryFlags.canExecute, areParametersDirty, isQueryExecuting, isDirty, selectedText, executeQuery]
[query, queryFlags.canExecute, areParametersDirty, isQueryExecuting, isDirty, selectedText, executeQuery, retrievedAt]
);

const [isQuerySaving, setIsQuerySaving] = useState(false);
Expand Down Expand Up @@ -300,7 +307,11 @@ function QuerySource(props) {
shortcut: "mod+enter, alt+enter, ctrl+enter, shift+enter",
onClick: doExecuteQuery,
text: (
<span className="hidden-xs">{selectedText === null ? "Execute" : "Execute Selected"}</span>
<span className="hidden-xs">
<RefreshCooldownLabel retrievedAt={retrievedAt} label="Execute">
{selectedText === null ? "Execute" : "Execute Selected"}
</RefreshCooldownLabel>
</span>
),
}}
autocompleteToggleProps={{
Expand Down Expand Up @@ -393,7 +404,9 @@ function QuerySource(props) {
loading={isQueryExecuting}
onClick={doExecuteQuery}>
{!isQueryExecuting && <i className="zmdi zmdi-refresh m-r-5" aria-hidden="true" />}
Refresh Now
<RefreshCooldownLabel retrievedAt={retrievedAt} label="Refresh Now">
Refresh Now
</RefreshCooldownLabel>
</Button>
}
/>
Expand Down
17 changes: 14 additions & 3 deletions client/app/pages/queries/QueryView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ import useEditScheduleDialog from "./hooks/useEditScheduleDialog";
import useEditVisualizationDialog from "./hooks/useEditVisualizationDialog";
import useDeleteVisualization from "./hooks/useDeleteVisualization";
import useFullscreenHandler from "../../lib/hooks/useFullscreenHandler";
import { notifyRefreshCooldown } from "@/lib/hooks/useRefreshCooldown";

import RefreshCooldownLabel from "./components/RefreshCooldownLabel";

import "./QueryView.less";

Expand Down Expand Up @@ -64,6 +67,7 @@ function QueryView(props) {
} = useQueryExecute(query);

const queryResultData = useQueryResultData(queryResult);
const retrievedAt = queryResult ? queryResult.getUpdatedAt() : null;

const updateQueryDescription = useUpdateQueryDescription(query, setQuery);
const editSchedule = useEditScheduleDialog(query, setQuery);
Expand All @@ -79,9 +83,12 @@ function QueryView(props) {
if (!queryFlags.canExecute || (!skipParametersDirtyFlag && (areParametersDirty || isExecuting))) {
return;
}
if (notifyRefreshCooldown(retrievedAt)) {
Comment thread
seungoh-lee marked this conversation as resolved.
return;
}
executeQuery();
},
[areParametersDirty, executeQuery, isExecuting, queryFlags.canExecute]
[areParametersDirty, executeQuery, isExecuting, queryFlags.canExecute, retrievedAt]
);

useEffect(() => {
Expand Down Expand Up @@ -113,7 +120,9 @@ function QueryView(props) {
shortcut="mod+enter, alt+enter, ctrl+enter"
disabled={!queryFlags.canExecute || isExecuting || areParametersDirty}
onClick={doExecuteQuery}>
Refresh
<RefreshCooldownLabel retrievedAt={retrievedAt} label="Refresh">
Refresh
</RefreshCooldownLabel>
</QueryViewButton>
)}
</DynamicComponent>
Expand Down Expand Up @@ -179,7 +188,9 @@ function QueryView(props) {
loading={isExecuting}
onClick={doExecuteQuery}>
{!isExecuting && <i className="zmdi zmdi-refresh m-r-5" aria-hidden="true" />}
Refresh Now
<RefreshCooldownLabel retrievedAt={retrievedAt} label="Refresh Now">
Refresh Now
</RefreshCooldownLabel>
</Button>
)
}
Expand Down
21 changes: 21 additions & 0 deletions client/app/pages/queries/components/RefreshCooldownLabel.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from "react";
import PropTypes from "prop-types";
import useRefreshCooldown from "@/lib/hooks/useRefreshCooldown";

// Button label that appends a live `(Ns)` countdown while a refresh cooldown is active.
// It owns the per-second countdown state, so during cooldown only this label re-renders —
// the surrounding page (editor, visualizations) stays off the 1Hz render path.
export default function RefreshCooldownLabel({ retrievedAt, label, children }) {
const { isCooldownActive, remainingTime } = useRefreshCooldown(retrievedAt);
return <React.Fragment>{isCooldownActive ? `${label} (${remainingTime}s)` : children}</React.Fragment>;
}

RefreshCooldownLabel.propTypes = {
retrievedAt: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
label: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};

RefreshCooldownLabel.defaultProps = {
retrievedAt: null,
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import Checkbox from "antd/lib/checkbox";
import Form from "antd/lib/form";
import InputNumber from "antd/lib/input-number";
import Row from "antd/lib/row";
import Skeleton from "antd/lib/skeleton";
import DynamicComponent from "@/components/DynamicComponent";
Expand Down Expand Up @@ -49,6 +50,22 @@ export default function FeatureFlagsSettings(props) {
</>
)}
</Form.Item>
<Form.Item label="Non-Admin Refresh Cooldown (seconds)">
{loading ? (
<Skeleton.Input active size="small" />
) : (
<>
<InputNumber
min={0}
value={values.non_admin_refresh_cooldown}
onChange={value => onChange({ non_admin_refresh_cooldown: value })}
/>
<div className="m-t-5" style={{ color: "#888" }}>
Non-admin users cannot refresh queries or dashboards more frequently than this interval. Set to 0 to disable.
</div>
</>
)}
</Form.Item>
</DynamicComponent>
);
}
Expand Down
1 change: 1 addition & 0 deletions client/app/pages/settings/hooks/useOrganizationSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default function useOrganizationSettings({ onError }) {
dateFormat: currentValues.date_format,
timeFormat: currentValues.time_format,
dateTimeFormat: `${currentValues.date_format} ${currentValues.time_format}`,
nonAdminRefreshCooldown: currentValues.non_admin_refresh_cooldown,
});
})
.catch(handleError)
Expand Down
1 change: 1 addition & 0 deletions redash/handlers/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ def client_config():
"ldapLoginEnabled": settings.LDAP_LOGIN_ENABLED,
"pageSize": settings.PAGE_SIZE,
"pageSizeOptions": settings.PAGE_SIZE_OPTIONS,
"nonAdminRefreshCooldown": current_org.get_setting("non_admin_refresh_cooldown"),
"tableCellMaxJSONSize": settings.TABLE_CELL_MAX_JSON_SIZE,
}

Expand Down
6 changes: 6 additions & 0 deletions redash/handlers/query_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ def run_query(query, parameters, data_source, query_id, should_apply_auto_limit,
if not data_source:
return error_messages["no_data_source"]

# Enforce refresh cooldown for non-admin users
if not current_user.has_permission("admin"):
cooldown = current_user.org.get_setting("non_admin_refresh_cooldown")
if cooldown and cooldown > 0 and (max_age == 0 or (0 < max_age < cooldown)):
max_age = cooldown

if data_source.paused:
if data_source.pause_reason:
message = "{} is paused ({}). Please try later.".format(data_source.name, data_source.pause_reason)
Expand Down
1 change: 1 addition & 0 deletions redash/settings/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,5 @@
"send_email_on_failed_scheduled_queries": SEND_EMAIL_ON_FAILED_SCHEDULED_QUERIES,
"hide_plotly_mode_bar": HIDE_PLOTLY_MODE_BAR,
"disable_public_urls": DISABLE_PUBLIC_URLS,
"non_admin_refresh_cooldown": int(os.environ.get("REDASH_NON_ADMIN_REFRESH_COOLDOWN", 0)),
}
Loading