Compare commits

...

3 Commits

Author SHA1 Message Date
Yury Molodov
c29d79e0cc vmui: update text on the Raw Query page 2024-12-20 15:59:25 +01:00
Yury Molodov
f8339001a3 vmui: update CHANGELOG.md with for #7828 2024-12-14 04:40:31 +01:00
Yury Molodov
be0a0eadb7 vmui: add export button for raw query data #7628 2024-12-14 03:48:28 +01:00
23 changed files with 773 additions and 204 deletions

View File

@@ -48,3 +48,11 @@ export interface LogHits {
[key: string]: string;
};
}
export interface ReportMetaData {
id: number;
title: string;
endpoint: string;
comment: string;
params: Record<string, string>;
}

View File

@@ -38,6 +38,10 @@
align-items: flex-start;
gap: $padding-small;
ul {
list-style-position: inside;
}
button {
color: inherit;
min-height: 29px;

View File

@@ -570,3 +570,14 @@ export const SpinnerIcon = () => (
</path>
</svg>
);
export const CommentIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4zM18 14H6v-2h12zm0-3H6V9h12zm0-3H6V6h12z"
></path>
</svg>
);

View File

@@ -0,0 +1,62 @@
import React, { FC } from "preact/compat";
import useBoolean from "../../../hooks/useBoolean";
import classNames from "classnames";
import TextField from "../TextField/TextField";
import "./style.scss";
import { marked } from "marked";
interface Props {
value: string;
onChange: (value: string) => void;
}
const tabs = [
{ title: "Write", value: false },
{ title: "Preview", value: true },
];
const MarkdownEditor: FC<Props> = ({ value, onChange }) => {
const {
value: markdownPreview,
setTrue: setMarkdownPreviewTrue,
setFalse: setMarkdownPreviewFalse,
} = useBoolean(false);
return (
<div className="vm-markdown-editor">
<div className="vm-markdown-editor-header">
<div className="vm-markdown-editor-header-tabs">
{tabs.map(({ title, value }) => (
<div
key={title}
className={classNames({
"vm-markdown-editor-header-tabs__tab": true,
"vm-markdown-editor-header-tabs__tab_active": markdownPreview === value,
})}
onClick={value ? setMarkdownPreviewTrue : setMarkdownPreviewFalse}
>
{title}
</div>
))}
</div>
<span className="vm-markdown-editor-header__info">
Markdown is supported
</span>
</div>
{markdownPreview ? (
<div
className="vm-markdown-editor-preview vm-markdown"
dangerouslySetInnerHTML={{ __html: marked(value) as string }}
/>
) : (
<TextField
type="textarea"
value={value}
onChange={onChange}
/>
)}
</div>
);
};
export default MarkdownEditor;

View File

@@ -0,0 +1,75 @@
@use "src/styles/variables" as *;
.vm-markdown-editor {
margin-top: 6px;
padding: 0 6px;
border-radius: $border-radius-small;
border: $border-divider;
overflow: hidden;
&-header {
display: flex;
align-items: center;
background-color: $color-hover-black;
padding-right: $padding-global;
border-bottom: $border-divider;
margin: -1px -7px 6px;
&-tabs {
display: flex;
&__tab {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: -1px;
padding: $padding-small $padding-large;
min-height: 40px;
color: $color-text-secondary;
transition: color 0.3s;
cursor: pointer;
&:hover {
color: $color-text;
}
&_active {
position: relative;
color: $color-text;
background-color: $color-background-body;
border-top-right-radius: $border-radius-small;
border-top-left-radius: $border-radius-small;
z-index: 1;
&:first-child {
border-right: $border-divider;
}
&:last-child {
border-right: $border-divider;
border-left: $border-divider;
}
}
}
}
&__info {
margin-left: auto;
margin-right: 0;
color: $color-text-secondary;
font-size: $font-size-small;
font-weight: 500;
}
}
&-preview {
padding: $padding-small;
margin-bottom: 6px;
}
&-preview,
textarea {
min-height: 200px;
resize: vertical;
}
}

View File

@@ -16,17 +16,20 @@ const UploadJsonButtons: FC<Props> = ({ onOpenModal, onChange }) => (
>
Paste JSON
</Button>
<Button>
Upload Files
<div className="vm-upload-json-buttons__upload">
<Button>
Upload Files
</Button>
<input
id="json"
name="json"
type="file"
accept="application/json"
multiple
title=" "
onChange={onChange}
/>
</Button>
</div>
</div>
);

View File

@@ -6,4 +6,8 @@
gap: $padding-global;
align-items: center;
justify-content: center;
&__upload {
position: relative;
}
}

View File

@@ -1,4 +1,4 @@
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "preact/compat";
import React, { FC, useCallback, useEffect, 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";
@@ -12,32 +12,65 @@ 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";
import { Link } from "react-router-dom";
import router from "../../../router";
import { parseLineToJSON } from "../../../utils/json";
import { ExportMetricResult, ReportMetaData } from "../../../api/types";
import { getApiEndpoint } from "../../../utils/url";
import MarkdownEditor from "../../../components/Main/MarkdownEditor/MarkdownEditor";
export enum ReportType {
QUERY_DATA,
RAW_DATA,
}
type Props = {
fetchUrl?: string[];
reportType?: ReportType
}
const getDefaultReportName = () => `vmui_report_${dayjs().utc().format(DATE_FILENAME_FORMAT)}`;
type MetaData = {
id: number;
url: URL;
title: string;
comment: string;
}
const DownloadReport: FC<Props> = ({ fetchUrl }) => {
const getDefaultTitle = (type: ReportType) => {
switch (type) {
case ReportType.RAW_DATA:
return "Raw report";
default:
return "Report";
}
};
const getDefaultFilename = (title: string) => {
const timestamp = dayjs().utc().format(DATE_FILENAME_FORMAT);
return `vmui_${title.toLowerCase().replace(/ /g, "_")}_${timestamp}`;
};
const DownloadReport: FC<Props> = ({ fetchUrl, reportType = ReportType.QUERY_DATA }) => {
const { query } = useQueryState();
const [filename, setFilename] = useState(getDefaultReportName());
const defaultTitle = getDefaultTitle(reportType);
const defaultFilename = getDefaultFilename(defaultTitle);
const [title, setTitle] = useState(defaultTitle);
const [filename, setFilename] = useState(defaultFilename);
const [comment, setComment] = useState("");
const [trace, setTrace] = useState(true);
const [trace, setTrace] = useState(reportType === ReportType.QUERY_DATA);
const [error, setError] = useState<ErrorTypes | string>();
const [isLoading, setIsLoading] = useState(false);
const titleRef = useRef<HTMLDivElement>(null);
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 helperRefs = [filenameRef, titleRef, commentRef, traceRef, generateRef];
const [stepHelper, setStepHelper] = useState(0);
const {
@@ -52,13 +85,17 @@ const DownloadReport: FC<Props> = ({ fetchUrl }) => {
setFalse: handleCloseHelper,
} = useBoolean(false);
const fetchUrlReport = useMemo(() => {
const getFetchUrlReport = useCallback(() => {
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 };
});
try {
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 };
});
} catch (e) {
setError(String(e));
}
}, [fetchUrl, trace]);
const generateFile = useCallback((data: unknown) => {
@@ -68,7 +105,7 @@ const DownloadReport: FC<Props> = ({ fetchUrl }) => {
const link = document.createElement("a");
link.href = href;
link.download = `${filename || getDefaultReportName()}.json`;
link.download = `${filename || defaultFilename}.json`;
document.body.appendChild(link);
link.click();
@@ -77,9 +114,63 @@ const DownloadReport: FC<Props> = ({ fetchUrl }) => {
handleClose();
}, [filename]);
const getMetaData = ({ id, url, comment, title }: MetaData): ReportMetaData => {
return {
id,
title: title || defaultTitle,
comment,
endpoint: getApiEndpoint(url.pathname) || "",
params: Object.fromEntries(url.searchParams)
};
};
const processJsonLineResponse = async (response: Response, metaData: MetaData) => {
const result: { metric: { [p: string]: string }, values: number[][] }[] = [];
const text = await response.text();
if (response.ok) {
const lines = text.split("\n").filter(line => line);
lines.forEach((line: string) => {
const jsonLine = parseLineToJSON(line) as (ExportMetricResult | null);
if (!jsonLine) return;
result.push({
metric: jsonLine.metric,
values: jsonLine.values.map((value, index) => [(jsonLine.timestamps[index] / 1000), value]),
});
});
} else {
setError(String(text));
}
return { data: { result, resultType: "matrix" }, vmui: getMetaData(metaData) };
};
const processJsonResponse = async (response: Response, metaData: MetaData) => {
const resp = await response.json();
if (response.ok) {
resp.vmui = getMetaData(metaData);
return resp;
} else {
const errorType = resp.errorType ? `${resp.errorType}\r\n` : "";
setError(`${errorType}${resp?.error || resp?.message || "unknown error"}`);
}
};
const processResponse = async (response: Response, metaData: MetaData) => {
switch (reportType) {
case ReportType.RAW_DATA:
return await processJsonLineResponse(response, metaData);
default:
return await processJsonResponse(response, metaData);
}
};
const handleGenerateReport = useCallback(async () => {
const fetchUrlReport = getFetchUrlReport();
if (!fetchUrlReport) {
setError(ErrorTypes.validQuery);
setError(prev => !prev ? ErrorTypes.validQuery : prev);
return;
}
@@ -88,20 +179,12 @@ const DownloadReport: FC<Props> = ({ fetchUrl }) => {
try {
const result = [];
for await (const { url, id } of fetchUrlReport) {
for await (const fetchOps of fetchUrlReport) {
if (!fetchOps) continue;
const { url, id } = fetchOps;
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"}`);
}
const data = await processResponse(response, { id, url, comment, title });
result.push(data);
}
result.length && generateFile(result);
} catch (e) {
@@ -111,15 +194,20 @@ const DownloadReport: FC<Props> = ({ fetchUrl }) => {
} finally {
setIsLoading(false);
}
}, [fetchUrlReport, comment, generateFile, query]);
}, [getFetchUrlReport, comment, generateFile, query, title]);
const handleChangeHelp = (step: number) => () => {
setStepHelper(prevStep => prevStep + step);
const findNextRef = (index: number): number => {
const nextIndex = index + step;
if (helperRefs[nextIndex]?.current) return nextIndex;
return findNextRef(nextIndex);
};
setStepHelper(findNextRef);
};
useEffect(() => {
setError("");
setFilename(getDefaultReportName());
setFilename(defaultFilename);
setComment("");
}, [openModal]);
@@ -155,31 +243,41 @@ const DownloadReport: FC<Props> = ({ fetchUrl }) => {
<div className="vm-download-report">
<div className="vm-download-report-settings">
<div ref={filenameRef}>
<div className="vm-download-report-settings__title">Filename</div>
<TextField
label="Filename"
value={filename}
onChange={setFilename}
/>
</div>
<div ref={commentRef}>
<div ref={titleRef}>
<div className="vm-download-report-settings__title">Report title</div>
<TextField
type="textarea"
label="Comment"
value={title}
onChange={setTitle}
/>
</div>
<div ref={commentRef}>
<div className="vm-download-report-settings__title">Comment</div>
<MarkdownEditor
value={comment}
onChange={setComment}
/>
</div>
<div ref={traceRef}>
<Checkbox
checked={trace}
onChange={setTrace}
label={"Include query trace"}
/>
</div>
<Alert variant="info">
If confused with the query results,
try viewing the raw samples for selected series in <RawQueryLink/> tab.
</Alert>
{reportType === ReportType.QUERY_DATA && (
<>
<div ref={traceRef}>
<Checkbox
checked={trace}
onChange={setTrace}
label={"Include query trace"}
/>
</div>
<Alert variant="info">
If confused with the query results,
try viewing the raw samples for selected series in <RawQueryLink/> tab.
</Alert>
</>
)}
</div>
{error && <Alert variant="error">{error}</Alert>}
<div className="vm-download-report__buttons">

View File

@@ -11,6 +11,18 @@ const filename = (
</>
);
const tittle = (
<>
<p>Title - specify the title that will be displayed on the <Link
to={router.queryAnalyzer}
target="_blank"
rel="noreferrer"
className="vm-link vm-link_underlined"
>{routerOptions[router.queryAnalyzer].title}</Link> page.</p>
<p>This helps identify your report in the interface.</p>
</>
);
const comment = (
<>
<p>Comment (optional) - add a comment to your report.</p>
@@ -39,6 +51,7 @@ const generate = (
export default [
filename,
tittle,
comment,
trace,
generate,

View File

@@ -9,10 +9,15 @@
&-settings {
display: grid;
gap: $padding-global;
gap: $padding-large;
textarea {
min-height: 200px;
&__title {
display: flex;
align-items: center;
margin-right: $padding-global;
font-size: $font-size;
font-weight: 600;
white-space: nowrap;
}
}
@@ -34,7 +39,7 @@
line-height: 1.3;
p {
margin-bottom: calc($padding-small/2);
margin-bottom: calc($padding-small / 2);
}
}

View File

@@ -1,13 +1,20 @@
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 {
ClockIcon,
CommentIcon,
InfoIcon,
TimelineIcon
} from "../../../components/Main/Icons";
import { TimeParams } from "../../../types";
import "./style.scss";
import dayjs from "dayjs";
import { DATE_TIME_FORMAT } from "../../../constants/date";
import useBoolean from "../../../hooks/useBoolean";
import Modal from "../../../components/Main/Modal/Modal";
import { marked } from "marked";
import Button from "../../../components/Main/Button/Button";
import get from "lodash.get";
type Props = {
data: DataAnalyzerType[];
@@ -15,8 +22,23 @@ type Props = {
}
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 dataWithStats = useMemo(() => data.filter(d => d.vmui || d.stats), [data]);
const title = dataWithStats.find(d => d?.vmui?.title)?.vmui?.title || "Report";
const comment = dataWithStats.find(d => d?.vmui?.comment)?.vmui?.comment;
const table = useMemo(() => {
return [
"vmui.endpoint",
...new Set(dataWithStats.flatMap(d => [
...Object.keys(d.vmui?.params || []).map(key => `vmui.params.${key}`),
...Object.keys(d.stats || []).map(key => `stats.${key}`),
"isPartial"
]))
].map(key => ({
column: key.split(".").pop(),
values: dataWithStats.map(data => get(data, key, "-"))
})).filter(({ values }) => values.length && values.every(v => v !== "-"));
}, [dataWithStats]);
const timeRange = useMemo(() => {
if (!period) return "";
@@ -34,59 +56,80 @@ const QueryAnalyzerInfo: FC<Props> = ({ data, period }) => {
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>
</>
<h1 className="vm-query-analyzer-info-header__title">{title}</h1>
{timeRange && (
<div className="vm-query-analyzer-info-header__timerange">
<ClockIcon/> {timeRange}
</div>
)}
{period?.step && (
<div className="vm-query-analyzer-info-header__timerange">
<TimelineIcon/> step {period.step}
</div>
)}
{(comment || !!table.length) && (
<div className="vm-query-analyzer-info-header__info">
<Button
startIcon={<InfoIcon/>}
variant="outlined"
color="warning"
onClick={handleOpenModal}
>
Show stats{comment && " & comments"}
</Button>
</div>
)}
</div>
{openModal && (
<Modal
title="Report info"
title={title}
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 className="vm-query-analyzer-info__modal">
{!!table.length && (
<div className="vm-query-analyzer-info-stats">
<div className="vm-query-analyzer-info-comment-header">
<InfoIcon/>
Stats
</div>
<table>
<thead>
<tr>
{table.map(({ column }) => (
<th key={column}>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{table[0]?.values.map((_, rowIndex) => (
<tr key={rowIndex}>
{table.map(({ values }, j) => (
<td key={j}>
{values[rowIndex]}
</td>
))}
</tr>
))}
</tbody>
</table>
</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 ?? "-")}
{comment && (
<div className="vm-query-analyzer-info-comment">
<div className="vm-query-analyzer-info-comment-header">
<CommentIcon/>
Comments
</div>
<div
className="vm-query-analyzer-info-comment-body vm-markdown"
dangerouslySetInnerHTML={{ __html: (marked(comment) as string) || comment }}
/>
</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>
)}

View File

@@ -1,47 +1,115 @@
@use "src/styles/variables" as *;
.vm-query-analyzer-info-header {
display: flex;
gap: $padding-global;
.vm-query-analyzer-info {
&__period {
&-header {
display: flex;
align-items: center;
width: 100%;
height: 100%;
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%;
font-size: $font-size-small;
background-color: $color-background-body;
z-index: 1;
&__title {
font-weight: bold;
font-size: $font-size-large;
font-weight: 500;
}
&__text {
white-space: pre-wrap;
&__timerange {
display: flex;
align-items: center;
gap: calc($padding-small / 2);
border: $border-divider;
border-radius: $border-radius-small;
padding: calc($padding-small / 2) $padding-small;
font-size: $font-size-small;
svg {
width: calc($font-size-small + 1px);
color: $color-primary;
}
}
&__info {
margin-left: auto;
margin-right: 0;
}
}
&__modal {
width: min(800px, 90vw);
}
&-comment {
position: relative;
max-width: 800px;
border-radius: $border-radius-medium;
border: $border-divider;
font-size: $font-size-small;
&-header {
display: grid;
grid-template-columns: 16px 1fr;
align-items: center;
gap: $padding-small;
padding: $padding-small;
border-bottom: $border-divider;
background-color: $color-hover-black;
font-weight: 500;
z-index: 1;
svg {
color: $color-primary;
}
}
&-body {
padding: $padding-small;
max-height: 60vh;
overflow: auto;
}
}
&-stats {
border-radius: $border-radius-medium;
border: $border-divider;
font-size: $font-size-small;
margin-bottom: $padding-global;
overflow: hidden;
table {
width: 100%;
}
td, th {
padding: $padding-small;
text-align: left;
}
tr {
border-bottom: $border-divider;
}
thead {
th {
font-weight: 500;
}
}
tbody {
tr {
transition: background-color 0.3s;
&:hover {
background-color: $color-hover-black;
}
&:last-child {
border-bottom: none;
}
}
}
}
}

View File

@@ -9,8 +9,9 @@ import useBoolean from "../../hooks/useBoolean";
import UploadJsonButtons from "../../components/UploadJsonButtons/UploadJsonButtons";
import JsonForm from "./JsonForm/JsonForm";
import "../TracePage/style.scss";
import "./style.scss";
import QueryAnalyzerView from "./QueryAnalyzerView/QueryAnalyzerView";
import { InstantMetricResult, MetricResult, TracingData } from "../../api/types";
import { InstantMetricResult, MetricResult, ReportMetaData, TracingData } from "../../api/types";
import QueryAnalyzerInfo from "./QueryAnalyzerInfo/QueryAnalyzerInfo";
import { TimeParams } from "../../types";
import { dateFromSeconds, formatDateToUTC, humanizeSeconds } from "../../utils/time";
@@ -21,15 +22,8 @@ export type DataAnalyzerType = {
resultType: "vector" | "matrix";
result: MetricResult[] | InstantMetricResult[]
};
stats?: {
seriesFetched?: string;
executionTimeMsec?: number
};
vmui?: {
id: number;
comment: string;
params: Record<string, string>;
};
stats?: Record<string, string>;
vmui?: ReportMetaData;
status: string;
trace?: TracingData;
isPartial?: boolean;
@@ -92,10 +86,12 @@ const QueryAnalyzer: FC = () => {
setData(response);
} else {
setError("Invalid structure - JSON does not match the expected format");
setData([]);
}
} catch (e) {
if (e instanceof Error) {
setError(`${e.name}: ${e.message}`);
setData([]);
}
}
};
@@ -129,33 +125,16 @@ const QueryAnalyzer: FC = () => {
}, [files]);
return (
<div className="vm-trace-page">
<div className="vm-query-analyzer">
{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 className="vm-query-analyzer-header">
<QueryAnalyzerInfo
data={data}
period={period}
/>
<UploadJsonButtons
onOpenModal={handleOpenModal}
onChange={handleChange}
/>
</div>
)}
@@ -185,6 +164,19 @@ const QueryAnalyzer: FC = () => {
</div>
)}
{error && (
<div className="vm-query-analyzer-error">
<Alert variant="error">{error}</Alert>
<Button
className="vm-query-analyzer-error__close"
startIcon={<CloseIcon/>}
variant="text"
color="error"
onClick={handleCloseError}
/>
</div>
)}
{openModal && (
<Modal
title="Paste JSON"

View File

@@ -0,0 +1,29 @@
@use "src/styles/variables" as *;
.vm-query-analyzer {
display: flex;
flex-direction: column;
@media (max-width: 768px) {
padding: $padding-medium 0;
}
&-header {
display: grid;
grid-template-columns: 1fr auto;
gap: $padding-global;
margin-bottom: $padding-global;
}
&-error {
position: relative;
margin: $padding-global 0;
&__close {
position: absolute;
top: $padding-small;
right: $padding-small;
z-index: 2;
}
}
}

View File

@@ -7,6 +7,7 @@ import { useAppState } from "../../../state/common/StateContext";
import { useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext";
import { isValidHttpUrl } from "../../../utils/url";
import { getExportDataUrl } from "../../../api/query-range";
import { parseLineToJSON } from "../../../utils/json";
interface FetchQueryParams {
hideQuery?: number[];
@@ -24,14 +25,6 @@ interface FetchQueryReturn {
abortFetch: () => void
}
const parseLineToJSON = (line: string): ExportMetricResult | null => {
try {
return JSON.parse(line);
} catch (e) {
return null;
}
};
export const useFetchExport = ({ hideQuery, showAllSeries }: FetchQueryParams): FetchQueryReturn => {
const { query } = useQueryState();
const { period } = useTimeState();
@@ -62,7 +55,7 @@ export const useFetchExport = ({ hideQuery, showAllSeries }: FetchQueryParams):
}
}, [serverUrl, period, hideQuery, reduceMemUsage]);
const fetchData = useCallback(async ( { fetchUrl, stateSeriesLimits, showAllSeries }: {
const fetchData = useCallback(async ({ fetchUrl, stateSeriesLimits, showAllSeries }: {
fetchUrl: string[];
stateSeriesLimits: SeriesLimits;
showAllSeries?: boolean;
@@ -99,12 +92,12 @@ export const useFetchExport = ({ hideQuery, showAllSeries }: FetchQueryParams):
const lines = text.split("\n").filter(line => line);
const lineLimited = lines.slice(0, freeTempSize).sort();
lineLimited.forEach((line: string) => {
const jsonLine = parseLineToJSON(line);
const jsonLine = parseLineToJSON(line) as (ExportMetricResult | null);
if (!jsonLine) return;
tempData.push({
group: counter,
metric: jsonLine.metric,
values: jsonLine.values.map((value, index) => [(jsonLine.timestamps[index]/1000), value]),
values: jsonLine.values.map((value, index) => [(jsonLine.timestamps[index] / 1000), value]),
} as MetricBase);
});
totalLength += lines.length;
@@ -119,7 +112,7 @@ export const useFetchExport = ({ hideQuery, showAllSeries }: FetchQueryParams):
} catch (e) {
setIsLoading(false);
if (e instanceof Error && e.name !== "AbortError") {
setError(error);
setError(String(e));
console.error(e);
}
}

View File

@@ -17,6 +17,7 @@ import { DisplayType } from "../../types";
import Hyperlink from "../../components/Main/Hyperlink/Hyperlink";
import { CloseIcon } from "../../components/Main/Icons";
import Button from "../../components/Main/Button/Button";
import DownloadReport, { ReportType } from "../CustomPanel/DownloadReport/DownloadReport";
const RawSamplesLink = () => (
<Hyperlink
@@ -27,15 +28,6 @@ const RawSamplesLink = () => (
</Hyperlink>
);
const QueryDataLink = () => (
<Hyperlink
underlined
href="https://docs.victoriametrics.com/keyconcepts/#query-data"
>
Query API
</Hyperlink>
);
const TimeSeriesSelectorLink = () => (
<Hyperlink
underlined
@@ -65,6 +57,7 @@ const RawQueryPage: FC = () => {
queryErrors,
setQueryErrors,
abortFetch,
fetchUrl,
} = useFetchExport({ hideQuery, showAllSeries });
const controlsRef = useRef<HTMLDivElement>(null);
@@ -106,12 +99,18 @@ const RawQueryPage: FC = () => {
{showPageDescription && (
<Alert variant="info">
<div className="vm-explore-metrics-header-description">
<p>
This page provides a dedicated view for querying and displaying <RawSamplesLink/> from VictoriaMetrics.
It expects only <TimeSeriesSelectorLink/> as a query argument.
Users often assume that the <QueryDataLink/> returns data exactly as stored,
but data samples and timestamps may be modified by the API.
</p>
<ul>
<li>
This page provides a dedicated view for querying and displaying <RawSamplesLink/> from VictoriaMetrics.
</li>
<li>
It expects only <TimeSeriesSelectorLink/> as a query argument.
</li>
<li>
Deduplication can only be disabled if it was previously enabled on the server
(<code>-dedup.minScrapeInterval</code>).
</li>
</ul>
<Button
variant="text"
size="small"
@@ -146,6 +145,12 @@ const RawQueryPage: FC = () => {
<div className="vm-custom-panel-body-header__tabs">
<DisplayTypeSwitch tabFilter={(tab) => (tab.value !== DisplayType.table)}/>
</div>
{data && (
<DownloadReport
fetchUrl={fetchUrl}
reportType={ReportType.RAW_DATA}
/>
)}
</div>
<CustomPanelTabs
graphData={data}

View File

@@ -61,7 +61,8 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: flex-end;
min-height: calc(($vh * 50) - var(--scrollbar-height));
&__text {
margin-bottom: $padding-global;

View File

@@ -0,0 +1,139 @@
@use "src/styles/variables" as *;
.vm-markdown {
display: block;
line-height: 1.5;
pre,
code {
font-size: 1em;
font-family: $font-family-monospace
}
pre {
padding: .5em;
line-height: 1.25;
overflow-x: scroll;
}
a,
a:visited {
color: $color-primary;
text-decoration: underline;
cursor: pointer;
}
body {
line-height: 1.85;
}
p {
font-size: 1em;
margin-bottom: 1.3em;
}
h1, h2, h3, h4, h5, h6 {
margin: .5em 0 0.25em;
font-weight: inherit;
line-height: 1.42;
}
h1 {
font-size: 1.8em;
}
h2 {
font-size: 1.6em;
}
h3 {
font-size: 1.4em;
}
h4 {
font-size: 1.2em;
}
h5 {
font-size: 1em;
}
h6 {
font-size: 0.8em;
}
small {
font-size: 0.8em;
}
img,
canvas,
iframe,
video,
svg,
select,
textarea {
max-width: 100%;
}
pre {
background-color: $color-hover-black;
}
blockquote {
border-left: 3px solid rgba($color-black, 0.2);
padding-left: 1em;
opacity: 0.7;
}
ul, ol {
margin: 0.3em 0;
list-style-position: inside;
li {
margin-bottom: 0.3em;
ul, ol {
margin-left: 1em;
}
}
}
input[type="checkbox"] {
display: none;
}
th,
td {
padding: 0.2em 0.4em;
}
hr {
border-top: $border-divider;
}
strong {
font-weight: bold;
}
em {
font-style: italic;
}
del {
text-decoration: line-through;
}
code:not(pre code) {
display: inline;
vertical-align: middle;
background: $color-hover-black;
border-radius: $border-radius-small;
border: $border-divider;
tab-size: 4;
font-variant-ligatures: none;
padding: 0.12em 0.4em;
font-size: 0.9em;
white-space: nowrap;
}
}

View File

@@ -50,10 +50,6 @@ button {
background: none;
}
strong {
letter-spacing: 1px;
}
input[type='file'] {
opacity: 0;
cursor: pointer;

View File

@@ -10,6 +10,7 @@
@forward "./components/table";
@forward "./components/link";
@forward "./components/dynamic-number";
@forward "./components/markdown";
:root {
/* base palette */

View File

@@ -0,0 +1,7 @@
export const parseLineToJSON = (line: string) => {
try {
return JSON.parse(line);
} catch (e) {
return null;
}
};

View File

@@ -25,3 +25,13 @@ export const isEqualURLSearchParams = (params1: URLSearchParams, params2: URLSea
return true;
};
export const getApiEndpoint = (url: string): string | null => {
try {
const match = url.match(/\/api\/v1\/[^?]+/);
return match ? match[0] : null;
} catch (error) {
console.error("Invalid URL:", error);
return null;
}
};

View File

@@ -34,6 +34,8 @@ See also [LTS releases](https://docs.victoriametrics.com/lts-releases/).
* FEATURE: [vmauth](https://docs.victoriametrics.com/vmauth/): allow to start `vmauth` with empty configuration file. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6467) for details.
* FEATURE: [vmalert-tool](https://docs.victoriametrics.com/vmalert-tool/): support debug mode for alerting rule. See [this doc](https://docs.victoriametrics.com/vmalert-tool/#debug-mode).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): update error messages for Clipboard API issues with docs links. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/7677).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add export data functionality for the `Raw Query` page and the ability to import exported data into the `Query Analyzer` page. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/7628).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add `markdown` support for comments during data export. [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/7828).
* BUGFIX: all VictoriaMetrics components: consistently deduplicate values with stale markers within deduplication interval. Previously, deduplication could randomly prefer stale marker or value on the deduplication interval. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/7674) for details. Thanks to @tIGO for the [pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/7675).
* BUGFIX: [vmagent](https://docs.victoriametrics.com/vmagent/) and [Single-node VictoriaMetrics](https://docs.victoriametrics.com/): add missing common service labels for docker swarm service discovery when `role` is set to `tasks`. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/7800).