vmui: add storage for query history (#5022)

* vmui: add storage for query history

* docs/vmui: add storage for query history
This commit is contained in:
Yury Molodov 2023-10-02 21:41:03 +02:00 committed by GitHub
parent a4bd73ec7e
commit f39045eca6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 369 additions and 158 deletions

View file

@ -430,3 +430,23 @@ export const ListIcon = () => (
<path d="M3 14h4v-4H3v4zm0 5h4v-4H3v4zM3 9h4V5H3v4zm5 5h13v-4H8v4zm0 5h13v-4H8v4zM8 5v4h13V5H8z"></path>
</svg>
);
export const StarBorderIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="m22 9.24-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"
></path>
</svg>
);
export const StarIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path>
</svg>
);

View file

@ -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,

View file

@ -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);

View file

@ -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<QueryConfiguratorProps> = ({
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<QueryConfiguratorProps> = ({
<div className="vm-query-configurator-settings">
<AdditionalSettings/>
<div className="vm-query-configurator-settings__buttons">
<QueryHistoryList
history={queryHistory}
handleSelectQuery={handleSelectHistory}
/>
<QueryHistory handleSelectQuery={handleSelectHistory}/>
{stateQuery.length < MAX_QUERY_FIELDS && (
<Button
variant="outlined"

View file

@ -0,0 +1,188 @@
import React, { FC, useEffect, useMemo, useState } from "preact/compat";
import Button from "../../../components/Main/Button/Button";
import { ClockIcon, DeleteIcon } from "../../../components/Main/Icons";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import useBoolean from "../../../hooks/useBoolean";
import Modal from "../../../components/Main/Modal/Modal";
import Tabs from "../../../components/Main/Tabs/Tabs";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useEventListener from "../../../hooks/useEventListener";
import { useQueryState } from "../../../state/query/QueryStateContext";
import { getQueriesFromStorage } from "./utils";
import QueryHistoryItem from "./QueryHistoryItem";
import classNames from "classnames";
import "./style.scss";
import { saveToStorage } from "../../../utils/storage";
import { arrayEquals } from "../../../utils/array";
interface Props {
handleSelectQuery: (query: string, index: number) => 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<Props> = ({ 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 (
<>
<Tooltip title={"Show history"}>
<Button
color="primary"
variant="text"
onClick={handleOpenModal}
startIcon={<ClockIcon/>}
/>
</Tooltip>
{openModal && (
<Modal
title={"Query history"}
onClose={handleCloseModal}
>
<div
className={classNames({
"vm-query-history": true,
"vm-query-history_mobile": isMobile,
})}
>
<div
className={classNames({
"vm-query-history__tabs": true,
"vm-section-header__tabs": true,
"vm-query-history__tabs_mobile": isMobile,
})}
>
<Tabs
activeItem={activeTab}
items={historyTabs}
onChange={setActiveTab}
/>
</div>
<div className="vm-query-history-list">
{isNoData && <div className="vm-query-history-list__no-data">{noDataText}</div>}
{list.map((queries, group) => (
<div key={group}>
{list.length > 1 && (
<div
className={classNames({
"vm-query-history-list__group-title": true,
"vm-query-history-list__group-title_first": group === 0,
})}
>
Query {group + 1}
</div>
)}
{queries.map((query, index) => (
<QueryHistoryItem
key={index}
query={query}
favorites={historyFavorites.flat()}
onRun={handleRunQuery(group)}
onToggleFavorite={handleToggleFavorite}
/>
))}
</div>
))}
{(activeTab === HistoryTabTypes.storage) && !isNoData && (
<div className="vm-query-history-footer">
<Button
color="error"
variant="outlined"
size="small"
startIcon={<DeleteIcon/>}
onClick={handleClearStorage}
>
clear history
</Button>
</div>
)}
</div>
</div>
</Modal>
)}
</>
);
};
export default QueryHistory;

View file

@ -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<Props> = ({ 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 (
<div className="vm-query-history-item">
<span className="vm-query-history-item__value">{query}</span>
<div className="vm-query-history-item__buttons">
<Tooltip title={"Execute query"}>
<Button
size="small"
variant="text"
onClick={handleRunQuery}
startIcon={<PlayCircleOutlineIcon/>}
/>
</Tooltip>
<Tooltip title={"Copy query"}>
<Button
size="small"
variant="text"
onClick={handleCopyQuery}
startIcon={<CopyIcon/>}
/>
</Tooltip>
<Tooltip title={isFavorite ? "Remove Favorite" : "Add to Favorites"}>
<Button
size="small"
variant="text"
color={isFavorite ? "warning" : "primary"}
onClick={handleToggleFavorite}
startIcon={isFavorite ? <StarIcon/> : <StarBorderIcon/>}
/>
</Tooltip>
</div>
</div>
);
};
export default QueryHistoryItem;

View file

@ -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<QueryHistoryProps> = ({ 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 (
<>
<Tooltip title={"Show history"}>
<Button
color="primary"
variant="text"
onClick={handleOpenModal}
startIcon={<ClockIcon/>}
/>
</Tooltip>
{openModal && (
<Modal
title={"Query history"}
onClose={handleCloseModal}
>
<div className="vm-query-history">
<div
className={classNames({
"vm-query-history__tabs": true,
"vm-section-header__tabs": true,
"vm-query-history__tabs_mobile": isMobile,
})}
>
<Tabs
activeItem={activeTab}
items={tabs}
onChange={setActiveTab}
/>
</div>
<div className="vm-query-history-list">
{queries.map((query, index) => (
<div
className="vm-query-history-list-item"
key={index}
>
<span className="vm-query-history-list-item__value">{query}</span>
<div className="vm-query-history-list-item__buttons">
<Tooltip title={"Execute query"}>
<Button
size="small"
variant="text"
onClick={handleRunQuery(query, +activeTab)}
startIcon={<PlayCircleOutlineIcon/>}
/>
</Tooltip>
<Tooltip title={"Copy query"}>
<Button
size="small"
variant="text"
onClick={handleCopyQuery(query)}
startIcon={<CopyIcon/>}
/>
</Tooltip>
</div>
</div>
))}
</div>
</div>
</Modal>
)}
</>
);
};
export default QueryHistoryList;

View file

@ -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,19 +23,37 @@
display: grid;
align-items: flex-start;
&__group-title {
font-weight: bold;
margin: 0 (-$padding-medium) 0;
padding: $padding-medium $padding-global $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 calc($padding-medium + $padding-small);
padding: $padding-small $padding-medium;
border-bottom: $border-divider;
&:first-child {
border-top: $border-divider;
}
&__value {
white-space: pre-wrap;
overflow-wrap: anywhere;
@ -38,8 +62,12 @@
&__buttons {
display: flex;
gap: $padding-small;
}
}
&-footer {
display: flex;
justify-content: flex-end;
padding-top: $padding-medium;
}
}

View file

@ -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));
};

View file

@ -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,
};

View file

@ -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

View file

@ -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<string, unknown>): void => {
if (value) {
@ -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"];

View file

@ -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).