diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx index 6ca90a9217..5d50f3fce5 100644 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx @@ -178,6 +178,10 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({ } }, [stateQuery, awaitStateQuery]); + useEffect(() => { + setStateQuery(query || []); + }, [query]); + return <div className={classNames({ "vm-query-configurator": true, diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/hooks/useSetQueryParams.ts b/app/vmui/packages/vmui/src/pages/CustomPanel/hooks/useSetQueryParams.ts index 0b3f22feb4..60f7f916f8 100644 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/hooks/useSetQueryParams.ts +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/hooks/useSetQueryParams.ts @@ -1,12 +1,18 @@ -import { useEffect } from "react"; -import { useTimeState } from "../../../state/time/TimeStateContext"; -import { useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext"; -import { useAppState } from "../../../state/common/StateContext"; -import { useQueryState } from "../../../state/query/QueryStateContext"; +import { useEffect, useState } from "react"; +import { useTimeDispatch, useTimeState } from "../../../state/time/TimeStateContext"; +import { useCustomPanelDispatch, useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext"; +import { useAppDispatch, useAppState } from "../../../state/common/StateContext"; +import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext"; import { displayTypeTabs } from "../DisplayTypeSwitch"; -import { compactObject } from "../../../utils/object"; -import { useGraphState } from "../../../state/graph/GraphStateContext"; +import { useGraphDispatch, useGraphState } from "../../../state/graph/GraphStateContext"; import { useSearchParams } from "react-router-dom"; +import { useCallback } from "preact/compat"; +import { getInitialDisplayType } from "../../../state/customPanel/reducer"; +import { getInitialTimeState } from "../../../state/time/reducer"; +import useEventListener from "../../../hooks/useEventListener"; +import { getQueryArray } from "../../../utils/query-string"; +import { arrayEquals } from "../../../utils/array"; +import { isEqualURLSearchParams } from "../../../utils/url"; export const useSetQueryParams = () => { const { tenantId } = useAppState(); @@ -14,25 +20,108 @@ export const useSetQueryParams = () => { const { query } = useQueryState(); const { duration, relativeTime, period: { date, step } } = useTimeState(); const { customStep } = useGraphState(); - const [, setSearchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); + + const dispatch = useAppDispatch(); + const timeDispatch = useTimeDispatch(); + const graphDispatch = useGraphDispatch(); + const queryDispatch = useQueryDispatch(); + const customPanelDispatch = useCustomPanelDispatch(); + + const [isPopstate, setIsPopstate] = useState(false); + + const setterSearchParams = useCallback(() => { + if (isPopstate) { + // After the popstate event, the states synchronizes with the searchParams, + // so there's no need to refresh the searchParams again. + setIsPopstate(false); + return; + } + + const newSearchParams = new URLSearchParams(searchParams); - const setSearchParamsFromState = () => { - const params: Record<string, unknown> = {}; query.forEach((q, i) => { const group = `g${i}`; - params[`${group}.expr`] = q; - params[`${group}.range_input`] = duration; - params[`${group}.end_input`] = date; - params[`${group}.tab`] = displayTypeTabs.find(t => t.value === displayType)?.prometheusCode || 0; - params[`${group}.relative_time`] = relativeTime; - params[`${group}.tenantID`] = tenantId; + if ((searchParams.get(`${group}.expr`) !== q) && q) { + newSearchParams.set(`${group}.expr`, q); + } - if ((step !== customStep) && customStep) params[`${group}.step_input`] = customStep; + if (searchParams.get(`${group}.range_input`) !== duration) { + newSearchParams.set(`${group}.range_input`, duration); + } + + if (searchParams.get(`${group}.end_input`) !== date) { + newSearchParams.set(`${group}.end_input`, date); + } + + if (searchParams.get(`${group}.relative_time`) !== relativeTime) { + newSearchParams.set(`${group}.relative_time`, relativeTime || "none"); + } + + const stepFromUrl = searchParams.get(`${group}.step_input`) || step; + if (stepFromUrl && (stepFromUrl !== customStep)) { + newSearchParams.set(`${group}.step_input`, customStep); + } + + const displayTypeCode = `${displayTypeTabs.find(t => t.value === displayType)?.prometheusCode || 0}`; + if (searchParams.get(`${group}.tab`) !== displayTypeCode) { + newSearchParams.set(`${group}.tab`, `${displayTypeCode}`); + } + + if (searchParams.get(`${group}.tenantID`) !== tenantId && tenantId) { + newSearchParams.set(`${group}.tenantID`, tenantId); + } }); + if (isEqualURLSearchParams(newSearchParams, searchParams) || !newSearchParams.size) return; + setSearchParams(newSearchParams); + }, [tenantId, displayType, query, duration, relativeTime, date, step, customStep]); - setSearchParams(compactObject(params) as Record<string, string>); - }; + useEffect(() => { + const timer = setTimeout(setterSearchParams, 200); + return () => clearTimeout(timer); + }, [setterSearchParams]); - useEffect(setSearchParamsFromState, [tenantId, displayType, query, duration, relativeTime, date, step, customStep]); - useEffect(setSearchParamsFromState, []); + useEffect(() => { + // Synchronize the states with searchParams only after the popstate event. + if (!isPopstate) return; + + const timeFromUrl = getInitialTimeState(); + const isDurationDifferent = (timeFromUrl.duration !== duration); + const isRelativeTimeDifferent = timeFromUrl.relativeTime !== relativeTime; + const isDateDifferent = timeFromUrl.relativeTime === "none" && timeFromUrl.period.date !== date; + const someNotEqual = isDurationDifferent || isRelativeTimeDifferent || isDateDifferent; + if (someNotEqual) { + timeDispatch({ type: "SET_TIME_STATE", payload: timeFromUrl }); + } + + const displayTypeFromUrl = getInitialDisplayType(); + if (displayTypeFromUrl !== displayType) { + customPanelDispatch({ type: "SET_DISPLAY_TYPE", payload: displayTypeFromUrl }); + } + + const tenantIdFromUrl = searchParams.get("g0.tenantID") || ""; + if (tenantIdFromUrl !== tenantId) { + dispatch({ type: "SET_TENANT_ID", payload: tenantIdFromUrl }); + } + + const queryFromUrl = getQueryArray(); + if (!arrayEquals(queryFromUrl, query)) { + queryDispatch({ type: "SET_QUERY", payload: queryFromUrl }); + timeDispatch({ type: "RUN_QUERY" }); + } + + // Timer prevents customStep reset on time range change. + const timer = setTimeout(() => { + const customStepFromUrl = searchParams.get("g0.step_input") || step; + if (customStepFromUrl && customStepFromUrl !== customStep) { + graphDispatch({ type: "SET_CUSTOM_STEP", payload: customStepFromUrl }); + } + }, 50); + + return () => clearTimeout(timer); + }, [searchParams, isPopstate]); + + useEventListener("popstate", () => { + setIsPopstate(true); + }); }; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx index 64536551c1..cf56a8c407 100644 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx @@ -12,7 +12,6 @@ import Alert from "../../components/Main/Alert/Alert"; import classNames from "classnames"; import useDeviceDetect from "../../hooks/useDeviceDetect"; import InstantQueryTip from "./InstantQueryTip/InstantQueryTip"; -import useEventListener from "../../hooks/useEventListener"; import { useRef } from "react"; import CustomPanelTraces from "./CustomPanelTraces/CustomPanelTraces"; import WarningLimitSeries from "./WarningLimitSeries/WarningLimitSeries"; @@ -65,9 +64,6 @@ const CustomPanel: FC = () => { setHideError(false); }; - const handleChangePopstate = () => window.location.reload(); - useEventListener("popstate", handleChangePopstate); - useEffect(() => { graphDispatch({ type: "SET_IS_HISTOGRAM", payload: isHistogram }); }, [graphData]); diff --git a/app/vmui/packages/vmui/src/state/customPanel/reducer.ts b/app/vmui/packages/vmui/src/state/customPanel/reducer.ts index b80aaeaa30..720a4645f6 100644 --- a/app/vmui/packages/vmui/src/state/customPanel/reducer.ts +++ b/app/vmui/packages/vmui/src/state/customPanel/reducer.ts @@ -19,12 +19,16 @@ export type CustomPanelAction = | { type: "TOGGLE_QUERY_TRACING" } | { type: "TOGGLE_TABLE_COMPACT" } -const queryTab = getQueryStringValue("g0.tab", 0) as string; -const displayType = displayTypeTabs.find(t => t.prometheusCode === +queryTab || t.value === queryTab); +export const getInitialDisplayType = () => { + const queryTab = getQueryStringValue("g0.tab", 0) as string; + const displayType = displayTypeTabs.find(t => t.prometheusCode === +queryTab || t.value === queryTab); + return displayType?.value || DisplayType.chart; +}; + const limitsStorage = getFromStorage("SERIES_LIMITS") as string; export const initialCustomPanelState: CustomPanelState = { - displayType: (displayType?.value || DisplayType.chart), + displayType: getInitialDisplayType(), nocache: false, isTracingEnabled: false, seriesLimits: limitsStorage ? JSON.parse(limitsStorage) : DEFAULT_MAX_SERIES, diff --git a/app/vmui/packages/vmui/src/state/time/reducer.ts b/app/vmui/packages/vmui/src/state/time/reducer.ts index 75d3231b1a..92ef5ef210 100644 --- a/app/vmui/packages/vmui/src/state/time/reducer.ts +++ b/app/vmui/packages/vmui/src/state/time/reducer.ts @@ -21,6 +21,7 @@ export interface TimeState { } export type TimeAction = + | { type: "SET_TIME_STATE", payload: { duration: string, period: TimeParams, relativeTime?: string; } } | { type: "SET_DURATION", payload: string } | { type: "SET_RELATIVE_TIME", payload: {id: string, duration: string, until: Date} } | { type: "SET_PERIOD", payload: TimePeriod } @@ -32,24 +33,35 @@ export type TimeAction = const timezone = getFromStorage("TIMEZONE") as string || getBrowserTimezone().region; setTimezone(timezone); -const defaultDuration = getQueryStringValue("g0.range_input") as string; +export const getInitialTimeState = () => { + const defaultDuration = getQueryStringValue("g0.range_input") as string; -const { duration, endInput, relativeTimeId } = getRelativeTime({ - defaultDuration: defaultDuration || "1h", - defaultEndInput: formatDateToLocal(getQueryStringValue("g0.end_input", getDateNowUTC()) as string), - relativeTimeId: defaultDuration ? getQueryStringValue("g0.relative_time", "none") as string : undefined -}); + const { duration, endInput, relativeTimeId } = getRelativeTime({ + defaultDuration: defaultDuration || "1h", + defaultEndInput: formatDateToLocal(getQueryStringValue("g0.end_input", getDateNowUTC()) as string), + relativeTimeId: defaultDuration ? getQueryStringValue("g0.relative_time", "none") as string : undefined + }); + + return { + duration, + period: getTimeperiodForDuration(duration, endInput), + relativeTime: relativeTimeId, + }; +}; export const initialTimeState: TimeState = { - duration, - period: getTimeperiodForDuration(duration, endInput), - relativeTime: relativeTimeId, + ...getInitialTimeState(), timezone, }; export function reducer(state: TimeState, action: TimeAction): TimeState { switch (action.type) { + case "SET_TIME_STATE": + return { + ...state, + ...action.payload + }; case "SET_DURATION": return { ...state, diff --git a/app/vmui/packages/vmui/src/utils/url.ts b/app/vmui/packages/vmui/src/utils/url.ts index 8704fdbf61..e804b07a05 100644 --- a/app/vmui/packages/vmui/src/utils/url.ts +++ b/app/vmui/packages/vmui/src/utils/url.ts @@ -11,3 +11,17 @@ export const isValidHttpUrl = (str: string): boolean => { }; export const removeTrailingSlash = (url: string) => url.replace(/\/$/, ""); + +export const isEqualURLSearchParams = (params1: URLSearchParams, params2: URLSearchParams): boolean => { + if (Array.from(params1.entries()).length !== Array.from(params2.entries()).length) { + return false; + } + + for (const [key, value] of params1) { + if (params2.get(key) !== value) { + return false; + } + } + + return true; +}; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ab2791aa9d..89de24e0fe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -50,6 +50,7 @@ See also [LTS releases](https://docs.victoriametrics.com/lts-releases/). * BUGFIX: [vmui](https://docs.victoriametrics.com/#vmui): fix bug that prevents the first query trace from expanding on click event. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6186). The issue was introduced in [v1.100.0](https://docs.victoriametrics.com/changelog/#v11000) release. * BUGFIX: [vmui](https://docs.victoriametrics.com/#vmui): fix calendar display when `UTC+00:00` timezone is set. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6239). * BUGFIX: [vmui](https://docs.victoriametrics.com/#vmui): remove redundant requests on the `Explore Cardinality` page. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6240). +* BUGFIX: [vmui](https://docs.victoriametrics.com/#vmui): fix handling of URL params for browser history navigation (back and forward buttons). See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6126) and [this comment](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5516#issuecomment-1867507232). * BUGFIX: [vmagent](https://docs.victoriametrics.com/vmagent/): prevent potential panic during [stream aggregation](https://docs.victoriametrics.com/stream-aggregation.html) if more than one `--remoteWrite.streamAggr.dedupInterval` is configured. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6205). * BUGFIX: [vmagent](https://docs.victoriametrics.com/vmagent/): skip empty data blocks before sending to the remote write destination. Thanks to @viperstars for [the pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/6241). * BUGFIX: [stream aggregation](https://docs.victoriametrics.com/stream-aggregation/): set correct suffix `<output>_prometheus` for aggregation outputs [increase_prometheus](https://docs.victoriametrics.com/stream-aggregation/#increase_prometheus) and [total_prometheus](https://docs.victoriametrics.com/stream-aggregation/#total_prometheus). Before, outputs `total` and `total_prometheus` or `increase` and `increase_prometheus` had the same suffix.