diff --git a/client/app/lib/hooks/useRefreshCooldown.js b/client/app/lib/hooks/useRefreshCooldown.js new file mode 100644 index 0000000000..cf04f44bad --- /dev/null +++ b/client/app/lib/hooks/useRefreshCooldown.js @@ -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]); + + const isCooldownActive = remainingTime > 0; + + const notifyCooldown = useCallback(() => { + if (isCooldownActive) { + warnCooldown(remainingTime); + } + }, [isCooldownActive, remainingTime]); + + return { + isCooldownActive, + remainingTime, + notifyCooldown, + }; +} diff --git a/client/app/pages/dashboards/components/DashboardHeader.jsx b/client/app/pages/dashboards/components/DashboardHeader.jsx index 86d7e12aee..95af44a89b 100644 --- a/client/app/pages/dashboards/components/DashboardHeader.jsx +++ b/client/app/pages/dashboards/components/DashboardHeader.jsx @@ -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"; @@ -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"; @@ -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 ( -