diff --git a/web/ui/mantine-ui/src/pages/query/QueryPage.tsx b/web/ui/mantine-ui/src/pages/query/QueryPage.tsx index 696cac8aa9..c6b3d3b7af 100644 --- a/web/ui/mantine-ui/src/pages/query/QueryPage.tsx +++ b/web/ui/mantine-ui/src/pages/query/QueryPage.tsx @@ -5,19 +5,37 @@ import { IconPlus, } from "@tabler/icons-react"; import { useAppDispatch, useAppSelector } from "../../state/hooks"; -import { addPanel } from "../../state/queryPageSlice"; +import { addPanel, setPanels } from "../../state/queryPageSlice"; import Panel from "./QueryPanel"; import { LabelValuesResult } from "../../api/responseTypes/labelValues"; import { useAPIQuery } from "../../api/api"; import { useEffect, useState } from "react"; import { InstantQueryResult } from "../../api/responseTypes/query"; import { humanizeDuration } from "../../lib/formatTime"; +import { decodePanelOptionsFromURLParams } from "./urlStateEncoding"; export default function QueryPage() { const panels = useAppSelector((state) => state.queryPage.panels); const dispatch = useAppDispatch(); const [timeDelta, setTimeDelta] = useState(0); + useEffect(() => { + const handleURLChange = () => { + const panels = decodePanelOptionsFromURLParams(window.location.search); + if (panels.length > 0) { + dispatch(setPanels(panels)); + } + }; + + handleURLChange(); + + window.addEventListener("popstate", handleURLChange); + + return () => { + window.removeEventListener("popstate", handleURLChange); + }; + }, [dispatch]); + const { data: metricNamesResult, error: metricNamesError } = useAPIQuery({ path: "/label/__name__/values", diff --git a/web/ui/mantine-ui/src/pages/query/QueryPanel.tsx b/web/ui/mantine-ui/src/pages/query/QueryPanel.tsx index 6dac332afa..f887c2af35 100644 --- a/web/ui/mantine-ui/src/pages/query/QueryPanel.tsx +++ b/web/ui/mantine-ui/src/pages/query/QueryPanel.tsx @@ -33,6 +33,8 @@ import ExpressionInput from "./ExpressionInput"; import Graph from "./Graph"; import { formatPrometheusDuration, + formatTimestamp, + now, parsePrometheusDuration, } from "../../lib/formatTime"; diff --git a/web/ui/mantine-ui/src/pages/query/UPlotChart.tsx b/web/ui/mantine-ui/src/pages/query/UPlotChart.tsx index 580b28136c..407cda12bd 100644 --- a/web/ui/mantine-ui/src/pages/query/UPlotChart.tsx +++ b/web/ui/mantine-ui/src/pages/query/UPlotChart.tsx @@ -225,7 +225,6 @@ const autoPadLeft = ( ); if (longestVal != "") { - console.log("axis.font", axis.font![0]); u.ctx.font = axis.font![0]; axisSize += u.ctx.measureText(longestVal).width / devicePixelRatio; } diff --git a/web/ui/mantine-ui/src/pages/query/urlStateEncoding.ts b/web/ui/mantine-ui/src/pages/query/urlStateEncoding.ts new file mode 100644 index 0000000000..616b4ff634 --- /dev/null +++ b/web/ui/mantine-ui/src/pages/query/urlStateEncoding.ts @@ -0,0 +1,109 @@ +import { + GraphDisplayMode, + Panel, + newDefaultPanel, +} from "../../state/queryPageSlice"; +import dayjs from "dayjs"; +import { + formatPrometheusDuration, + parsePrometheusDuration, +} from "../../lib/formatTime"; + +export function parseTime(timeText: string): number { + return dayjs.utc(timeText).valueOf(); +} + +export const decodePanelOptionsFromURLParams = (query: string): Panel[] => { + const urlParams = new URLSearchParams(query); + const panels = []; + + for (let i = 0; ; i++) { + if (!urlParams.has(`g${i}.expr`)) { + // Every panel should have an expr, so if we don't find one, we're done. + break; + } + + const panel = newDefaultPanel(); + + const decodeSetting = (setting: string, fn: (_value: string) => void) => { + const param = `g${i}.${setting}`; + if (urlParams.has(param)) { + fn(urlParams.get(param) as string); + } + }; + + decodeSetting("expr", (value) => { + panel.expr = value; + }); + decodeSetting("tab", (value) => { + panel.visualizer.activeTab = value === "0" ? "graph" : "table"; + }); + decodeSetting("display_mode", (value) => { + panel.visualizer.displayMode = value as GraphDisplayMode; + }); + decodeSetting("stacked", (value) => { + panel.visualizer.displayMode = + value === "1" ? GraphDisplayMode.Stacked : GraphDisplayMode.Lines; + }); + decodeSetting("show_exemplars", (value) => { + panel.visualizer.showExemplars = value === "1"; + }); + decodeSetting("range_input", (value) => { + panel.visualizer.range = + parsePrometheusDuration(value) || panel.visualizer.range; + }); + decodeSetting("end_input", (value) => { + panel.visualizer.endTime = parseTime(value); + }); + decodeSetting("moment_input", (value) => { + panel.visualizer.endTime = parseTime(value); + }); + decodeSetting("step_input", (value) => { + if (parseInt(value) > 0) { + panel.visualizer.resolution = { + type: "custom", + value: parseInt(value) * 1000, + }; + } + }); + + panels.push(panel); + } + + return panels; +}; + +export function formatTime(time: number): string { + return dayjs.utc(time).format("YYYY-MM-DD HH:mm:ss"); +} + +export const encodePanelOptionsToURLParams = ( + panels: Panel[] +): URLSearchParams => { + const params = new URLSearchParams(); + + const addParam = (idx: number, param: string, value: string) => + params.append(`g${idx}.${param}`, value); + + panels.forEach((p, idx) => { + addParam(idx, "expr", p.expr); + addParam(idx, "tab", p.visualizer.activeTab === "graph" ? "0" : "1"); + if (p.visualizer.endTime !== null) { + addParam(idx, "end_input", formatTime(p.visualizer.endTime)); + addParam(idx, "moment_input", formatTime(p.visualizer.endTime)); + } + addParam(idx, "range_input", formatPrometheusDuration(p.visualizer.range)); + // TODO: Support the other new resolution types. + if (p.visualizer.resolution.type === "custom") { + addParam( + idx, + "step_input", + (p.visualizer.resolution.value / 1000).toString() + ); + } + addParam(idx, "display_mode", p.visualizer.displayMode); + addParam(idx, "show_exemplars", p.visualizer.showExemplars ? "1" : "0"); + }); + + return params; +}; diff --git a/web/ui/mantine-ui/src/state/queryPageSlice.ts b/web/ui/mantine-ui/src/state/queryPageSlice.ts index 885f61ac66..375ba770d1 100644 --- a/web/ui/mantine-ui/src/state/queryPageSlice.ts +++ b/web/ui/mantine-ui/src/state/queryPageSlice.ts @@ -1,5 +1,11 @@ import { randomId } from "@mantine/hooks"; -import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { + PayloadAction, + createListenerMiddleware, + createSlice, +} from "@reduxjs/toolkit"; +import { encodePanelOptionsToURLParams } from "../pages/query/urlStateEncoding"; +import { update } from "lodash"; export enum GraphDisplayMode { Lines = "lines", @@ -69,7 +75,7 @@ interface QueryPageState { panels: Panel[]; } -const newDefaultPanel = (): Panel => ({ +export const newDefaultPanel = (): Panel => ({ id: randomId(), expr: "", exprStale: false, @@ -88,32 +94,44 @@ const initialState: QueryPageState = { panels: [newDefaultPanel()], }; +const updateURL = (panels: Panel[]) => { + const query = "?" + encodePanelOptionsToURLParams(panels).toString(); + window.history.pushState({}, "", query); +}; + export const queryPageSlice = createSlice({ name: "queryPage", initialState, reducers: { + setPanels: (state, { payload }: PayloadAction) => { + state.panels = payload; + }, addPanel: (state) => { state.panels.push(newDefaultPanel()); + updateURL(state.panels); }, removePanel: (state, { payload }: PayloadAction) => { state.panels.splice(payload, 1); + updateURL(state.panels); }, setExpr: ( state, { payload }: PayloadAction<{ idx: number; expr: string }> ) => { state.panels[payload.idx].expr = payload.expr; + updateURL(state.panels); }, setVisualizer: ( state, { payload }: PayloadAction<{ idx: number; visualizer: Visualizer }> ) => { state.panels[payload.idx].visualizer = payload.visualizer; + updateURL(state.panels); }, }, }); -export const { addPanel, removePanel, setExpr, setVisualizer } = +export const { setPanels, addPanel, removePanel, setExpr, setVisualizer } = queryPageSlice.actions; export default queryPageSlice.reducer;