From 1db2b991b72c7804d142295ea3bea9a06dd798a4 Mon Sep 17 00:00:00 2001 From: Yury Molodov Date: Tue, 23 Jan 2024 03:23:26 +0100 Subject: [PATCH] vmui: query report (#5497) * vmui: add query analyzer page * vmui: fix tabs for query analyzer * vmui: add help to export query * vmui: add time params to query analyzer * docs/vmui: add query analyzer * vmui: fix validation JSON form --------- Co-authored-by: Aliaksandr Valialkin --- app/vmui/packages/vmui/src/App.tsx | 5 + .../src/components/Main/Button/Button.tsx | 2 +- .../src/components/Main/Button/style.scss | 13 +- .../vmui/src/components/Main/Icons/index.tsx | 9 + .../src/components/Main/Popper/Popper.tsx | 6 +- .../src/components/Main/Popper/style.scss | 10 + .../UploadJsonButtons/UploadJsonButtons.tsx | 33 +++ .../components/UploadJsonButtons/style.scss | 9 + app/vmui/packages/vmui/src/constants/date.ts | 1 + .../packages/vmui/src/constants/navigation.ts | 4 + .../packages/vmui/src/hooks/useFetchQuery.ts | 14 +- .../DownloadReport/DownloadReport.tsx | 225 ++++++++++++++++++ .../CustomPanel/DownloadReport/helperText.tsx | 45 ++++ .../CustomPanel/DownloadReport/style.scss | 47 ++++ .../vmui/src/pages/CustomPanel/index.tsx | 7 +- .../vmui/src/pages/CustomPanel/style.scss | 8 +- .../pages/QueryAnalyzer/JsonForm/JsonForm.tsx | 77 ++++++ .../pages/QueryAnalyzer/JsonForm/style.scss | 73 ++++++ .../QueryAnalyzerInfo/QueryAnalyzerInfo.tsx | 97 ++++++++ .../QueryAnalyzerInfo/style.scss | 47 ++++ .../QueryAnalyzerView/QueryAnalyzerView.tsx | 180 ++++++++++++++ .../QueryAnalyzerView/style.scss | 30 +++ .../QueryAnalyzer/QueryAnalyzerView/utils.ts | 20 ++ .../vmui/src/pages/QueryAnalyzer/index.tsx | 205 ++++++++++++++++ .../TraceUploadButtons/TraceUploadButtons.tsx | 35 --- .../vmui/src/pages/TracePage/index.tsx | 6 +- .../vmui/src/pages/TracePage/style.scss | 12 +- app/vmui/packages/vmui/src/router/index.ts | 5 + docs/CHANGELOG.md | 3 + 29 files changed, 1175 insertions(+), 53 deletions(-) create mode 100644 app/vmui/packages/vmui/src/components/UploadJsonButtons/UploadJsonButtons.tsx create mode 100644 app/vmui/packages/vmui/src/components/UploadJsonButtons/style.scss create mode 100644 app/vmui/packages/vmui/src/pages/CustomPanel/DownloadReport/DownloadReport.tsx create mode 100644 app/vmui/packages/vmui/src/pages/CustomPanel/DownloadReport/helperText.tsx create mode 100644 app/vmui/packages/vmui/src/pages/CustomPanel/DownloadReport/style.scss create mode 100644 app/vmui/packages/vmui/src/pages/QueryAnalyzer/JsonForm/JsonForm.tsx create mode 100644 app/vmui/packages/vmui/src/pages/QueryAnalyzer/JsonForm/style.scss create mode 100644 app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerInfo/QueryAnalyzerInfo.tsx create mode 100644 app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerInfo/style.scss create mode 100644 app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/QueryAnalyzerView.tsx create mode 100644 app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/style.scss create mode 100644 app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/utils.ts create mode 100644 app/vmui/packages/vmui/src/pages/QueryAnalyzer/index.tsx delete mode 100644 app/vmui/packages/vmui/src/pages/TracePage/TraceUploadButtons/TraceUploadButtons.tsx diff --git a/app/vmui/packages/vmui/src/App.tsx b/app/vmui/packages/vmui/src/App.tsx index 4e3fc2a89..f2807d7bf 100644 --- a/app/vmui/packages/vmui/src/App.tsx +++ b/app/vmui/packages/vmui/src/App.tsx @@ -14,6 +14,7 @@ import PreviewIcons from "./components/Main/Icons/PreviewIcons"; import WithTemplate from "./pages/WithTemplate"; import Relabel from "./pages/Relabel"; import ActiveQueries from "./pages/ActiveQueries"; +import QueryAnalyzer from "./pages/QueryAnalyzer"; const App: FC = () => { const [loadedTheme, setLoadedTheme] = useState(false); @@ -49,6 +50,10 @@ const App: FC = () => { path={router.trace} element={} /> + } + /> } diff --git a/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx b/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx index f7507a77b..7987c64eb 100644 --- a/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx +++ b/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx @@ -5,7 +5,7 @@ import "./style.scss"; interface ButtonProps { variant?: "contained" | "outlined" | "text" - color?: "primary" | "secondary" | "success" | "error" | "gray" | "warning" + color?: "primary" | "secondary" | "success" | "error" | "gray" | "warning" | "white" size?: "small" | "medium" | "large" ariaLabel?: string // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label endIcon?: ReactNode 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 24360b758..d6aeea571 100644 --- a/app/vmui/packages/vmui/src/components/Main/Button/style.scss +++ b/app/vmui/packages/vmui/src/components/Main/Button/style.scss @@ -9,7 +9,7 @@ $button-radius: 6px; justify-content: center; padding: 6px 14px; font-size: $font-size-small; - line-height: 1.3; + line-height: calc($font-size + 1px); font-weight: normal; min-height: 31px; border-radius: $button-radius; @@ -56,7 +56,7 @@ $button-radius: 6px; transform: translateZ(1px); svg { - width: 15px; + width: calc($font-size + 1px); } } @@ -180,6 +180,10 @@ $button-radius: 6px; color: $color-text-secondary; } + &_text_white { + color: $color-white; + } + &_text_warning { color: $color-warning; } @@ -211,6 +215,11 @@ $button-radius: 6px; color: $color-text-secondary; } + &_outlined_white { + border: 1px solid $color-white; + color: $color-white; + } + &_outlined_warning { border: 1px solid $color-warning; color: $color-warning; 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 aca59dfb7..8f49d37be 100644 --- a/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx +++ b/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx @@ -511,3 +511,12 @@ export const ValueIcon = () => ( /> ); + +export const DownloadIcon = () => ( + + + +); diff --git a/app/vmui/packages/vmui/src/components/Main/Popper/Popper.tsx b/app/vmui/packages/vmui/src/components/Main/Popper/Popper.tsx index 50d364670..b67783bc8 100644 --- a/app/vmui/packages/vmui/src/components/Main/Popper/Popper.tsx +++ b/app/vmui/packages/vmui/src/components/Main/Popper/Popper.tsx @@ -23,6 +23,7 @@ interface PopperProps { fullWidth?: boolean title?: string disabledFullScreen?: boolean + variant?: "default" | "dark" } const Popper: FC = ({ @@ -35,7 +36,8 @@ const Popper: FC = ({ clickOutside = true, fullWidth, title, - disabledFullScreen + disabledFullScreen, + variant }) => { const { isMobile } = useDeviceDetect(); const navigate = useNavigate(); @@ -147,6 +149,7 @@ const Popper: FC = ({
= ({

{title}

+ +
+); + +export default UploadJsonButtons; diff --git a/app/vmui/packages/vmui/src/components/UploadJsonButtons/style.scss b/app/vmui/packages/vmui/src/components/UploadJsonButtons/style.scss new file mode 100644 index 000000000..bd66d3a73 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/UploadJsonButtons/style.scss @@ -0,0 +1,9 @@ +@use "src/styles/variables" as *; + +.vm-upload-json-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $padding-global; + align-items: center; + justify-content: center; +} diff --git a/app/vmui/packages/vmui/src/constants/date.ts b/app/vmui/packages/vmui/src/constants/date.ts index 4f821f4bf..b010ff4d3 100644 --- a/app/vmui/packages/vmui/src/constants/date.ts +++ b/app/vmui/packages/vmui/src/constants/date.ts @@ -2,3 +2,4 @@ export const DATE_FORMAT = "YYYY-MM-DD"; export const DATE_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss"; export const DATE_FULL_TIMEZONE_FORMAT = "YYYY-MM-DD HH:mm:ss:SSS (Z)"; export const DATE_ISO_FORMAT = "YYYY-MM-DD[T]HH:mm:ss"; +export const DATE_FILENAME_FORMAT = "YYYY-MM-DD_HHmmss"; diff --git a/app/vmui/packages/vmui/src/constants/navigation.ts b/app/vmui/packages/vmui/src/constants/navigation.ts index f565c1964..1b7c4700f 100644 --- a/app/vmui/packages/vmui/src/constants/navigation.ts +++ b/app/vmui/packages/vmui/src/constants/navigation.ts @@ -36,6 +36,10 @@ const tools = { label: routerOptions[router.trace].title, value: router.trace, }, + { + label: routerOptions[router.queryAnalyzer].title, + value: router.queryAnalyzer, + }, { label: routerOptions[router.withTemplate].title, value: router.withTemplate, diff --git a/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts b/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts index 638eb375b..4e4aaf06c 100644 --- a/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts +++ b/app/vmui/packages/vmui/src/hooks/useFetchQuery.ts @@ -212,5 +212,17 @@ export const useFetchQuery = ({ if (defaultStep === customStep) setGraphData([]); }, [isHistogram]); - return { fetchUrl, isLoading, graphData, liveData, error, queryErrors, setQueryErrors, 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/DownloadReport/DownloadReport.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/DownloadReport/DownloadReport.tsx new file mode 100644 index 000000000..c8f5377af --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/DownloadReport/DownloadReport.tsx @@ -0,0 +1,225 @@ +import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "preact/compat"; +import { DownloadIcon } from "../../../components/Main/Icons"; +import Button from "../../../components/Main/Button/Button"; +import Tooltip from "../../../components/Main/Tooltip/Tooltip"; +import useBoolean from "../../../hooks/useBoolean"; +import "./style.scss"; +import Checkbox from "../../../components/Main/Checkbox/Checkbox"; +import Modal from "../../../components/Main/Modal/Modal"; +import dayjs from "dayjs"; +import { DATE_FILENAME_FORMAT } from "../../../constants/date"; +import TextField from "../../../components/Main/TextField/TextField"; +import { useQueryState } from "../../../state/query/QueryStateContext"; +import { ErrorTypes } from "../../../types"; +import Alert from "../../../components/Main/Alert/Alert"; +import qs from "qs"; +import Popper from "../../../components/Main/Popper/Popper"; +import helperText from "./helperText"; + +type Props = { + fetchUrl?: string[]; +} + +const getDefaultReportName = () => `vmui_report_${dayjs().utc().format(DATE_FILENAME_FORMAT)}`; + +const DownloadReport: FC = ({ fetchUrl }) => { + const { query } = useQueryState(); + + const [filename, setFilename] = useState(getDefaultReportName()); + const [comment, setComment] = useState(""); + const [trace, setTrace] = useState(true); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); + + const filenameRef = useRef(null); + const commentRef = useRef(null); + const traceRef = useRef(null); + const generateRef = useRef(null); + const helperRefs = [filenameRef, commentRef, traceRef, generateRef]; + const [stepHelper, setStepHelper] = useState(0); + + const { + value: openModal, + toggle: toggleOpen, + setFalse: handleClose, + } = useBoolean(false); + + const { + value: openHelper, + toggle: toggleHelper, + setFalse: handleCloseHelper, + } = useBoolean(false); + + const fetchUrlReport = useMemo(() => { + if (!fetchUrl) return; + return fetchUrl.map((str, i) => { + const url = new URL(str); + trace ? url.searchParams.set("trace", "1") : url.searchParams.delete("trace"); + return { id: i, url: url }; + }); + }, [fetchUrl, trace]); + + const generateFile = useCallback((data: unknown) => { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const href = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = href; + link.download = `${filename || getDefaultReportName()}.json`; + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + URL.revokeObjectURL(href); + handleClose(); + }, [filename]); + + const handleGenerateReport = useCallback(async () => { + if (!fetchUrlReport) { + setError(ErrorTypes.validQuery); + return; + } + + setError(""); + setIsLoading(true); + + try { + const result = []; + for await (const { url, id } of fetchUrlReport) { + const response = await fetch(url); + const resp = await response.json(); + if (response.ok) { + resp.vmui = { + id, + comment, + params: qs.parse(new URL(url).search.replace(/^\?/, "")) + }; + result.push(resp); + } else { + const errorType = resp.errorType ? `${resp.errorType}\r\n` : ""; + setError(`${errorType}${resp?.error || resp?.message || "unknown error"}`); + } + } + result.length && generateFile(result); + } catch (e) { + if (e instanceof Error && e.name !== "AbortError") { + setError(`${e.name}: ${e.message}`); + } + } finally { + setIsLoading(false); + } + }, [fetchUrlReport, comment, generateFile, query]); + + const handleChangeHelp = (step: number) => () => { + setStepHelper(prevStep => prevStep + step); + }; + + useEffect(() => { + setError(""); + setFilename(getDefaultReportName()); + setComment(""); + }, [openModal]); + + useEffect(() => { + setStepHelper(0); + }, [openHelper]); + + return ( + <> + + +
+ +
+ + +
+
+ {helperText[stepHelper]} +
+
+ {stepHelper !== 0 && ( + + )} + +
+
+
+ + + )} + + ); +}; + +export default DownloadReport; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/DownloadReport/helperText.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/DownloadReport/helperText.tsx new file mode 100644 index 000000000..efb2383e3 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/DownloadReport/helperText.tsx @@ -0,0 +1,45 @@ +import React from "preact/compat"; +import { DATE_FILENAME_FORMAT } from "../../../constants/date"; +import router, { routerOptions } from "../../../router"; +import { Link } from "react-router-dom"; + +const filename = ( + <> +

Filename - specify the name for your report file.

+

Default format: vmui_report_${DATE_FILENAME_FORMAT}.json.

+

This name will be used when saving your report on your device.

+ +); + +const comment = ( + <> +

Comment (optional) - add a comment to your report.

+

This can be any additional information that will be useful when reviewing the report later.

+ +); + +const trace = ( + <> +

Query trace - enable this option to include a query trace in your report.

+

This will assist in analyzing and diagnosing the query processing.

+ +); + +const generate = ( + <> +

Generate Report - click this button to generate and save your report.

+

After creation, the report can be downloaded and examined on the {routerOptions[router.queryAnalyzer].title} page.

+ +); + +export default [ + filename, + comment, + trace, + generate, +]; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/DownloadReport/style.scss b/app/vmui/packages/vmui/src/pages/CustomPanel/DownloadReport/style.scss new file mode 100644 index 000000000..9255694bc --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/DownloadReport/style.scss @@ -0,0 +1,47 @@ +@use "src/styles/variables" as *; + +.vm-download-report { + display: grid; + gap: $padding-large; + padding-top: calc($padding-large - $padding-global); + min-width: 400px; + + &-settings { + display: grid; + gap: $padding-global; + + textarea { + min-height: 100px; + } + } + + &__buttons { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $padding-global; + } + + &-helper { + display: grid; + gap: $padding-small; + padding: $padding-global; + + &__description { + max-width: 400px; + white-space: pre-line; + line-height: 1.3; + + p { + margin-bottom: calc($padding-small/2); + } + } + + &__buttons { + display: flex; + align-items: center; + justify-content: flex-end; + 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 4bb9fdc2f..64536551c 100644 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx @@ -18,6 +18,7 @@ import CustomPanelTraces from "./CustomPanelTraces/CustomPanelTraces"; import WarningLimitSeries from "./WarningLimitSeries/WarningLimitSeries"; import CustomPanelTabs from "./CustomPanelTabs"; import { DisplayType } from "../../types"; +import DownloadReport from "./DownloadReport/DownloadReport"; const CustomPanel: FC = () => { useSetQueryParams(); @@ -35,6 +36,7 @@ const CustomPanel: FC = () => { const controlsRef = useRef(null); const { + fetchUrl, isLoading, liveData, graphData, @@ -111,7 +113,10 @@ const CustomPanel: FC = () => { className="vm-custom-panel-body-header" ref={controlsRef} > - {} +
+ +
+ {(graphData || liveData) && } void + onClose: () => void +} + +const JsonForm: FC = ({ onClose, onUpload }) => { + const { isMobile } = useDeviceDetect(); + + const [json, setJson] = useState(""); + const [error, setError] = useState(""); + + const errorJson = useMemo(() => { + try { + JSON.parse(json); + return ""; + } catch (e) { + return e instanceof Error ? e.message : "Unknown error"; + } + }, [json]); + + const handleChangeJson = (val: string) => { + setError(""); + setJson(val); + }; + + const handleApply = () => { + setError(errorJson); + if (errorJson) return; + onUpload(json); + onClose(); + }; + + return ( +
+ +
+
+ + +
+
+
+ ); +}; + +export default JsonForm; diff --git a/app/vmui/packages/vmui/src/pages/QueryAnalyzer/JsonForm/style.scss b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/JsonForm/style.scss new file mode 100644 index 000000000..9846dc215 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/JsonForm/style.scss @@ -0,0 +1,73 @@ +@use "src/styles/variables" as *; + +.vm-json-form { + display: grid; + grid-template-rows: auto calc(($vh * 70) - 78px - ($padding-medium*3)) auto; + gap: $padding-global; + width: 70vw; + max-width: 1000px; + max-height: 900px; + overflow: hidden; + + &_mobile { + width: 100%; + min-height: 100%; + grid-template-rows: auto calc(($vh * 100) - 200px - ($padding-global*3)) auto; + } + + &_one-field { + grid-template-rows: calc(($vh * 70) - 78px - ($padding-medium*3)) auto; + + &_mobile { + grid-template-rows: calc(($vh * 100) - 160px - ($padding-global*2)) auto; + } + } + + textarea { + overflow: auto; + width: 100%; + height: 100%; + max-height: 900px; + } + + &-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: $padding-small; + + @media (max-width: 500px) { + flex-direction: column; + + button { + flex-grow: 1; + } + } + + &__controls { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: flex-start; + gap: $padding-small; + + @media (max-width: 500px) { + grid-template-columns: repeat(2, 1fr); + justify-content: center; + width: 100%; + } + + &_right { + display: grid; + grid-template-columns: repeat(2, 90px); + justify-content: flex-end; + + @media (max-width: 500px) { + grid-template-columns: repeat(2, 1fr); + justify-content: center; + width: 100%; + } + } + } + } +} diff --git a/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerInfo/QueryAnalyzerInfo.tsx b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerInfo/QueryAnalyzerInfo.tsx new file mode 100644 index 000000000..b72cbdcd9 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerInfo/QueryAnalyzerInfo.tsx @@ -0,0 +1,97 @@ +import React, { FC, useMemo } from "preact/compat"; +import { DataAnalyzerType } from "../index"; +import Button from "../../../components/Main/Button/Button"; +import { ClockIcon, InfoIcon, TimelineIcon } from "../../../components/Main/Icons"; +import useBoolean from "../../../hooks/useBoolean"; +import Modal from "../../../components/Main/Modal/Modal"; +import { TimeParams } from "../../../types"; +import "./style.scss"; +import dayjs from "dayjs"; +import { DATE_TIME_FORMAT } from "../../../constants/date"; + +type Props = { + data: DataAnalyzerType[]; + period?: TimeParams; +} + +const QueryAnalyzerInfo: FC = ({ data, period }) => { + const dataWithStats = useMemo(() => data.filter(d => d.stats && d.data.resultType === "matrix"), [data]); + const comment = useMemo(() => data.find(d => d?.vmui?.comment)?.vmui?.comment, [data]); + + const timeRange = useMemo(() => { + if (!period) return ""; + const start = dayjs(period.start * 1000).tz().format(DATE_TIME_FORMAT); + const end = dayjs(period.end * 1000).tz().format(DATE_TIME_FORMAT); + return `${start} - ${end}`; + }, [period]); + + const { + value: openModal, + setTrue: handleOpenModal, + setFalse: handleCloseModal, + } = useBoolean(false); + + return ( + <> +
+ + {period && ( + <> +
+ step: {period.step} +
+
+ {timeRange} +
+ + )} +
+ + {openModal && ( + +
+ {comment && ( +
+
Comment:
+
{comment}
+
+ )} + {dataWithStats.map((d, i) => ( +
+
+ {dataWithStats.length > 1 ? `Query ${i + 1}:` : "Stats:"} +
+
+ {Object.entries(d.stats || {}).map(([key, value]) => ( +
+ {key}: {value ?? "-"} +
+ ))} + isPartial: {String(d.isPartial ?? "-")} +
+
+ ))} +
+ {dataWithStats[0]?.vmui?.params ? "The report was created using vmui" : "The report was created manually"} +
+
+
+ )} + + ); +}; + +export default QueryAnalyzerInfo; diff --git a/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerInfo/style.scss b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerInfo/style.scss new file mode 100644 index 000000000..3acfc2f09 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerInfo/style.scss @@ -0,0 +1,47 @@ +@use "src/styles/variables" as *; + +.vm-query-analyzer-info-header { + display: flex; + gap: $padding-global; + + &__period { + display: flex; + align-items: center; + gap: $padding-small; + border: $border-divider; + border-radius: $border-radius-small; + padding: 6px $padding-global; + + svg { + width: calc($font-size-small + 1px); + color: $color-primary; + } + } +} + +.vm-query-analyzer-info { + display: grid; + gap: $padding-large; + min-width: 300px; + + &-type { + text-align: center; + font-style: italic; + color: $color-text-secondary; + } + + &-item { + display: grid; + padding-bottom: $padding-large; + border-bottom: $border-divider; + line-height: 130%; + + &__title { + font-weight: bold; + } + + &__text { + white-space: pre-wrap; + } + } +} diff --git a/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/QueryAnalyzerView.tsx b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/QueryAnalyzerView.tsx new file mode 100644 index 000000000..9fd277fbd --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/QueryAnalyzerView.tsx @@ -0,0 +1,180 @@ +import React, { FC, useMemo, useState, useEffect } from "preact/compat"; +import Trace from "../../../components/TraceQuery/Trace"; +import { DataAnalyzerType } from "../index"; +import classNames from "classnames"; +import { displayTypeTabs } from "../../CustomPanel/DisplayTypeSwitch"; +import GraphTips from "../../../components/Chart/GraphTips/GraphTips"; +import GraphSettings from "../../../components/Configurators/GraphSettings/GraphSettings"; +import useDeviceDetect from "../../../hooks/useDeviceDetect"; +import { AxisRange } from "../../../state/graph/reducer"; +import { useGraphDispatch, useGraphState } from "../../../state/graph/GraphStateContext"; +import Tabs from "../../../components/Main/Tabs/Tabs"; +import TracingsView from "../../../components/TraceQuery/TracingsView"; +import "./style.scss"; +import GraphView from "../../../components/Views/GraphView/GraphView"; +import JsonView from "../../../components/Views/JsonView/JsonView"; +import { InstantMetricResult, MetricResult } from "../../../api/types"; +import { isHistogramData } from "../../../utils/metric"; +import { DisplayType, TimeParams } from "../../../types"; +import TableSettings from "../../../components/Table/TableSettings/TableSettings"; +import { getColumns } from "../../../hooks/useSortedCategories"; +import { useCustomPanelDispatch, useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext"; +import TableView from "../../../components/Views/TableView/TableView"; + +type Props = { + data: DataAnalyzerType[]; + period?: TimeParams; +} + +const QueryAnalyzerView: FC = ({ data, period }) => { + const { isMobile } = useDeviceDetect(); + const { tableCompact } = useCustomPanelState(); + const customPanelDispatch = useCustomPanelDispatch(); + + const [traces, setTraces] = useState([]); + const [graphData, setGraphData] = useState(); + const [liveData, setLiveData] = useState(); + const [isHistogram, setIsHistogram] = useState(false); + const [queries, setQueries] = useState([]); + const [displayColumns, setDisplayColumns] = useState(); + + const columns = useMemo(() => getColumns(liveData || []).map(c => c.key), [liveData]); + + const tabs = useMemo(() => { + const hasQueryRange = data.some(d => d.data.resultType === "matrix"); + const hasInstantQuery = data.some(d => d.data.resultType === "vector"); + if (hasInstantQuery && hasQueryRange) return displayTypeTabs; + if (!hasQueryRange) return displayTypeTabs.filter(t => t.value !== "chart"); + return displayTypeTabs.filter(t => t.value === "chart"); + }, [data]); + const [displayType, setDisplayType] = useState(tabs[0].value); + + const { yaxis } = useGraphState(); + const graphDispatch = useGraphDispatch(); + + const setYaxisLimits = (limits: AxisRange) => { + graphDispatch({ type: "SET_YAXIS_LIMITS", payload: limits }); + }; + + const toggleEnableLimits = () => { + graphDispatch({ type: "TOGGLE_ENABLE_YAXIS_LIMITS" }); + }; + + const handleChangeDisplayType = (newValue: string) => { + setDisplayType(newValue as DisplayType); + }; + + const handleTraceDelete = (trace: Trace) => { + setTraces(prev => prev.filter((data) => data.idValue !== trace.idValue)); + }; + + const toggleTableCompact = () => { + customPanelDispatch({ type: "TOGGLE_TABLE_COMPACT" }); + }; + + useEffect(() => { + const resultType = displayType === "chart" ? "matrix" : "vector"; + const traces = data.filter(d => d.data.resultType === resultType && d.trace) + .map(d => d.trace ? new Trace(d.trace, d?.vmui?.params?.query || "Query") : null); + setTraces(traces.filter(Boolean) as Trace[]); + }, [data, displayType]); + + useEffect(() => { + const tempQueries: string[] = []; + const tempGraphData: MetricResult[] = []; + const tempLiveData: InstantMetricResult[] = []; + + data.forEach((d, i) => { + const result = d.data.result.map((r) => ({ ...r, group: Number(d.vmui?.params?.id ?? i) + 1 })); + if (d.data.resultType === "matrix") { + tempGraphData.push(...result as MetricResult[]); + tempQueries.push(d.vmui?.params?.query || "Query"); + } else { + tempLiveData.push(...result as InstantMetricResult[]); + } + }); + + setQueries(tempQueries); + setGraphData(tempGraphData); + setLiveData(tempLiveData); + }, [data]); + + useEffect(() => { + setIsHistogram(!!graphData && isHistogramData(graphData)); + }, [graphData]); + + return ( +
+ {!!traces.length && ( + + )} +
+
+
+ +
+
+ {displayType === "chart" && } + {displayType === "chart" && ( + + )} + {displayType === "table" && ( + + )} +
+
+ {graphData && period && (displayType === "chart") && ( + null} + height={isMobile ? window.innerHeight * 0.5 : 500} + isHistogram={isHistogram} + /> + )} + {liveData && (displayType === "code") && ( + + )} + {liveData && (displayType === "table") && ( + + )} +
+
+ ); +}; + +export default QueryAnalyzerView; diff --git a/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/style.scss b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/style.scss new file mode 100644 index 000000000..5eeb43cfb --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/style.scss @@ -0,0 +1,30 @@ +@use "src/styles/variables" as *; + +.vm-query-analyzer-view { + display: grid; + gap: $padding-global; + position: relative; + + &-header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + font-size: $font-size-small; + margin: -$padding-medium 0-$padding-medium $padding-medium; + padding: 0 $padding-medium; + border-bottom: $border-divider; + z-index: 1; + + &__left { + display: flex; + align-items: center; + gap: $padding-small; + } + } + + &_mobile &-header { + margin: -$padding-global 0-$padding-global $padding-global; + padding: 0 $padding-global; + } +} diff --git a/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/utils.ts b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/utils.ts new file mode 100644 index 000000000..0b73d3734 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/QueryAnalyzerView/utils.ts @@ -0,0 +1,20 @@ +export const findMostCommonStep = (numbers: number[]) => { + const differences: number[] = numbers.slice(1).map((num, i) => num - numbers[i]); + + const counts: { [key: string]: number } = {}; + differences.forEach(diff => { + const key = diff.toString(); + counts[key] = (counts[key] || 0) + 1; + }); + + let mostCommonStep = 0; + let maxCount = 0; + for (const diff in counts) { + if (counts[diff] > maxCount) { + maxCount = counts[diff]; + mostCommonStep = Number(diff); + } + } + + return mostCommonStep; +}; diff --git a/app/vmui/packages/vmui/src/pages/QueryAnalyzer/index.tsx b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/index.tsx new file mode 100644 index 000000000..cd224b991 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/QueryAnalyzer/index.tsx @@ -0,0 +1,205 @@ +import React, { FC, useEffect, useMemo, useState } from "preact/compat"; +import { ChangeEvent } from "react"; +import Button from "../../components/Main/Button/Button"; +import Alert from "../../components/Main/Alert/Alert"; +import { CloseIcon } from "../../components/Main/Icons"; +import Modal from "../../components/Main/Modal/Modal"; +import useDropzone from "../../hooks/useDropzone"; +import useBoolean from "../../hooks/useBoolean"; +import UploadJsonButtons from "../../components/UploadJsonButtons/UploadJsonButtons"; +import JsonForm from "./JsonForm/JsonForm"; +import "../TracePage/style.scss"; +import QueryAnalyzerView from "./QueryAnalyzerView/QueryAnalyzerView"; +import { InstantMetricResult, MetricResult, TracingData } from "../../api/types"; +import QueryAnalyzerInfo from "./QueryAnalyzerInfo/QueryAnalyzerInfo"; +import { TimeParams } from "../../types"; +import { dateFromSeconds, formatDateToUTC, humanizeSeconds } from "../../utils/time"; +import { findMostCommonStep } from "./QueryAnalyzerView/utils"; + +export type DataAnalyzerType = { + data: { + resultType: "vector" | "matrix"; + result: MetricResult[] | InstantMetricResult[] + }; + stats?: { + seriesFetched?: string; + executionTimeMsec?: number + }; + vmui?: { + id: number; + comment: string; + params: Record; + }; + status: string; + trace?: TracingData; + isPartial?: boolean; +} + +const QueryAnalyzer: FC = () => { + const [data, setData] = useState([]); + const [error, setError] = useState(""); + const hasData = useMemo(() => !!data.length, [data]); + + const { + value: openModal, + setTrue: handleOpenModal, + setFalse: handleCloseModal, + } = useBoolean(false); + + const period: TimeParams | undefined = useMemo(() => { + if (!data) return; + const params = data[0]?.vmui?.params; + + const result = { + start: +(params?.start || 0), + end: +(params?.end || 0), + step: params?.step, + date: "" + }; + + if (!params) { + const dataResult = data.filter(d => d.data.resultType === "matrix").map(d => d.data.result).flat(); + const times = dataResult.map(r => r.values ? r.values?.map(v => v[0]) : [0]).flat(); + const uniqTimes = Array.from(new Set(times.filter(Boolean))).sort((a, b) => a - b); + result.start = uniqTimes[0]; + result.end = uniqTimes[uniqTimes.length - 1]; + result.step = humanizeSeconds(findMostCommonStep(uniqTimes)); + } + + result.date = formatDateToUTC(dateFromSeconds(result.end)); + return result; + }, [data]); + + const isValidResponse = (response: unknown[]): boolean => { + return response.every(element => { + if (typeof element === "object" && element !== null) { + const data = (element as { data?: unknown }).data; + if (typeof data === "object" && data !== null) { + const result = (data as { result?: unknown }).result; + const resultType = (data as { resultType?: unknown }).resultType; + return Array.isArray(result) && typeof resultType === "string"; + } + } + return false; + }); + }; + + const handleOnload = (result: string) => { + try { + const obj = JSON.parse(result); + const response = Array.isArray(obj) ? obj : [obj]; + if (isValidResponse(response)) { + setData(response); + } else { + setError("Invalid structure - JSON does not match the expected format"); + } + } catch (e) { + if (e instanceof Error) { + setError(`${e.name}: ${e.message}`); + } + } + }; + + const handleReadFiles = (files: File[]) => { + files.map(f => { + const reader = new FileReader(); + reader.onload = (e) => { + const result = String(e.target?.result); + handleOnload(result); + }; + reader.readAsText(f); + }); + }; + + const handleChange = (e: ChangeEvent) => { + setError(""); + const files = Array.from(e.target.files || []); + handleReadFiles(files); + e.target.value = ""; + }; + + const handleCloseError = () => { + setError(""); + }; + + const { files, dragging } = useDropzone(); + + useEffect(() => { + handleReadFiles(files); + }, [files]); + + return ( +
+ {hasData && ( +
+
+ +
+
+ +
+
+ )} + + {error && ( +
+ {error} +
+ )} + + {hasData && ( + + )} + + {!hasData && ( +
+

+ Please, upload file with JSON response content. + {"\n"} + The file must contain query information in JSON format. + {"\n"} + Graph will be displayed after file upload. + {"\n"} + Attach files by dragging & dropping, selecting or pasting them. +

+ +
+ )} + + {openModal && ( + + + + )} + + {dragging &&
} +
+ ); +}; + +export default QueryAnalyzer; diff --git a/app/vmui/packages/vmui/src/pages/TracePage/TraceUploadButtons/TraceUploadButtons.tsx b/app/vmui/packages/vmui/src/pages/TracePage/TraceUploadButtons/TraceUploadButtons.tsx deleted file mode 100644 index a4ed9606d..000000000 --- a/app/vmui/packages/vmui/src/pages/TracePage/TraceUploadButtons/TraceUploadButtons.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React, { FC } from "preact/compat"; -import Button from "../../../components/Main/Button/Button"; -import Tooltip from "../../../components/Main/Tooltip/Tooltip"; -import { ChangeEvent } from "react"; - -interface TraceUploadButtonsProps { - onOpenModal: () => void; - onChange: (e: ChangeEvent) => void; -} - -const TraceUploadButtons: FC = ({ onOpenModal, onChange }) => ( -
- - - - -
-); - -export default TraceUploadButtons; diff --git a/app/vmui/packages/vmui/src/pages/TracePage/index.tsx b/app/vmui/packages/vmui/src/pages/TracePage/index.tsx index acec6ebef..3cb98baa0 100644 --- a/app/vmui/packages/vmui/src/pages/TracePage/index.tsx +++ b/app/vmui/packages/vmui/src/pages/TracePage/index.tsx @@ -10,7 +10,7 @@ import Modal from "../../components/Main/Modal/Modal"; import JsonForm from "./JsonForm/JsonForm"; import { ErrorTypes } from "../../types"; import useDropzone from "../../hooks/useDropzone"; -import TraceUploadButtons from "./TraceUploadButtons/TraceUploadButtons"; +import UploadJsonButtons from "../../components/UploadJsonButtons/UploadJsonButtons"; import useBoolean from "../../hooks/useBoolean"; const TracePage: FC = () => { @@ -106,7 +106,7 @@ const TracePage: FC = () => {
{hasTraces && ( - @@ -145,7 +145,7 @@ const TracePage: FC = () => { {"\n"} Attach files by dragging & dropping, selecting or pasting them.

- diff --git a/app/vmui/packages/vmui/src/pages/TracePage/style.scss b/app/vmui/packages/vmui/src/pages/TracePage/style.scss index a89c1879e..db5c90d6e 100644 --- a/app/vmui/packages/vmui/src/pages/TracePage/style.scss +++ b/app/vmui/packages/vmui/src/pages/TracePage/style.scss @@ -9,14 +9,6 @@ padding: $padding-medium 0; } - &-controls { - display: grid; - grid-template-columns: 1fr 1fr; - gap: $padding-global; - align-items: center; - justify-content: center; - } - &-header { display: grid; grid-template-columns: 1fr auto; @@ -46,6 +38,10 @@ align-items: center; justify-content: stretch; + &_margin-bottom { + margin-bottom: $padding-global; + } + &__filename { min-height: 20px; } diff --git a/app/vmui/packages/vmui/src/router/index.ts b/app/vmui/packages/vmui/src/router/index.ts index 517c45aa0..46856e849 100644 --- a/app/vmui/packages/vmui/src/router/index.ts +++ b/app/vmui/packages/vmui/src/router/index.ts @@ -11,6 +11,7 @@ const router = { relabel: "/relabeling", logs: "/logs", activeQueries: "/active-queries", + queryAnalyzer: "/query-analyzer", icons: "/icons", anomaly: "/anomaly", query: "/query", @@ -72,6 +73,10 @@ export const routerOptions: {[key: string]: RouterOptions} = { title: "Trace analyzer", header: {} }, + [router.queryAnalyzer]: { + title: "Query analyzer", + header: {} + }, [router.dashboards]: { title: "Dashboards", ...routerOptionsDefault, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 09fb460a9..226f782cd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -54,6 +54,9 @@ The sandbox cluster installation is running under the constant load generated by * FEATURE: [vmctl](https://docs.victoriametrics.com/vmctl.html): add `-vm-native-src-insecure-skip-verify` and `-vm-native-dst-insecure-skip-verify` command-line flags for native protocol. It can be used for skipping TLS certificate verification when connecting to the source or destination addresses. * FEATURE: [Alerting rules for VictoriaMetrics](https://github.com/VictoriaMetrics/VictoriaMetrics/tree/master/deployment/docker#alerts): add `job` label to `DiskRunsOutOfSpace` alerting rule, so it is easier to understand to which installation the triggered instance belongs. * FEATURE: `vmstorage`: add tenant identifier for log messages regarding dropping excessive labels due to limits defined by `-maxLabelsPerTimeseries` or `-maxLabelValueLen` command-line flags. Previously, it was hard to understand to which tenant the dropped labels belong. +* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add the ability to export and import query reports: + - add a `Query Analyzer` page that allows you to build graphs from `JSON` data containing the results of executing a query request. + - add an `Export query` button to the graph that saves the result of executing the query in `JSON`. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/5497). * FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add `-vmui.defaultTimezone` flag to set a default timezone. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5375) and [these docs](https://github.com/VictoriaMetrics/VictoriaMetrics/tree/master/app/vmui#timezone-configuration). * FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): include UTC in the timezone selection dropdown for standardized time referencing. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5375). * FEATURE: add [VictoriaMetrics datasource](https://github.com/VictoriaMetrics/grafana-datasource) to docker compose environment. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/5363).