diff --git a/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx b/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx index 6e96b3cee..4831ba7ec 100644 --- a/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx +++ b/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx @@ -430,3 +430,23 @@ export const ListIcon = () => ( ); + +export const StarBorderIcon = () => ( + + + +); + +export const StarIcon = () => ( + + + +); diff --git a/app/vmui/packages/vmui/src/constants/graph.ts b/app/vmui/packages/vmui/src/constants/graph.ts index 48147c639..97d0fcd27 100644 --- a/app/vmui/packages/vmui/src/constants/graph.ts +++ b/app/vmui/packages/vmui/src/constants/graph.ts @@ -1,6 +1,7 @@ import { GraphSize, SeriesItemStats } from "../types"; export const MAX_QUERY_FIELDS = 4; +export const MAX_QUERIES_HISTORY = 25; export const DEFAULT_MAX_SERIES = { table: 100, chart: 20, diff --git a/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts b/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts index 4ab13b24d..a4c39f56c 100644 --- a/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts +++ b/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts @@ -125,7 +125,7 @@ export const useFetchQuery = ({ } isHistogramResult = isDisplayChart && isHistogramData(resp.data.result); - seriesLimit = isHistogramResult ? Infinity : Math.max(totalLength, defaultLimit); + seriesLimit = isHistogramResult ? Infinity : defaultLimit; const freeTempSize = seriesLimit - tempData.length; resp.data.result.slice(0, freeTempSize).forEach((d: MetricBase) => { d.group = counter; @@ -140,7 +140,7 @@ export const useFetchQuery = ({ counter++; } - const limitText = `Showing ${seriesLimit} series out of ${totalLength} series due to performance reasons. Please narrow down the query, so it returns less series`; + const limitText = `Showing ${tempData.length} series out of ${totalLength} series due to performance reasons. Please narrow down the query, so it returns less series`; setWarning(totalLength > seriesLimit ? limitText : ""); isDisplayChart ? setGraphData(tempData as MetricResult[]) : setLiveData(tempData as InstantMetricResult[]); setTraces(tempTraces); 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 badf930ee..7648ffc0b 100644 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx @@ -2,7 +2,7 @@ import React, { FC, StateUpdater, useEffect, useState } from "preact/compat"; import QueryEditor from "../../../components/Configurators/QueryEditor/QueryEditor"; import AdditionalSettings from "../../../components/Configurators/AdditionalSettings/AdditionalSettings"; import usePrevious from "../../../hooks/usePrevious"; -import { MAX_QUERY_FIELDS } from "../../../constants/graph"; +import { MAX_QUERIES_HISTORY, MAX_QUERY_FIELDS } from "../../../constants/graph"; import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext"; import { useTimeDispatch } from "../../../state/time/TimeStateContext"; import { @@ -22,7 +22,7 @@ import { arrayEquals } from "../../../utils/array"; import useDeviceDetect from "../../../hooks/useDeviceDetect"; import { QueryStats } from "../../../api/types"; import { usePrettifyQuery } from "./hooks/usePrettifyQuery"; -import QueryHistoryList from "../QueryHistory/QueryHistoryList"; +import QueryHistory from "../QueryHistory/QueryHistory"; export interface QueryConfiguratorProps { queryErrors: string[]; @@ -66,7 +66,7 @@ const QueryConfigurator: FC = ({ const newValues = !queryEqual && q ? [...h.values, q] : h.values; // limit the history - if (newValues.length > 25) newValues.shift(); + if (newValues.length > MAX_QUERIES_HISTORY) newValues.shift(); return { index: h.values.length - Number(queryEqual), @@ -243,10 +243,7 @@ const QueryConfigurator: FC = ({ - + {stateQuery.length < MAX_QUERY_FIELDS && ( void +} + +export const HistoryTabTypes = { + session: "session", + storage: "saved", + favorite: "favorite", +}; + +export const historyTabs = [ + { label: "Session history", value: HistoryTabTypes.session }, + { label: "Saved history", value: HistoryTabTypes.storage }, + { label: "Favorite queries", value: HistoryTabTypes.favorite }, +]; + +const QueryHistory: FC = ({ handleSelectQuery }) => { + const { queryHistory: historyState } = useQueryState(); + const { isMobile } = useDeviceDetect(); + + const { + value: openModal, + setTrue: handleOpenModal, + setFalse: handleCloseModal, + } = useBoolean(false); + + const [activeTab, setActiveTab] = useState(historyTabs[0].value); + const [historyStorage, setHistoryStorage] = useState(getQueriesFromStorage("QUERY_HISTORY")); + const [historyFavorites, setHistoryFavorites] = useState(getQueriesFromStorage("QUERY_FAVORITES")); + + const historySession = useMemo(() => { + return historyState.map((h) => h.values.filter(q => q).reverse()); + }, [historyState]); + + const list = useMemo(() => { + switch (activeTab) { + case HistoryTabTypes.favorite: + return historyFavorites; + case HistoryTabTypes.storage: + return historyStorage; + default: + return historySession; + } + }, [activeTab, historyFavorites, historyStorage, historySession]); + + const isNoData = list?.every(s => !s.length); + + const noDataText = useMemo(() => { + switch (activeTab) { + case HistoryTabTypes.favorite: + return "Favorites queries are empty.\nTo see your favorites, mark a query as a favorite."; + default: + return "Query history is empty.\nTo see the history, please make a query."; + } + }, [activeTab]); + + const handleRunQuery = (group: number) => (value: string) => { + handleSelectQuery(value, group); + handleCloseModal(); + }; + + const handleToggleFavorite = (value: string, isFavorite: boolean) => { + setHistoryFavorites((prev) => { + const values = prev[0] || []; + if (isFavorite) return [values.filter(v => v !== value)]; + if (!isFavorite && !values.includes(value)) return [[...values, value]]; + return prev; + }); + }; + + const updateStageHistory = () => { + setHistoryStorage(getQueriesFromStorage("QUERY_HISTORY")); + setHistoryFavorites(getQueriesFromStorage("QUERY_FAVORITES")); + }; + + const handleClearStorage = () => { + saveToStorage("QUERY_HISTORY", ""); + }; + + useEffect(() => { + const nextValue = historyFavorites[0] || []; + const prevValue = getQueriesFromStorage("QUERY_FAVORITES")[0] || []; + const isEqual = arrayEquals(nextValue, prevValue); + if (isEqual) return; + saveToStorage("QUERY_FAVORITES", JSON.stringify(historyFavorites)); + }, [historyFavorites]); + + useEventListener("storage", updateStageHistory); + + return ( + <> + + } + /> + + + {openModal && ( + + + + + + + {isNoData && {noDataText}} + {list.map((queries, group) => ( + + {list.length > 1 && ( + + Query {group + 1} + + )} + {queries.map((query, index) => ( + + ))} + + ))} + {(activeTab === HistoryTabTypes.storage) && !isNoData && ( + + } + onClick={handleClearStorage} + > + clear history + + + )} + + + + )} + > + ); +}; + +export default QueryHistory; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/QueryHistoryItem.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/QueryHistoryItem.tsx new file mode 100644 index 000000000..23e54efc1 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/QueryHistoryItem.tsx @@ -0,0 +1,65 @@ +import React, { FC, useMemo } from "preact/compat"; +import Button from "../../../components/Main/Button/Button"; +import { CopyIcon, PlayCircleOutlineIcon, StarBorderIcon, StarIcon } from "../../../components/Main/Icons"; +import Tooltip from "../../../components/Main/Tooltip/Tooltip"; +import useCopyToClipboard from "../../../hooks/useCopyToClipboard"; +import "./style.scss"; + +interface Props { + query: string; + favorites: string[]; + onRun: (query: string) => void; + onToggleFavorite: (query: string, isFavorite: boolean) => void; +} + +const QueryHistoryItem: FC = ({ query, favorites, onRun, onToggleFavorite }) => { + const copyToClipboard = useCopyToClipboard(); + const isFavorite = useMemo(() => favorites.includes(query), [query, favorites]); + + const handleCopyQuery = async () => { + await copyToClipboard(query, "Query has been copied"); + }; + + const handleRunQuery = () => { + onRun(query); + }; + + const handleToggleFavorite = () => { + onToggleFavorite(query, isFavorite); + }; + + return ( + + {query} + + + } + /> + + + } + /> + + + : } + /> + + + + ); +}; + +export default QueryHistoryItem; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/QueryHistoryList.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/QueryHistoryList.tsx deleted file mode 100644 index 0ce8ddf35..000000000 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/QueryHistoryList.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { FC, useMemo } from "preact/compat"; -import Button from "../../../components/Main/Button/Button"; -import { ClockIcon, CopyIcon, PlayCircleOutlineIcon } from "../../../components/Main/Icons"; -import Tooltip from "../../../components/Main/Tooltip/Tooltip"; -import { QueryHistory } from "../../../state/query/reducer"; -import useBoolean from "../../../hooks/useBoolean"; -import Modal from "../../../components/Main/Modal/Modal"; -import "./style.scss"; -import Tabs from "../../../components/Main/Tabs/Tabs"; -import { useState } from "react"; -import useCopyToClipboard from "../../../hooks/useCopyToClipboard"; -import useDeviceDetect from "../../../hooks/useDeviceDetect"; -import classNames from "classnames"; - -interface QueryHistoryProps { - history: QueryHistory[]; - handleSelectQuery: (query: string, index: number) => void -} - -const QueryHistoryList: FC = ({ history, handleSelectQuery }) => { - const { isMobile } = useDeviceDetect(); - const copyToClipboard = useCopyToClipboard(); - const { - value: openModal, - setTrue: handleOpenModal, - setFalse: handleCloseModal, - } = useBoolean(false); - - const [activeTab, setActiveTab] = useState("0"); - const tabs = useMemo(() => history.map((item, i) => ({ - value: `${i}`, - label: `Query ${i+1}`, - })), [history]); - - const queries = useMemo(() => { - const historyItem = history[+activeTab]; - return historyItem ? historyItem.values.filter(q => q).reverse() : []; - }, [activeTab, history]); - - const handleCopyQuery = (value: string) => async () => { - await copyToClipboard(value, "Query has been copied"); - }; - - const handleRunQuery = (value: string, index: number) => () => { - handleSelectQuery(value, index); - handleCloseModal(); - }; - - return ( - <> - - } - /> - - - {openModal && ( - - - - - - - {queries.map((query, index) => ( - - {query} - - - } - /> - - - } - /> - - - - ))} - - - - )} - > - ); -}; - -export default QueryHistoryList; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/style.scss b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/style.scss index 661c1d7f8..8b595e920 100644 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/style.scss +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/style.scss @@ -2,11 +2,17 @@ .vm-query-history { max-width: 80vw; - min-width: 40vw; + min-width: 500px; + + &_mobile { + max-width: 100vw; + min-width: 100vw; + } &__tabs { margin: (-$padding-medium) (-$padding-medium) 0; - padding: 0 $padding-medium; + padding: 0 $padding-small; + border-bottom: $border-divider; &_mobile { margin: (-$padding-global) (-$padding-medium) 0; @@ -17,29 +23,51 @@ display: grid; align-items: flex-start; - &-item { - display: grid; - grid-template-columns: 1fr auto; - gap: $padding-small; - align-items: center; + &__group-title { + font-weight: bold; margin: 0 (-$padding-medium) 0; - padding: $padding-small calc($padding-medium + $padding-small); - border-bottom: $border-divider; + padding: $padding-medium $padding-global $padding-small; - &:first-child { - border-top: $border-divider; - } - - &__value { - white-space: pre-wrap; - overflow-wrap: anywhere; - font-family: $font-family-monospace; - } - - &__buttons { - display: flex; - gap: $padding-small; + &_first { + padding-top: $padding-global; } } + + &__no-data { + display: flex; + align-items: center; + justify-content: center; + padding: $padding-large $padding-global; + color: $color-text-secondary; + text-align: center; + line-height: $font-size-large; + white-space: pre-line; + } + } + + &-item { + display: grid; + grid-template-columns: 1fr auto; + gap: $padding-small; + align-items: center; + margin: 0 (-$padding-medium) 0; + padding: $padding-small $padding-medium; + border-bottom: $border-divider; + + &__value { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: $font-family-monospace; + } + + &__buttons { + display: flex; + } + } + + &-footer { + display: flex; + justify-content: flex-end; + padding-top: $padding-medium; } } diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/utils.ts b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/utils.ts new file mode 100644 index 000000000..e4c6a1230 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryHistory/utils.ts @@ -0,0 +1,27 @@ +import { getFromStorage, saveToStorage, StorageKeys } from "../../../utils/storage"; +import { QueryHistoryType } from "../../../state/query/reducer"; +import { MAX_QUERIES_HISTORY, MAX_QUERY_FIELDS } from "../../../constants/graph"; + +export const getQueriesFromStorage = (key: StorageKeys) => { + const list = getFromStorage(key) as string; + return list ? JSON.parse(list) as string[][] : []; +}; + +export const setQueriesToStorage = (history: QueryHistoryType[]) => { + // For localStorage, avoid splitting into query fields because when working from multiple tabs can cause confusion. + // For convenience, we maintain the original structure of `string[][]` + const lastValues = history.map(h => h.values[h.index]); + const storageValues = getQueriesFromStorage("QUERY_HISTORY"); + if (!storageValues[0]) storageValues[0] = []; + + const values = storageValues[0]; + const TOTAL_LIMIT = MAX_QUERIES_HISTORY * MAX_QUERY_FIELDS; + + lastValues.forEach((v) => { + const already = values.includes(v); + if (!already && v) values.unshift(v); + if (values.length > TOTAL_LIMIT) values.shift(); + }); + + saveToStorage("QUERY_HISTORY", JSON.stringify(storageValues)); +}; diff --git a/app/vmui/packages/vmui/src/state/customPanel/reducer.ts b/app/vmui/packages/vmui/src/state/customPanel/reducer.ts index 1b7a885ba..b9854d626 100644 --- a/app/vmui/packages/vmui/src/state/customPanel/reducer.ts +++ b/app/vmui/packages/vmui/src/state/customPanel/reducer.ts @@ -27,7 +27,7 @@ export const initialCustomPanelState: CustomPanelState = { displayType: (displayType?.value || "chart") as DisplayType, nocache: false, isTracingEnabled: false, - seriesLimits: limitsStorage ? JSON.parse(getFromStorage("SERIES_LIMITS") as string) : DEFAULT_MAX_SERIES, + seriesLimits: limitsStorage ? JSON.parse(limitsStorage) : DEFAULT_MAX_SERIES, tableCompact: getFromStorage("TABLE_COMPACT") as boolean || false, }; diff --git a/app/vmui/packages/vmui/src/state/query/reducer.ts b/app/vmui/packages/vmui/src/state/query/reducer.ts index 3971d6355..39e9c86f8 100644 --- a/app/vmui/packages/vmui/src/state/query/reducer.ts +++ b/app/vmui/packages/vmui/src/state/query/reducer.ts @@ -1,22 +1,23 @@ import { getFromStorage, saveToStorage } from "../../utils/storage"; import { getQueryArray } from "../../utils/query-string"; +import { setQueriesToStorage } from "../../pages/CustomPanel/QueryHistory/utils"; -export interface QueryHistory { +export interface QueryHistoryType { index: number; values: string[]; } export interface QueryState { query: string[]; - queryHistory: QueryHistory[]; + queryHistory: QueryHistoryType[]; autocomplete: boolean; } export type QueryAction = | { type: "SET_QUERY", payload: string[] } - | { type: "SET_QUERY_HISTORY_BY_INDEX", payload: {value: QueryHistory, queryNumber: number} } - | { type: "SET_QUERY_HISTORY", payload: QueryHistory[] } + | { type: "SET_QUERY_HISTORY_BY_INDEX", payload: {value: QueryHistoryType, queryNumber: number} } + | { type: "SET_QUERY_HISTORY", payload: QueryHistoryType[] } | { type: "TOGGLE_AUTOCOMPLETE"} const query = getQueryArray(); @@ -34,6 +35,7 @@ export function reducer(state: QueryState, action: QueryAction): QueryState { query: action.payload.map(q => q) }; case "SET_QUERY_HISTORY": + setQueriesToStorage(action.payload); return { ...state, queryHistory: action.payload diff --git a/app/vmui/packages/vmui/src/utils/storage.ts b/app/vmui/packages/vmui/src/utils/storage.ts index 05734dfd2..844ac5b75 100644 --- a/app/vmui/packages/vmui/src/utils/storage.ts +++ b/app/vmui/packages/vmui/src/utils/storage.ts @@ -1,7 +1,4 @@ -export type StorageKeys = "BASIC_AUTH_DATA" - | "BEARER_AUTH_DATA" - | "AUTH_TYPE" - | "AUTOCOMPLETE" +export type StorageKeys = "AUTOCOMPLETE" | "NO_CACHE" | "QUERY_TRACING" | "SERIES_LIMITS" @@ -10,6 +7,8 @@ export type StorageKeys = "BASIC_AUTH_DATA" | "THEME" | "LOGS_LIMIT" | "EXPLORE_METRICS_TIPS" + | "QUERY_HISTORY" + | "QUERY_FAVORITES" export const saveToStorage = (key: StorageKeys, value: string | boolean | Record): void => { if (value) { @@ -22,7 +21,7 @@ export const saveToStorage = (key: StorageKeys, value: string | boolean | Record }; // TODO: make this aware of data type that is stored -export const getFromStorage = (key: StorageKeys): undefined | boolean | string | Record => { +export const getFromStorage = (key: StorageKeys): undefined | boolean | string | Record => { const valueObj = window.localStorage.getItem(key); if (valueObj === null) { return undefined; @@ -36,6 +35,3 @@ export const getFromStorage = (key: StorageKeys): undefined | boolean | string | }; export const removeFromStorage = (keys: StorageKeys[]): void => keys.forEach(k => window.localStorage.removeItem(k)); - -export const authKeys: StorageKeys[] = ["BASIC_AUTH_DATA", "BEARER_AUTH_DATA"]; - diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 729af3939..2a55defe9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -42,6 +42,7 @@ The sandbox cluster installation is running under the constant load generated by * FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): improve accessibility score to 100 according to [Google's Lighthouse](https://developer.chrome.com/docs/lighthouse/accessibility/) tests. * FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): organize `min`, `max`, `median` values on the chart legend and tooltips for better visibility. * FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add explanation about [cardinality explorer](https://docs.victoriametrics.com/#cardinality-explorer) statistic inaccuracy in VictoriaMetrics cluster. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3070). +* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add storage of query history in `localStorage`. See [the pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/5022). * FEATURE: dashboards: provide copies of Grafana dashboards alternated with VictoriaMetrics datasource at [dashboards/vm](https://github.com/VictoriaMetrics/VictoriaMetrics/tree/master/dashboards/vm). * FEATURE: [vmauth](https://docs.victoriametrics.com/vmauth.html): added ability to set, override and clear request and response headers on a per-user and per-path basis. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4825) and [these docs](https://docs.victoriametrics.com/vmauth.html#auth-config) for details. * FEATURE: [vmauth](https://docs.victoriametrics.com/vmauth.html): add ability to retry requests to the [remaining backends](https://docs.victoriametrics.com/vmauth.html#load-balancing) if they return response status codes specified in the `retry_status_codes` list. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4893).