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 <valyala@victoriametrics.com>
This commit is contained in:
Yury Molodov 2024-01-23 03:23:26 +01:00 committed by Aliaksandr Valialkin
parent 3a26e4d6ec
commit 1db2b991b7
No known key found for this signature in database
GPG key ID: 52C003EE2BCDB9EB
29 changed files with 1175 additions and 53 deletions

View file

@ -14,6 +14,7 @@ import PreviewIcons from "./components/Main/Icons/PreviewIcons";
import WithTemplate from "./pages/WithTemplate"; import WithTemplate from "./pages/WithTemplate";
import Relabel from "./pages/Relabel"; import Relabel from "./pages/Relabel";
import ActiveQueries from "./pages/ActiveQueries"; import ActiveQueries from "./pages/ActiveQueries";
import QueryAnalyzer from "./pages/QueryAnalyzer";
const App: FC = () => { const App: FC = () => {
const [loadedTheme, setLoadedTheme] = useState(false); const [loadedTheme, setLoadedTheme] = useState(false);
@ -49,6 +50,10 @@ const App: FC = () => {
path={router.trace} path={router.trace}
element={<TracePage/>} element={<TracePage/>}
/> />
<Route
path={router.queryAnalyzer}
element={<QueryAnalyzer/>}
/>
<Route <Route
path={router.dashboards} path={router.dashboards}
element={<DashboardsLayout/>} element={<DashboardsLayout/>}

View file

@ -5,7 +5,7 @@ import "./style.scss";
interface ButtonProps { interface ButtonProps {
variant?: "contained" | "outlined" | "text" variant?: "contained" | "outlined" | "text"
color?: "primary" | "secondary" | "success" | "error" | "gray" | "warning" color?: "primary" | "secondary" | "success" | "error" | "gray" | "warning" | "white"
size?: "small" | "medium" | "large" size?: "small" | "medium" | "large"
ariaLabel?: string // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label ariaLabel?: string // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label
endIcon?: ReactNode endIcon?: ReactNode

View file

@ -9,7 +9,7 @@ $button-radius: 6px;
justify-content: center; justify-content: center;
padding: 6px 14px; padding: 6px 14px;
font-size: $font-size-small; font-size: $font-size-small;
line-height: 1.3; line-height: calc($font-size + 1px);
font-weight: normal; font-weight: normal;
min-height: 31px; min-height: 31px;
border-radius: $button-radius; border-radius: $button-radius;
@ -56,7 +56,7 @@ $button-radius: 6px;
transform: translateZ(1px); transform: translateZ(1px);
svg { svg {
width: 15px; width: calc($font-size + 1px);
} }
} }
@ -180,6 +180,10 @@ $button-radius: 6px;
color: $color-text-secondary; color: $color-text-secondary;
} }
&_text_white {
color: $color-white;
}
&_text_warning { &_text_warning {
color: $color-warning; color: $color-warning;
} }
@ -211,6 +215,11 @@ $button-radius: 6px;
color: $color-text-secondary; color: $color-text-secondary;
} }
&_outlined_white {
border: 1px solid $color-white;
color: $color-white;
}
&_outlined_warning { &_outlined_warning {
border: 1px solid $color-warning; border: 1px solid $color-warning;
color: $color-warning; color: $color-warning;

View file

@ -511,3 +511,12 @@ export const ValueIcon = () => (
/> />
</svg> </svg>
); );
export const DownloadIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path>
</svg>
);

View file

@ -23,6 +23,7 @@ interface PopperProps {
fullWidth?: boolean fullWidth?: boolean
title?: string title?: string
disabledFullScreen?: boolean disabledFullScreen?: boolean
variant?: "default" | "dark"
} }
const Popper: FC<PopperProps> = ({ const Popper: FC<PopperProps> = ({
@ -35,7 +36,8 @@ const Popper: FC<PopperProps> = ({
clickOutside = true, clickOutside = true,
fullWidth, fullWidth,
title, title,
disabledFullScreen disabledFullScreen,
variant
}) => { }) => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const navigate = useNavigate(); const navigate = useNavigate();
@ -147,6 +149,7 @@ const Popper: FC<PopperProps> = ({
<div <div
className={classNames({ className={classNames({
"vm-popper": true, "vm-popper": true,
[`vm-popper_${variant}`]: variant,
"vm-popper_mobile": isMobile && !disabledFullScreen, "vm-popper_mobile": isMobile && !disabledFullScreen,
"vm-popper_open": (isMobile || Object.keys(popperStyle).length) && isOpen, "vm-popper_open": (isMobile || Object.keys(popperStyle).length) && isOpen,
})} })}
@ -158,6 +161,7 @@ const Popper: FC<PopperProps> = ({
<p className="vm-popper-header__title">{title}</p> <p className="vm-popper-header__title">{title}</p>
<Button <Button
variant="text" variant="text"
color={variant === "dark" ? "white" : "primary"}
size="small" size="small"
onClick={handleClickClose} onClick={handleClickClose}
ariaLabel="close" ariaLabel="close"

View file

@ -49,6 +49,16 @@
user-select: none; user-select: none;
} }
} }
&_dark {
background-color: $color-background-tooltip;
color: $color-white;
}
&_dark &-header {
background-color: transparent;
color: $color-white;
}
} }
@keyframes vm-slider { @keyframes vm-slider {

View file

@ -0,0 +1,33 @@
import React, { FC } from "preact/compat";
import { ChangeEvent } from "react";
import Button from "../Main/Button/Button";
import "./style.scss";
interface Props {
onOpenModal: () => void;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}
const UploadJsonButtons: FC<Props> = ({ onOpenModal, onChange }) => (
<div className="vm-upload-json-buttons">
<Button
variant="outlined"
onClick={onOpenModal}
>
Paste JSON
</Button>
<Button>
Upload Files
<input
id="json"
type="file"
accept="application/json"
multiple
title=" "
onChange={onChange}
/>
</Button>
</div>
);
export default UploadJsonButtons;

View file

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

View file

@ -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_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_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_ISO_FORMAT = "YYYY-MM-DD[T]HH:mm:ss";
export const DATE_FILENAME_FORMAT = "YYYY-MM-DD_HHmmss";

View file

@ -36,6 +36,10 @@ const tools = {
label: routerOptions[router.trace].title, label: routerOptions[router.trace].title,
value: router.trace, value: router.trace,
}, },
{
label: routerOptions[router.queryAnalyzer].title,
value: router.queryAnalyzer,
},
{ {
label: routerOptions[router.withTemplate].title, label: routerOptions[router.withTemplate].title,
value: router.withTemplate, value: router.withTemplate,

View file

@ -212,5 +212,17 @@ export const useFetchQuery = ({
if (defaultStep === customStep) setGraphData([]); if (defaultStep === customStep) setGraphData([]);
}, [isHistogram]); }, [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
};
}; };

View file

@ -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<Props> = ({ fetchUrl }) => {
const { query } = useQueryState();
const [filename, setFilename] = useState(getDefaultReportName());
const [comment, setComment] = useState("");
const [trace, setTrace] = useState(true);
const [error, setError] = useState<ErrorTypes | string>();
const [isLoading, setIsLoading] = useState(false);
const filenameRef = useRef<HTMLDivElement>(null);
const commentRef = useRef<HTMLDivElement>(null);
const traceRef = useRef<HTMLDivElement>(null);
const generateRef = useRef<HTMLDivElement>(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 (
<>
<Tooltip title={"Export query"}>
<Button
variant="text"
startIcon={<DownloadIcon/>}
onClick={toggleOpen}
ariaLabel="export query"
/>
</Tooltip>
{openModal && (
<Modal
title={"Export query"}
onClose={handleClose}
isOpen={openModal}
>
<div className="vm-download-report">
<div className="vm-download-report-settings">
<div ref={filenameRef}>
<TextField
label="Filename"
value={filename}
onChange={setFilename}
/>
</div>
<div ref={commentRef}>
<TextField
type="textarea"
label="Comment"
value={comment}
onChange={setComment}
/>
</div>
<div ref={traceRef}>
<Checkbox
checked={trace}
onChange={setTrace}
label={"Include query trace"}
/>
</div>
</div>
{error && <Alert variant="error">{error}</Alert>}
<div className="vm-download-report__buttons">
<Button
variant="text"
onClick={toggleHelper}
>
Help
</Button>
<div ref={generateRef}>
<Button
onClick={handleGenerateReport}
disabled={isLoading}
>
{isLoading ? "Loading data..." : "Generate Report"}
</Button>
</div>
</div>
<Popper
open={openHelper}
buttonRef={helperRefs[stepHelper]}
placement="top-left"
variant="dark"
onClose={handleCloseHelper}
>
<div className="vm-download-report-helper">
<div className="vm-download-report-helper__description">
{helperText[stepHelper]}
</div>
<div className="vm-download-report-helper__buttons">
{stepHelper !== 0 && (
<Button
onClick={handleChangeHelp(-1)}
size="small"
color={"white"}
>
Prev
</Button>
)}
<Button
onClick={stepHelper === helperRefs.length - 1 ? handleCloseHelper : handleChangeHelp(1)}
size="small"
color={"white"}
variant={"text"}
>
{stepHelper === helperRefs.length - 1 ? "Close" : "Next"}
</Button>
</div>
</div>
</Popper>
</div>
</Modal>
)}
</>
);
};
export default DownloadReport;

View file

@ -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 = (
<>
<p>Filename - specify the name for your report file.</p>
<p>Default format: <code>vmui_report_${DATE_FILENAME_FORMAT}.json</code>.</p>
<p>This name will be used when saving your report on your device.</p>
</>
);
const comment = (
<>
<p>Comment (optional) - add a comment to your report.</p>
<p>This can be any additional information that will be useful when reviewing the report later.</p>
</>
);
const trace = (
<>
<p>Query trace - enable this option to include a query trace in your report.</p>
<p>This will assist in analyzing and diagnosing the query processing.</p>
</>
);
const generate = (
<>
<p>Generate Report - click this button to generate and save your report. </p>
<p>After creation, the report can be downloaded and examined on the <Link
to={router.queryAnalyzer}
target="_blank"
rel="noreferrer"
className="vm-link vm-link_underlined"
>{routerOptions[router.queryAnalyzer].title}</Link> page.</p>
</>
);
export default [
filename,
comment,
trace,
generate,
];

View file

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

View file

@ -18,6 +18,7 @@ import CustomPanelTraces from "./CustomPanelTraces/CustomPanelTraces";
import WarningLimitSeries from "./WarningLimitSeries/WarningLimitSeries"; import WarningLimitSeries from "./WarningLimitSeries/WarningLimitSeries";
import CustomPanelTabs from "./CustomPanelTabs"; import CustomPanelTabs from "./CustomPanelTabs";
import { DisplayType } from "../../types"; import { DisplayType } from "../../types";
import DownloadReport from "./DownloadReport/DownloadReport";
const CustomPanel: FC = () => { const CustomPanel: FC = () => {
useSetQueryParams(); useSetQueryParams();
@ -35,6 +36,7 @@ const CustomPanel: FC = () => {
const controlsRef = useRef<HTMLDivElement>(null); const controlsRef = useRef<HTMLDivElement>(null);
const { const {
fetchUrl,
isLoading, isLoading,
liveData, liveData,
graphData, graphData,
@ -111,7 +113,10 @@ const CustomPanel: FC = () => {
className="vm-custom-panel-body-header" className="vm-custom-panel-body-header"
ref={controlsRef} ref={controlsRef}
> >
{<DisplayTypeSwitch/>} <div className="vm-custom-panel-body-header__tabs">
<DisplayTypeSwitch/>
</div>
{(graphData || liveData) && <DownloadReport fetchUrl={fetchUrl}/>}
</div> </div>
<CustomPanelTabs <CustomPanelTabs
graphData={graphData} graphData={graphData}

View file

@ -33,13 +33,19 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
font-size: $font-size-small; font-size: $font-size-small;
margin: -$padding-medium 0-$padding-medium $padding-medium; margin: -$padding-medium 0-$padding-medium $padding-medium;
padding: 0 $padding-medium; padding: 0 $padding-medium;
border-bottom: $border-divider; border-bottom: $border-divider;
z-index: 1; z-index: 1;
&__tabs {
display: flex;
justify-content: flex-start;
flex-grow: 1;
}
&__graph-controls { &__graph-controls {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -0,0 +1,77 @@
import React, { FC, useState, useMemo } from "preact/compat";
import TextField from "../../../components/Main/TextField/TextField";
import "./style.scss";
import Button from "../../../components/Main/Button/Button";
import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface JsonFormProps {
onUpload: (json: string) => void
onClose: () => void
}
const JsonForm: FC<JsonFormProps> = ({ 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 (
<div
className={classNames({
"vm-json-form vm-json-form_one-field": true,
"vm-json-form_mobile vm-json-form_one-field_mobile": isMobile,
})}
>
<TextField
value={json}
label="JSON"
type="textarea"
error={error}
autofocus
onChange={handleChangeJson}
onEnter={handleApply}
/>
<div className="vm-json-form-footer">
<div className="vm-json-form-footer__controls vm-json-form-footer__controls_right">
<Button
variant="outlined"
color="error"
onClick={onClose}
>
Cancel
</Button>
<Button
variant="contained"
onClick={handleApply}
>
apply
</Button>
</div>
</div>
</div>
);
};
export default JsonForm;

View file

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

View file

@ -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<Props> = ({ 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 (
<>
<div className="vm-query-analyzer-info-header">
<Button
startIcon={<InfoIcon/>}
variant="outlined"
color="warning"
onClick={handleOpenModal}
>
Show report info
</Button>
{period && (
<>
<div className="vm-query-analyzer-info-header__period">
<TimelineIcon/> step: {period.step}
</div>
<div className="vm-query-analyzer-info-header__period">
<ClockIcon/> {timeRange}
</div>
</>
)}
</div>
{openModal && (
<Modal
title="Report info"
onClose={handleCloseModal}
>
<div className="vm-query-analyzer-info">
{comment && (
<div className="vm-query-analyzer-info-item vm-query-analyzer-info-item_comment">
<div className="vm-query-analyzer-info-item__title">Comment:</div>
<div className="vm-query-analyzer-info-item__text">{comment}</div>
</div>
)}
{dataWithStats.map((d, i) => (
<div
className="vm-query-analyzer-info-item"
key={i}
>
<div className="vm-query-analyzer-info-item__title">
{dataWithStats.length > 1 ? `Query ${i + 1}:` : "Stats:"}
</div>
<div className="vm-query-analyzer-info-item__text">
{Object.entries(d.stats || {}).map(([key, value]) => (
<div key={key}>
{key}: {value ?? "-"}
</div>
))}
isPartial: {String(d.isPartial ?? "-")}
</div>
</div>
))}
<div className="vm-query-analyzer-info-type">
{dataWithStats[0]?.vmui?.params ? "The report was created using vmui" : "The report was created manually"}
</div>
</div>
</Modal>
)}
</>
);
};
export default QueryAnalyzerInfo;

View file

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

View file

@ -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<Props> = ({ data, period }) => {
const { isMobile } = useDeviceDetect();
const { tableCompact } = useCustomPanelState();
const customPanelDispatch = useCustomPanelDispatch();
const [traces, setTraces] = useState<Trace[]>([]);
const [graphData, setGraphData] = useState<MetricResult[]>();
const [liveData, setLiveData] = useState<InstantMetricResult[]>();
const [isHistogram, setIsHistogram] = useState(false);
const [queries, setQueries] = useState<string[]>([]);
const [displayColumns, setDisplayColumns] = useState<string[]>();
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 (
<div
className={classNames({
"vm-query-analyzer-view": true,
"vm-query-analyzer-view_mobile": isMobile,
})}
>
{!!traces.length && (
<TracingsView
traces={traces}
onDeleteClick={handleTraceDelete}
/>
)}
<div
className={classNames({
"vm-block": true,
"vm-block_mobile": isMobile,
})}
>
<div className="vm-custom-panel-body-header">
<div className="vm-custom-panel-body-header__tabs">
<Tabs
activeItem={displayType}
items={tabs}
onChange={handleChangeDisplayType}
/>
</div>
<div className="vm-custom-panel-body-header__graph-controls">
{displayType === "chart" && <GraphTips/>}
{displayType === "chart" && (
<GraphSettings
yaxis={yaxis}
setYaxisLimits={setYaxisLimits}
toggleEnableLimits={toggleEnableLimits}
/>
)}
{displayType === "table" && (
<TableSettings
columns={columns}
defaultColumns={displayColumns}
onChangeColumns={setDisplayColumns}
tableCompact={tableCompact}
toggleTableCompact={toggleTableCompact}
/>
)}
</div>
</div>
{graphData && period && (displayType === "chart") && (
<GraphView
data={graphData}
period={period}
customStep={period.step || "1s"}
query={queries}
yaxis={yaxis}
setYaxisLimits={setYaxisLimits}
setPeriod={() => null}
height={isMobile ? window.innerHeight * 0.5 : 500}
isHistogram={isHistogram}
/>
)}
{liveData && (displayType === "code") && (
<JsonView data={liveData}/>
)}
{liveData && (displayType === "table") && (
<TableView
data={liveData}
displayColumns={displayColumns}
/>
)}
</div>
</div>
);
};
export default QueryAnalyzerView;

View file

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

View file

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

View file

@ -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<string, string>;
};
status: string;
trace?: TracingData;
isPartial?: boolean;
}
const QueryAnalyzer: FC = () => {
const [data, setData] = useState<DataAnalyzerType[]>([]);
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<HTMLInputElement>) => {
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 (
<div className="vm-trace-page">
{hasData && (
<div className="vm-trace-page-header">
<div className="vm-trace-page-header-errors">
<QueryAnalyzerInfo
data={data}
period={period}
/>
</div>
<div>
<UploadJsonButtons
onOpenModal={handleOpenModal}
onChange={handleChange}
/>
</div>
</div>
)}
{error && (
<div className="vm-trace-page-header-errors-item vm-trace-page-header-errors-item_margin-bottom">
<Alert variant="error">{error}</Alert>
<Button
className="vm-trace-page-header-errors-item__close"
startIcon={<CloseIcon/>}
variant="text"
color="error"
onClick={handleCloseError}
/>
</div>
)}
{hasData && (
<QueryAnalyzerView
data={data}
period={period}
/>
)}
{!hasData && (
<div className="vm-trace-page-preview">
<p className="vm-trace-page-preview__text">
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.
</p>
<UploadJsonButtons
onOpenModal={handleOpenModal}
onChange={handleChange}
/>
</div>
)}
{openModal && (
<Modal
title="Paste JSON"
onClose={handleCloseModal}
>
<JsonForm
onClose={handleCloseModal}
onUpload={handleOnload}
/>
</Modal>
)}
{dragging && <div className="vm-trace-page__dropzone"/>}
</div>
);
};
export default QueryAnalyzer;

View file

@ -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<HTMLInputElement>) => void;
}
const TraceUploadButtons: FC<TraceUploadButtonsProps> = ({ onOpenModal, onChange }) => (
<div className="vm-trace-page-controls">
<Button
variant="outlined"
onClick={onOpenModal}
>
Paste JSON
</Button>
<Tooltip title="The file must contain tracing information in JSON format">
<Button>
Upload Files
<input
id="json"
type="file"
accept="application/json"
multiple
title=" "
onChange={onChange}
/>
</Button>
</Tooltip>
</div>
);
export default TraceUploadButtons;

View file

@ -10,7 +10,7 @@ import Modal from "../../components/Main/Modal/Modal";
import JsonForm from "./JsonForm/JsonForm"; import JsonForm from "./JsonForm/JsonForm";
import { ErrorTypes } from "../../types"; import { ErrorTypes } from "../../types";
import useDropzone from "../../hooks/useDropzone"; import useDropzone from "../../hooks/useDropzone";
import TraceUploadButtons from "./TraceUploadButtons/TraceUploadButtons"; import UploadJsonButtons from "../../components/UploadJsonButtons/UploadJsonButtons";
import useBoolean from "../../hooks/useBoolean"; import useBoolean from "../../hooks/useBoolean";
const TracePage: FC = () => { const TracePage: FC = () => {
@ -106,7 +106,7 @@ const TracePage: FC = () => {
</div> </div>
<div> <div>
{hasTraces && ( {hasTraces && (
<TraceUploadButtons <UploadJsonButtons
onOpenModal={handleOpenModal} onOpenModal={handleOpenModal}
onChange={handleChange} onChange={handleChange}
/> />
@ -145,7 +145,7 @@ const TracePage: FC = () => {
{"\n"} {"\n"}
Attach files by dragging & dropping, selecting or pasting them. Attach files by dragging & dropping, selecting or pasting them.
</p> </p>
<TraceUploadButtons <UploadJsonButtons
onOpenModal={handleOpenModal} onOpenModal={handleOpenModal}
onChange={handleChange} onChange={handleChange}
/> />

View file

@ -9,14 +9,6 @@
padding: $padding-medium 0; padding: $padding-medium 0;
} }
&-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $padding-global;
align-items: center;
justify-content: center;
}
&-header { &-header {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
@ -46,6 +38,10 @@
align-items: center; align-items: center;
justify-content: stretch; justify-content: stretch;
&_margin-bottom {
margin-bottom: $padding-global;
}
&__filename { &__filename {
min-height: 20px; min-height: 20px;
} }

View file

@ -11,6 +11,7 @@ const router = {
relabel: "/relabeling", relabel: "/relabeling",
logs: "/logs", logs: "/logs",
activeQueries: "/active-queries", activeQueries: "/active-queries",
queryAnalyzer: "/query-analyzer",
icons: "/icons", icons: "/icons",
anomaly: "/anomaly", anomaly: "/anomaly",
query: "/query", query: "/query",
@ -72,6 +73,10 @@ export const routerOptions: {[key: string]: RouterOptions} = {
title: "Trace analyzer", title: "Trace analyzer",
header: {} header: {}
}, },
[router.queryAnalyzer]: {
title: "Query analyzer",
header: {}
},
[router.dashboards]: { [router.dashboards]: {
title: "Dashboards", title: "Dashboards",
...routerOptionsDefault, ...routerOptionsDefault,

View file

@ -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: [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: [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: `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): 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: [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). * 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).