mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2025-01-10 15:14:09 +00:00
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:
parent
3a26e4d6ec
commit
1db2b991b7
29 changed files with 1175 additions and 53 deletions
|
@ -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/>}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
||||||
|
}
|
|
@ -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";
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
|
@ -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,
|
||||||
|
];
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
205
app/vmui/packages/vmui/src/pages/QueryAnalyzer/index.tsx
Normal file
205
app/vmui/packages/vmui/src/pages/QueryAnalyzer/index.tsx
Normal 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;
|
|
@ -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;
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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).
|
||||||
|
|
Loading…
Reference in a new issue