From 7349f18c55642306729366a493d422732d330bfd Mon Sep 17 00:00:00 2001
From: Tamara Vashchuk <98753789+aramattamara@users.noreply.github.com>
Date: Fri, 18 Aug 2023 10:12:48 -0700
Subject: [PATCH] vmui: Add button to prettify query (#4694)

* Add button to prettify query

Just capitalizes query text for now

* Add /prettify-query API handler

* Replace UI pretiffier using prettifier API

* Add showing server errors

Had to pass setQueryErrors from useFetchQuery.ts

* Use serverUrl from global AppState

* Change icon to AutoAwsome icon + added style change color when button is active

* Add sync/await to prettifyQuery function

* Doc public function for lint

* Minor async fix

* Removed extra blank lines

* Extract usePrettifyQuery hook

* Made more generic style for :active button

* Refactor usePrettifyQuery

However, prettify errors don't clean up query errors, but should

* Add prettyQuery functionality to CHANGELOG.md

* Reuse queryErrors

* Unhide errors on start

---------

Co-authored-by: Tamara <toma.vashchuk@gmail.com>
---
 app/vmselect/main.go                          |  5 ++
 app/vmselect/prometheus/prometheus.go         | 19 +++++++
 .../src/components/Main/Button/style.scss     |  4 ++
 .../vmui/src/components/Main/Icons/index.tsx  | 11 ++++
 .../packages/vmui/src/hooks/useFetchQuery.ts  |  8 +--
 .../QueryConfigurator/QueryConfigurator.tsx   | 53 ++++++++++++++++---
 .../hooks/usePrettifyQuery.ts                 | 49 +++++++++++++++++
 .../CustomPanel/QueryConfigurator/style.scss  |  2 +-
 .../vmui/src/pages/CustomPanel/index.tsx      |  5 +-
 docs/CHANGELOG.md                             |  1 +
 10 files changed, 145 insertions(+), 12 deletions(-)
 create mode 100644 app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/hooks/usePrettifyQuery.ts

diff --git a/app/vmselect/main.go b/app/vmselect/main.go
index 7f10095ddc..7ee0a6e90e 100644
--- a/app/vmselect/main.go
+++ b/app/vmselect/main.go
@@ -480,6 +480,10 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
 		expandWithExprsRequests.Inc()
 		prometheus.ExpandWithExprs(w, r)
 		return true
+	case "/prettify-query":
+		prettifyQueryRequests.Inc()
+		prometheus.PrettifyQuery(w, r)
+		return true
 	case "/api/v1/rules", "/rules":
 		rulesRequests.Inc()
 		if len(*vmalertProxyURL) > 0 {
@@ -655,6 +659,7 @@ var (
 	graphiteFunctionDetailsErrors   = metrics.NewCounter(`vm_http_request_errors_total{path="/functions/<func_name>"}`)
 
 	expandWithExprsRequests = metrics.NewCounter(`vm_http_requests_total{path="/expand-with-exprs"}`)
+	prettifyQueryRequests   = metrics.NewCounter(`vm_http_requests_total{path="/prettify-query"}`)
 
 	vmalertRequests = metrics.NewCounter(`vm_http_requests_total{path="/vmalert"}`)
 	rulesRequests   = metrics.NewCounter(`vm_http_requests_total{path="/api/v1/rules"}`)
diff --git a/app/vmselect/prometheus/prometheus.go b/app/vmselect/prometheus/prometheus.go
index 4cbed0d3bf..ee11979678 100644
--- a/app/vmselect/prometheus/prometheus.go
+++ b/app/vmselect/prometheus/prometheus.go
@@ -3,6 +3,7 @@ package prometheus
 import (
 	"flag"
 	"fmt"
+	"github.com/VictoriaMetrics/metricsql"
 	"math"
 	"net/http"
 	"runtime"
@@ -75,6 +76,24 @@ func ExpandWithExprs(w http.ResponseWriter, r *http.Request) {
 	_ = bw.Flush()
 }
 
+// PrettifyQuery implements /prettify-query. Takes a MetricsQL query and returns it formatted.
+func PrettifyQuery(w http.ResponseWriter, r *http.Request) {
+	query := r.FormValue("query")
+	bw := bufferedwriter.Get(w)
+	defer bufferedwriter.Put(bw)
+	w.Header().Set("Content-Type", "application/json")
+	httpserver.EnableCORS(w, r)
+
+	prettyQuery, err := metricsql.Prettify(query)
+	if err != nil {
+		fmt.Fprintf(bw, `{"status": "error", "msg": %q}`, err)
+	} else {
+		fmt.Fprintf(bw, `{"status": "success", "query": %q}`, prettyQuery)
+	}
+
+	_ = bw.Flush()
+}
+
 // FederateHandler implements /federate . See https://prometheus.io/docs/prometheus/latest/federation/
 func FederateHandler(startTime time.Time, w http.ResponseWriter, r *http.Request) error {
 	defer federateDuration.UpdateDuration(startTime)
diff --git a/app/vmui/packages/vmui/src/components/Main/Button/style.scss b/app/vmui/packages/vmui/src/components/Main/Button/style.scss
index 0a0bc1e2b4..24360b758c 100644
--- a/app/vmui/packages/vmui/src/components/Main/Button/style.scss
+++ b/app/vmui/packages/vmui/src/components/Main/Button/style.scss
@@ -45,6 +45,10 @@ $button-radius: 6px;
     transform: translateZ(-1px);
   }
 
+  &:active:after {
+    transform: scale(0.9);
+  }
+
   span {
     display: grid;
     align-items: center;
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 82ef532c2e..6e96b3cee6 100644
--- a/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx
+++ b/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx
@@ -291,6 +291,17 @@ export const VisibilityOffIcon = () => (
   </svg>
 );
 
+export const Prettify = () => (
+  <svg
+    viewBox="0 0 24 24"
+    fill="currentColor"
+  >
+    <path
+      d="M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z"
+    ></path>
+  </svg>
+);
+
 export const CopyIcon = () => (
   <svg
     viewBox="0 0 24 24"
diff --git a/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts b/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts
index 7576ecda26..e4f418983e 100644
--- a/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts
+++ b/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from "preact/compat";
+import { StateUpdater, useCallback, useEffect, useMemo, useState } from "preact/compat";
 import { getQueryRangeUrl, getQueryUrl } from "../api/query-range";
 import { useAppState } from "../state/common/StateContext";
 import { InstantMetricResult, MetricBase, MetricResult, QueryStats } from "../api/types";
@@ -28,6 +28,7 @@ interface FetchQueryReturn {
   liveData?: InstantMetricResult[],
   error?: ErrorTypes | string,
   queryErrors: (ErrorTypes | string)[],
+  setQueryErrors: StateUpdater<string[]>,
   queryStats: QueryStats[],
   warning?: string,
   traces?: Trace[],
@@ -62,12 +63,13 @@ export const useFetchQuery = ({
   const [liveData, setLiveData] = useState<InstantMetricResult[]>();
   const [traces, setTraces] = useState<Trace[]>();
   const [error, setError] = useState<ErrorTypes | string>();
-  const [queryErrors, setQueryErrors] = useState<(ErrorTypes | string)[]>([]);
+  const [queryErrors, setQueryErrors] = useState<string[]>([]);
   const [queryStats, setQueryStats] = useState<QueryStats[]>([]);
   const [warning, setWarning] = useState<string>();
   const [fetchQueue, setFetchQueue] = useState<AbortController[]>([]);
   const [isHistogram, setIsHistogram] = useState(false);
 
+
   const fetchData = async ({
     fetchUrl,
     fetchQueue,
@@ -193,5 +195,5 @@ export const useFetchQuery = ({
     setFetchQueue(fetchQueue.filter(f => !f.signal.aborted));
   }, [fetchQueue]);
 
-  return { fetchUrl, isLoading, graphData, liveData, error, queryErrors, queryStats, warning, traces, isHistogram };
+  return { fetchUrl, isLoading, graphData, liveData, error, queryErrors, setQueryErrors, queryStats, warning, traces, isHistogram };
 };
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 23e581a22a..b35cbc5e4b 100644
--- a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx
+++ b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx
@@ -1,12 +1,18 @@
-import React, { FC, useState, useEffect } from "preact/compat";
+import React, { FC, StateUpdater, useEffect, useState } from "preact/compat";
 import QueryEditor from "../../../components/Configurators/QueryEditor/QueryEditor";
 import AdditionalSettings from "../../../components/Configurators/AdditionalSettings/AdditionalSettings";
-import { ErrorTypes } from "../../../types";
 import usePrevious from "../../../hooks/usePrevious";
 import { MAX_QUERY_FIELDS } from "../../../constants/graph";
 import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext";
 import { useTimeDispatch } from "../../../state/time/TimeStateContext";
-import { DeleteIcon, PlayIcon, PlusIcon, VisibilityIcon, VisibilityOffIcon } from "../../../components/Main/Icons";
+import {
+  DeleteIcon,
+  PlayIcon,
+  PlusIcon,
+  Prettify,
+  VisibilityIcon,
+  VisibilityOffIcon
+} from "../../../components/Main/Icons";
 import Button from "../../../components/Main/Button/Button";
 import "./style.scss";
 import Tooltip from "../../../components/Main/Tooltip/Tooltip";
@@ -15,9 +21,12 @@ import { MouseEvent as ReactMouseEvent } from "react";
 import { arrayEquals } from "../../../utils/array";
 import useDeviceDetect from "../../../hooks/useDeviceDetect";
 import { QueryStats } from "../../../api/types";
+import { usePrettifyQuery } from "./hooks/usePrettifyQuery";
 
 export interface QueryConfiguratorProps {
-  errors: (ErrorTypes | string)[];
+  queryErrors: string[];
+  setQueryErrors: StateUpdater<string[]>;
+  setHideError: StateUpdater<boolean>;
   stats: QueryStats[];
   queryOptions: string[]
   onHideQuery: (queries: number[]) => void
@@ -25,12 +34,15 @@ export interface QueryConfiguratorProps {
 }
 
 const QueryConfigurator: FC<QueryConfiguratorProps> = ({
-  errors,
+  queryErrors,
+  setQueryErrors,
+  setHideError,
   stats,
   queryOptions,
   onHideQuery,
   onRunQuery
 }) => {
+
   const { isMobile } = useDeviceDetect();
 
   const { query, queryHistory, autocomplete } = useQueryState();
@@ -41,6 +53,8 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
   const [hideQuery, setHideQuery] = useState<number[]>([]);
   const prevStateQuery = usePrevious(stateQuery) as (undefined | string[]);
 
+  const getPrettifiedQuery = usePrettifyQuery();
+
   const updateHistory = () => {
     queryDispatch({
       type: "SET_QUERY_HISTORY", payload: stateQuery.map((q, i) => {
@@ -106,13 +120,25 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
 
   const createHandlerRemoveQuery = (i: number) => () => {
     handleRemoveQuery(i);
-    setHideQuery(prev => prev.includes(i) ? prev.filter(n => n !== i) : prev.map(n => n > i ? n - 1: n));
+    setHideQuery(prev => prev.includes(i) ? prev.filter(n => n !== i) : prev.map(n => n > i ? n - 1 : n));
   };
 
   const createHandlerHideQuery = (i: number) => (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {
     handleToggleHideQuery(e, i);
   };
 
+  const handlePrettifyQuery = async (i:number) => {
+    const prettyQuery = await getPrettifiedQuery(stateQuery[i]);
+    setHideError(false);
+
+    handleChangeQuery(prettyQuery.query, i);
+
+    setQueryErrors((qe) => {
+      qe[i] = prettyQuery.error;
+      return [...qe];
+    });
+  };
+
   useEffect(() => {
     if (prevStateQuery && (stateQuery.length < prevStateQuery.length)) {
       handleRunQuery();
@@ -144,7 +170,7 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
             value={stateQuery[i]}
             autocomplete={autocomplete}
             options={queryOptions}
-            error={errors[i]}
+            error={queryErrors[i]}
             stats={stats[i]}
             onArrowUp={createHandlerArrow(-1, i)}
             onArrowDown={createHandlerArrow(1, i)}
@@ -163,6 +189,19 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
               />
             </div>
           </Tooltip>
+
+          <Tooltip title={"Prettify query"}>
+            <div className="vm-query-configurator-list-row__button">
+              <Button
+                variant={"text"}
+                color={"gray"}
+                startIcon={<Prettify/>}
+                onClick={async () => await handlePrettifyQuery(i)}
+                className="prettify"
+              />
+            </div>
+          </Tooltip>
+
           {stateQuery.length > 1 && (
             <Tooltip title="Remove Query">
               <div className="vm-query-configurator-list-row__button">
diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/hooks/usePrettifyQuery.ts b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/hooks/usePrettifyQuery.ts
new file mode 100644
index 0000000000..da6589fe77
--- /dev/null
+++ b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/hooks/usePrettifyQuery.ts
@@ -0,0 +1,49 @@
+import { useAppState } from "../../../../state/common/StateContext";
+
+export interface PrettyQuery {
+  query: string;
+  error: string;
+}
+
+export const usePrettifyQuery = () => {
+  const { serverUrl } = useAppState();
+
+  const getPrettifiedQuery = async (query: string): Promise<PrettyQuery> => {
+    try {
+      const oldQuery = encodeURIComponent(query);
+      const fetchUrl = `${serverUrl}/prettify-query?query=${oldQuery}`;
+
+      // {"status": "success", "query": "metrics"}
+      // {"status": "error", "msg": "labelFilterExpr: unexpected token ..."}
+      const response = await fetch(fetchUrl);
+
+      if (response.status != 200) {
+        return {
+          query: query,
+          error: "Error requesting /prettify-query, status: " + response.status,
+        };
+      }
+
+      const data = await response.json();
+
+      if (data["status"] != "success") {
+        return {
+          query: query,
+          error: String(data.msg) };
+      }
+
+      return {
+        query: String(data.query),
+        error: "" };
+
+    } catch (e) {
+      console.error(e);
+      if (e instanceof Error && e.name !== "AbortError") {
+        return { query: query, error: `${e.name}: ${e.message}` };
+      }
+      return { query: query, error: String(e) };
+    }
+  };
+
+  return getPrettifiedQuery;
+};
diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/style.scss b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/style.scss
index fb39cfb0a6..a6a564da45 100644
--- a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/style.scss
+++ b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/style.scss
@@ -9,7 +9,7 @@
 
     &-row {
       display: grid;
-      grid-template-columns: 1fr auto auto;
+      grid-template-columns: 1fr auto auto auto;
       align-items: center;
       gap: $padding-small;
 
diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx
index 4e169c1e80..fb170f9bd7 100644
--- a/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx
+++ b/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx
@@ -57,6 +57,7 @@ const CustomPanel: FC = () => {
     graphData,
     error,
     queryErrors,
+    setQueryErrors,
     queryStats,
     warning,
     traces,
@@ -128,7 +129,9 @@ const CustomPanel: FC = () => {
       })}
     >
       <QueryConfigurator
-        errors={!hideError ? queryErrors : []}
+        queryErrors={!hideError ? queryErrors : []}
+        setQueryErrors={setQueryErrors}
+        setHideError={setHideError}
         stats={queryStats}
         queryOptions={queryOptions}
         onHideQuery={handleHideQuery}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 9703d85939..f59db25ed6 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -60,6 +60,7 @@ The v1.93.x line will be supported for at least 12 months since [v1.93.0](https:
 * FEATURE: [Official Grafana dashboards for VictoriaMetrics](https://grafana.com/orgs/victoriametrics): add panels for absolute Mem and CPU usage by vmalert. See related issue [here](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4627).
 * FEATURE: [Official Grafana dashboards for VictoriaMetrics](https://grafana.com/orgs/victoriametrics): correctly calculate `Bytes per point` value for single-server and cluster VM dashboards. Before, the calculation mistakenly accounted for the number of entries in indexdb in denominator, which could have shown lower values than expected.
 * FEATURE: [Alerting rules for VictoriaMetrics](https://github.com/VictoriaMetrics/VictoriaMetrics/tree/master/deployment/docker#alerts): `ConcurrentFlushesHitTheLimit` alerting rule was moved from [single-server](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/deployment/docker/alerts.yml) and [cluster](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/deployment/docker/alerts-cluster.yml) alerts to the [list of "health" alerts](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/deployment/docker/alerts-health.yml) as it could be related to many VictoriaMetrics components. 
+* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): added Prettify query functionality. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4681)
 
 * BUGFIX: [vmagent](https://docs.victoriametrics.com/vmagent.html): return human readable error if opentelemetry has json encoding. Follow-up after [PR](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/2570).
 * BUGFIX: [vmagent](https://docs.victoriametrics.com/vmagent.html): properly validate scheme for `proxy_url` field at the scrape config. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4811) for details.