vmui: add explore tab for exploration of metrics, which belong to a particular job/instance (#3470)

* feat: add "Explore" page

* feat: add graphs for explore page

* vmui: add explore tab for exploration of metrics, which belong to a particular job/instance

* refactor: rename variables

* refactor: extract graph to ExploreMetricItemGraph.tsx

* feat: add searchable for Select.tsx

* feat: improve metrics explorer

* feat: set document title by page

* feat: add page to view icons

* fix: improve styles

* fix: add encodeURIComponent to query
This commit is contained in:
Yury Molodov 2022-12-23 00:24:40 +01:00 committed by Aliaksandr Valialkin
parent 3834602c9d
commit ec2b24f3f3
No known key found for this signature in database
GPG key ID: A72BEC6CD3D0DED1
37 changed files with 1020 additions and 50 deletions

View file

@ -0,0 +1 @@
FAST_REFRESH=false

View file

@ -9,7 +9,8 @@ In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.\
Open [http://localhost:3000/#/icons](http://localhost:3000/#/icons) to view the icons used in the project.
The page will reload if you make edits.\
You will also see any lint errors in the console.

View file

@ -10,6 +10,8 @@ import TopQueries from "./pages/TopQueries";
import ThemeProvider from "./components/Main/ThemeProvider/ThemeProvider";
import Spinner from "./components/Main/Spinner/Spinner";
import TracePage from "./pages/TracePage";
import ExploreMetrics from "./pages/ExploreMetrics";
import PreviewIcons from "./components/Main/Icons/PreviewIcons";
const App: FC = () => {
@ -50,6 +52,14 @@ const App: FC = () => {
path={router.trace}
element={<TracePage/>}
/>
<Route
path={router.metrics}
element={<ExploreMetrics/>}
/>
<Route
path={router.icons}
element={<PreviewIcons/>}
/>
</Route>
</Routes>
</AppContextProvider>

View file

@ -0,0 +1,16 @@
import { TimeParams } from "../types";
export const getJobsUrl = (server: string, period: TimeParams): string =>
`${server}/api/v1/label/job/values?start=${period.start}&end=${period.end}`;
export const getInstancesUrl = (server: string, period: TimeParams, job: string): string =>
`${server}/api/v1/label/instance/values?match[]={job="${encodeURIComponent(job)}"}&start=${period.start}&end=${period.end}`;
export const getNamesUrl = (server: string, job: string, instance: string): string => {
const match = Object.entries({ job, instance })
.filter(val => val[1])
.map(([key, val]) => `${key}="${val}"`)
.join(",");
return `${server}/api/v1/label/__name__/values?match[]={${encodeURIComponent(match)}}`;
};

View file

@ -8,7 +8,7 @@ const Footer: FC = () => {
return <footer className="vm-footer">
<a
className="vm-footer__link vm-footer__website"
className="vm__link vm-footer__website"
target="_blank"
href="https://victoriametrics.com/"
rel="noreferrer"
@ -17,7 +17,7 @@ const Footer: FC = () => {
victoriametrics.com
</a>
<a
className="vm-footer__link"
className="vm__link"
target="_blank"
href="https://github.com/VictoriaMetrics/VictoriaMetrics/issues/new"
rel="noreferrer"

View file

@ -17,16 +17,6 @@
gap: 6px;
}
&__link {
transition: color 200ms ease;
cursor: pointer;
&:hover {
color: $color-primary;
text-decoration: underline;
}
}
&__copyright {
text-align: right;
flex-grow: 1;

View file

@ -28,25 +28,29 @@ const Header: FC = () => {
const { search, pathname } = useLocation();
const routes = useMemo(() => ([
{
label: "Custom panel",
label: routerOptions[router.home].title,
value: router.home,
},
{
label: "Dashboards",
label: routerOptions[router.dashboards].title,
value: router.dashboards,
hide: appModeEnable
},
{
label: "Cardinality",
label: routerOptions[router.cardinality].title,
value: router.cardinality,
},
{
label: "Top queries",
label: routerOptions[router.topQueries].title,
value: router.topQueries,
},
{
label: "Trace analyzer",
label: routerOptions[router.trace].title,
value: router.trace,
},
{
label: routerOptions[router.metrics].title,
value: router.metrics,
}
]), [appModeEnable]);

View file

@ -1,14 +1,22 @@
import Header from "./Header/Header";
import React, { FC } from "preact/compat";
import { Outlet } from "react-router-dom";
import React, { FC, useEffect } from "preact/compat";
import { Outlet, useLocation } from "react-router-dom";
import "./style.scss";
import { getAppModeEnable } from "../../utils/app-mode";
import classNames from "classnames";
import Footer from "./Footer/Footer";
import { routerOptions } from "../../router";
const Layout: FC = () => {
const appModeEnable = getAppModeEnable();
const { pathname } = useLocation();
useEffect(() => {
const defaultTitle = "VM UI";
const routeTitle = routerOptions[pathname]?.title;
document.title = routeTitle ? `${routeTitle} - ${defaultTitle}` : defaultTitle;
}, [pathname]);
return <section className="vm-container">
<Header/>
<div

View file

@ -10,6 +10,9 @@ interface AutocompleteProps {
anchor: Ref<HTMLElement>
disabled?: boolean
maxWords?: number
minLength?: number
fullWidth?: boolean
noOptionsText?: string
onSelect: (val: string) => void,
onOpenAutocomplete?: (val: boolean) => void
}
@ -20,6 +23,9 @@ const Autocomplete: FC<AutocompleteProps> = ({
anchor,
disabled,
maxWords = 1,
minLength = 2,
fullWidth,
noOptionsText,
onSelect,
onOpenAutocomplete
}) => {
@ -39,6 +45,10 @@ const Autocomplete: FC<AutocompleteProps> = ({
}
}, [openAutocomplete, options, value]);
const displayNoOptionsText = useMemo(() => {
return noOptionsText && !foundOptions.length;
}, [noOptionsText,foundOptions]);
const handleCloseAutocomplete = () => {
setOpenAutocomplete(false);
};
@ -84,7 +94,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
useEffect(() => {
const words = (value.match(/[a-zA-Z_:.][a-zA-Z0-9_:.]*/gm) || []).length;
setOpenAutocomplete(value.length > 2 && words <= maxWords);
setOpenAutocomplete(value.length > minLength && words <= maxWords);
}, [value]);
useEffect(() => {
@ -113,11 +123,13 @@ const Autocomplete: FC<AutocompleteProps> = ({
buttonRef={anchor}
placement="bottom-left"
onClose={handleCloseAutocomplete}
fullWidth={fullWidth}
>
<div
className="vm-autocomplete"
ref={wrapperEl}
>
{displayNoOptionsText && <div className="vm-autocomplete__no-options">{noOptionsText}</div>}
{foundOptions.map((option, i) =>
<div
className={classNames({

View file

@ -3,4 +3,10 @@
.vm-autocomplete {
max-height: 300px;
overflow: auto;
&__no-options {
padding: $padding-global;
text-align: center;
color: $color-text-disabled;
}
}

View file

@ -0,0 +1,38 @@
import React, { FC } from "preact/compat";
import * as icons from "./index";
import { useSnack } from "../../../contexts/Snackbar";
import "./style.scss";
const PreviewIcons: FC = () => {
const { showInfoMessage } = useSnack();
const handleClickIcon = (copyValue: string) => {
navigator.clipboard.writeText(`<${copyValue}/>`);
showInfoMessage({ text: `<${copyValue}/> has been copied`, type: "success" });
};
const createHandlerClickIcon = (key: string) => () => {
handleClickIcon(key);
};
return (
<div className="vm-preview-icons">
{Object.entries(icons).map(([iconKey, icon]) => (
<div
className="vm-preview-icons-item"
onClick={createHandlerClickIcon(iconKey)}
key={iconKey}
>
<div className="vm-preview-icons-item__svg">
{icon()}
</div>
<div className="vm-preview-icons-item__name">
{`<${iconKey}/>`}
</div>
</div>
))}
</div>
);
};
export default PreviewIcons;

View file

@ -116,15 +116,6 @@ export const ArrowDownIcon = () => (
</svg>
);
export const ArrowUpIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="m12 8-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path>
</svg>
);
export const ArrowDropDownIcon = () => (
<svg
viewBox="0 0 24 24"
@ -318,3 +309,14 @@ export const DragIcon = () => (
<path d="M20 9H4v2h16V9zM4 15h16v-2H4v2z"></path>
</svg>
);
export const SearchIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
></path>
</svg>
);

View file

@ -0,0 +1,53 @@
@use "src/styles/variables" as *;
.vm-preview-icons {
display: grid;
align-items: flex-start;
justify-content: center;
grid-template-columns: repeat(auto-fill, 100px);
gap: $padding-global;
&-item {
display: grid;
grid-template-rows: 1fr auto;
align-items: stretch;
justify-content: center;
gap: $padding-small;
height: 100px;
padding: $padding-global $padding-small;
border-radius: $border-radius-small;
border: 1px solid transparent;
cursor: pointer;
transition: box-shadow 200ms ease-in-out;
&:hover {
box-shadow: rgba(0, 0, 0, 0.16) 0 1px 4px;
}
&:active &__svg {
transform: scale(0.9);
}
&__name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
font-size: $font-size-small;
line-height: 2;
}
&__svg {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
transition: transform 100ms ease-out;
svg {
width: auto;
height: 24px
}
}
}
}

View file

@ -1,4 +1,4 @@
@import 'src/styles/variables';
@use "src/styles/variables" as *;
$padding-modal: 22px;

View file

@ -12,7 +12,8 @@ interface PopperProps {
placement?: "bottom-right" | "bottom-left" | "top-left" | "top-right"
animation?: string
offset?: {top: number, left: number}
clickOutside?: boolean
clickOutside?: boolean,
fullWidth?: boolean
}
const Popper: FC<PopperProps> = ({
@ -23,7 +24,8 @@ const Popper: FC<PopperProps> = ({
onClose,
animation,
offset = { top: 6, left: 0 },
clickOutside = true
clickOutside = true,
fullWidth
}) => {
const [isOpen, setIsOpen] = useState(true);
@ -68,7 +70,8 @@ const Popper: FC<PopperProps> = ({
const position = {
top: 0,
left: 0
left: 0,
width: "auto"
};
const needAlignRight = placement === "bottom-right" || placement === "top-right";
@ -96,8 +99,10 @@ const Popper: FC<PopperProps> = ({
if (isOverflowRight) position.left = buttonPos.right - popperSize.width - offsetLeft;
if (isOverflowLeft) position.left = buttonPos.left + offsetLeft;
if (fullWidth) position.width = `${buttonPos.width}px`;
return position;
},[buttonRef, placement, isOpen, children]);
},[buttonRef, placement, isOpen, children, fullWidth]);
if (clickOutside) useClickOutside(popperRef, () => setIsOpen(false), buttonRef);

View file

@ -0,0 +1,120 @@
import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
import classNames from "classnames";
import { ArrowDropDownIcon, CloseIcon } from "../Icons";
import TextField from "../../../components/Main/TextField/TextField";
import { MouseEvent } from "react";
import Autocomplete from "../Autocomplete/Autocomplete";
import "./style.scss";
interface JobSelectorProps {
value: string
list: string[]
label?: string
placeholder?: string
noOptionsText?: string
error?: string
clearable?: boolean
searchable?: boolean
onChange: (value: string) => void
}
const Select: FC<JobSelectorProps> = ({
value,
list,
label,
placeholder,
error,
noOptionsText,
clearable = false,
searchable,
onChange
}) => {
const [search, setSearch] = useState("");
const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
const [openList, setOpenList] = useState(false);
const textFieldValue = useMemo(() => openList ? search : value, [value, search, openList]);
const autocompleteValue = useMemo(() => !openList ? "" : search || "(.+)", [search, openList]);
const clearFocus = () => {
if (document.activeElement instanceof HTMLInputElement) {
document.activeElement.blur();
}
};
const handleCloseList = () => {
setOpenList(false);
clearFocus();
};
const handleFocus = () => {
setOpenList(true);
};
const handleClickJob = (job: string) => {
onChange(job);
handleCloseList();
};
const createHandleClick = (job: string) => (e: MouseEvent<HTMLDivElement>) => {
handleClickJob(job);
e.stopPropagation();
};
useEffect(() => {
setSearch("");
}, [openList]);
return (
<div className="vm-select">
<div
className="vm-select-input"
ref={autocompleteAnchorEl}
>
<TextField
label={label}
type="text"
value={textFieldValue}
placeholder={placeholder}
error={error}
disabled={!searchable}
onFocus={handleFocus}
onEnter={handleCloseList}
onChange={setSearch}
endIcon={(
<div
className={classNames({
"vm-select-input__icon": true,
"vm-select-input__icon_open": openList
})}
>
<ArrowDropDownIcon/>
</div>
)}
/>
{clearable && (
<div
className="vm-select-input__clear"
onClick={createHandleClick("")}
>
<CloseIcon/>
</div>
)}
</div>
<Autocomplete
value={autocompleteValue}
options={list}
anchor={autocompleteAnchorEl}
maxWords={10}
minLength={0}
fullWidth
noOptionsText={noOptionsText}
onSelect={handleClickJob}
onOpenAutocomplete={setOpenList}
/>
</div>
);
};
export default Select;

View file

@ -0,0 +1,50 @@
@use "src/styles/variables" as *;
.vm-select {
&-input {
position: relative;
cursor: pointer;
input {
pointer-events: none;
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: flex-end;
margin: 0 0 0 auto;
transition: transform 200ms ease-in;
svg {
width: 14px;
}
&_open {
transform: rotate(180deg);
}
}
&__clear {
position: absolute;
right: 30px;
top: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px $padding-small;
cursor: pointer;
transition: opacity 200ms ease-in;
color: $color-text-secondary;
border-right: $border-divider;
svg {
width: 12px;
}
&:hover {
opacity: 0.7;
}
}
}
}

View file

@ -1,4 +1,4 @@
import React from "react";
import React, { ReactNode } from "react";
import classNames from "classnames";
import "./style.scss";
import { FC } from "preact/compat";
@ -7,7 +7,7 @@ interface SwitchProps {
value: boolean
color?: "primary" | "secondary" | "error"
disabled?: boolean
label?: string
label?: string | ReactNode
onChange: (value: boolean) => void
}

View file

@ -8,6 +8,7 @@ interface TextFieldProps {
value?: string | number
type?: HTMLInputTypeAttribute | "textarea"
error?: string
placeholder?: string
endIcon?: ReactNode
startIcon?: ReactNode
disabled?: boolean
@ -16,6 +17,8 @@ interface TextFieldProps {
onChange?: (value: string) => void
onEnter?: () => void
onKeyDown?: (e: KeyboardEvent) => void
onFocus?: () => void
onBlur?: () => void
}
const TextField: FC<TextFieldProps> = ({
@ -23,6 +26,7 @@ const TextField: FC<TextFieldProps> = ({
value,
type = "text",
error = "",
placeholder,
endIcon,
startIcon,
disabled = false,
@ -30,7 +34,9 @@ const TextField: FC<TextFieldProps> = ({
helperText,
onChange,
onEnter,
onKeyDown
onKeyDown,
onFocus,
onBlur
}) => {
const inputRef = useRef<HTMLInputElement>(null);
@ -63,6 +69,14 @@ const TextField: FC<TextFieldProps> = ({
fieldRef?.current?.focus && fieldRef.current.focus();
}, [fieldRef, autofocus]);
const handleFocus = () => {
onFocus && onFocus();
};
const handleBlur = () => {
onBlur && onBlur();
};
return <label
className={classNames({
"vm-text-field": true,
@ -79,9 +93,12 @@ const TextField: FC<TextFieldProps> = ({
disabled={disabled}
ref={textareaRef}
value={value}
rows={1}
placeholder={placeholder}
onInput={handleChange}
onKeyDown={handleKeyDown}
rows={1}
onFocus={handleFocus}
onBlur={handleBlur}
/>
)
: (
@ -90,9 +107,13 @@ const TextField: FC<TextFieldProps> = ({
disabled={disabled}
ref={inputRef}
value={value}
type={type}
placeholder={placeholder}
onInput={handleChange}
onKeyDown={handleKeyDown}
type={type}
onFocus={handleFocus}
onBlur={handleBlur}
/>)
}
{label && <span className="vm-text-field__label">{label}</span>}

View file

@ -89,7 +89,7 @@
}
&_icon-start {
padding-left: 42px;
padding-left: 31px;
}
&:disabled {

View file

@ -1,7 +1,7 @@
import React, { FC, useState } from "preact/compat";
import LineProgress from "../../Main/LineProgress/LineProgress";
import Trace from "../Trace";
import { ArrowUpIcon } from "../../Main/Icons";
import { ArrowDownIcon } from "../../Main/Icons";
import "./style.scss";
import classNames from "classnames";
@ -38,7 +38,7 @@ const NestedNav: FC<RecursiveProps> = ({ trace, totalMsec }) => {
"vm-nested-nav-header__icon_open": openLevels[trace.idValue]
})}
>
<ArrowUpIcon />
<ArrowDownIcon />
</div>
)}
<div className="vm-nested-nav-header__progress">

View file

@ -0,0 +1,80 @@
import React, { FC, useEffect, useMemo, useState } from "preact/compat";
import Accordion from "../../../components/Main/Accordion/Accordion";
import ExploreMetricItemGraph from "./ExploreMetricItemGraph";
import "./style.scss";
import Switch from "../../../components/Main/Switch/Switch";
import { MouseEvent } from "react";
interface ExploreMetricItemProps {
name: string,
job: string,
instance: string
openMetrics: string[]
onOpen: (val: boolean, id: string) => void
}
const ExploreMetricItem: FC<ExploreMetricItemProps> = ({
name,
job,
instance,
openMetrics,
onOpen
}) => {
const expanded = useMemo(() => openMetrics.includes(name), [name, openMetrics]);
const isCounter = useMemo(() => /_sum?|_total?|_count?/.test(name), [name]);
const isBucket = useMemo(() => /_bucket?/.test(name), [name]);
const [rateEnabled, setRateEnabled] = useState(isCounter);
const handleOpenAccordion = (val: boolean) => {
onOpen(val, name);
};
const handleClickRate = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
};
useEffect(() => {
setRateEnabled(isCounter);
}, [job, expanded]);
const Title = () => (
<div className="vm-explore-metrics-item-header">
<div className="vm-explore-metrics-item-header__name">{name}</div>
{expanded && !isBucket && (
<div
className="vm-explore-metrics-item-header__rate"
onClick={handleClickRate}
>
<Switch
label={<span>wrapped into <code>rate()</code></span>}
value={rateEnabled}
onChange={setRateEnabled}
/>
</div>
)}
</div>
);
return (
<div className="vm-explore-metrics-item">
<Accordion
title={<Title/>}
defaultExpanded={expanded}
onChange={handleOpenAccordion}
>
<ExploreMetricItemGraph
key={`${name}_${job}_${instance}_${rateEnabled}`}
name={name}
job={job}
instance={instance}
rateEnabled={rateEnabled}
isCounter={isCounter}
isBucket={isBucket}
/>
</Accordion>
</div>
);
};
export default ExploreMetricItem;

View file

@ -0,0 +1,111 @@
import React, { FC, useMemo, useState } from "preact/compat";
import { useFetchQuery } from "../../../hooks/useFetchQuery";
import { useGraphDispatch, useGraphState } from "../../../state/graph/GraphStateContext";
import GraphView from "../../../components/Views/GraphView/GraphView";
import { useTimeDispatch, useTimeState } from "../../../state/time/TimeStateContext";
import { AxisRange } from "../../../state/graph/reducer";
import Spinner from "../../../components/Main/Spinner/Spinner";
import Alert from "../../../components/Main/Alert/Alert";
import Button from "../../../components/Main/Button/Button";
import "./style.scss";
interface ExploreMetricItemGraphProps {
name: string,
job: string,
instance: string,
rateEnabled: boolean,
isCounter: boolean,
isBucket: boolean,
}
const ExploreMetricItem: FC<ExploreMetricItemGraphProps> = ({
name,
job,
instance,
rateEnabled,
isCounter,
isBucket
}) => {
const { customStep, yaxis } = useGraphState();
const { period } = useTimeState();
const graphDispatch = useGraphDispatch();
const timeDispatch = useTimeDispatch();
const [showAllSeries, setShowAllSeries] = useState(false);
const query = useMemo(() => {
const params = Object.entries({ job, instance })
.filter(val => val[1])
.map(([key, val]) => `${key}="${val}"`);
const base = `${name}{${params.join(",")}}`;
const queryBase = rateEnabled ? `rate(${base})` : base;
const queryBucket = `histogram_quantiles("quantile", 0.5, 0.99, increase(${base}[5m]))`;
const queryBucketWithoutInstance = `histogram_quantiles("quantile", 0.5, 0.99, sum(increase(${base}[5m])) without (instance))`;
const queryCounterWithoutInstance = `sum(${queryBase}) without (job)`;
const queryWithoutInstance = `sum(${queryBase}) without (instance)`;
const isCounterWithoutInstance = isCounter && job && !instance;
const isBucketWithoutInstance = isBucket && job && !instance;
const isWithoutInstance = !isCounter && job && !instance;
if (isCounterWithoutInstance) return queryCounterWithoutInstance;
if (isBucketWithoutInstance) return queryBucketWithoutInstance;
if (isBucket) return queryBucket;
if (isWithoutInstance) return queryWithoutInstance;
return queryBase;
}, [name, job, instance, rateEnabled, isCounter, isBucket]);
const { isLoading, graphData, error, warning } = useFetchQuery({
predefinedQuery: [query],
visible: true,
customStep,
showAllSeries
});
const setYaxisLimits = (limits: AxisRange) => {
graphDispatch({ type: "SET_YAXIS_LIMITS", payload: limits });
};
const setPeriod = ({ from, to }: {from: Date, to: Date}) => {
timeDispatch({ type: "SET_PERIOD", payload: { from, to } });
};
const handleShowAll = () => {
setShowAllSeries(true);
};
return (
<div className="vm-explore-metrics-item-graph">
{isLoading && <Spinner />}
{error && <Alert variant="error">{error}</Alert>}
{warning && <Alert variant="warning">
<div className="vm-explore-metrics-item-graph__warning">
<p>{warning}</p>
<Button
color="warning"
variant="outlined"
onClick={handleShowAll}
>
Show all
</Button>
</div>
</Alert>}
{graphData && period && (
<GraphView
data={graphData}
period={period}
customStep={customStep}
query={[query]}
yaxis={yaxis}
setYaxisLimits={setYaxisLimits}
setPeriod={setPeriod}
/>
)}
</div>
);
};
export default ExploreMetricItem;

View file

@ -0,0 +1,36 @@
@use "src/styles/variables" as *;
.vm-explore-metrics-item {
border-bottom: $border-divider;
&-header {
display: grid;
grid-template-columns: 1fr auto;
padding: $padding-global calc(28px + $padding-global) $padding-global $padding-global;
&__rate {
display: flex;
align-items: center;
justify-content: center;
gap: $padding-small;
code {
padding: 0.2em 0.4em;
font-size: 85%;
background-color: rgba($color-black, 0.05);
border-radius: 6px;
}
}
}
&-graph {
padding: 0 $padding-global $padding-global;
&__warning {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
justify-content: space-between;
}
}
}

View file

@ -0,0 +1,50 @@
import { useTimeState } from "../../../state/time/TimeStateContext";
import { useEffect, useMemo, useState } from "preact/compat";
import { getInstancesUrl } from "../../../api/explore-metrics";
import { useAppState } from "../../../state/common/StateContext";
import { ErrorTypes } from "../../../types";
interface FetchInstanceReturn {
instances: string[],
isLoading: boolean,
error?: ErrorTypes | string,
}
export const useFetchInstances = (job: string): FetchInstanceReturn => {
const { serverUrl } = useAppState();
const { period } = useTimeState();
const [instances, setInstances] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>();
const fetchUrl = useMemo(() => getInstancesUrl(serverUrl, period, job), [serverUrl, period, job]);
useEffect(() => {
if (!job) return;
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(fetchUrl);
const resp = await response.json();
const data = (resp.data || []) as string[];
setInstances(data.sort((a, b) => a.localeCompare(b)));
if (response.ok) {
setError(undefined);
} else {
setError(`${resp.errorType}\r\n${resp?.error}`);
}
} catch (e) {
if (e instanceof Error) {
setError(`${e.name}: ${e.message}`);
}
}
setIsLoading(false);
};
fetchData().catch(console.error);
}, [fetchUrl]);
return { instances, isLoading, error };
};

View file

@ -0,0 +1,49 @@
import { useTimeState } from "../../../state/time/TimeStateContext";
import { useEffect, useMemo, useState } from "preact/compat";
import { getJobsUrl } from "../../../api/explore-metrics";
import { useAppState } from "../../../state/common/StateContext";
import { ErrorTypes } from "../../../types";
interface FetchJobsReturn {
jobs: string[],
isLoading: boolean,
error?: ErrorTypes | string,
}
export const useFetchJobs = (): FetchJobsReturn => {
const { serverUrl } = useAppState();
const { period } = useTimeState();
const [jobs, setJobs] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>();
const fetchUrl = useMemo(() => getJobsUrl(serverUrl, period), [serverUrl, period]);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(fetchUrl);
const resp = await response.json();
const data = (resp.data || []) as string[];
setJobs(data.sort((a, b) => a.localeCompare(b)));
if (response.ok) {
setError(undefined);
} else {
setError(`${resp.errorType}\r\n${resp?.error}`);
}
} catch (e) {
if (e instanceof Error) {
setError(`${e.name}: ${e.message}`);
}
}
setIsLoading(false);
};
fetchData().catch(console.error);
}, [fetchUrl]);
return { jobs, isLoading, error };
};

View file

@ -0,0 +1,48 @@
import { useEffect, useMemo, useState } from "preact/compat";
import { getNamesUrl } from "../../../api/explore-metrics";
import { useAppState } from "../../../state/common/StateContext";
import { ErrorTypes } from "../../../types";
interface FetchNamesReturn {
names: string[],
isLoading: boolean,
error?: ErrorTypes | string,
}
export const useFetchNames = (job: string, instance: string): FetchNamesReturn => {
const { serverUrl } = useAppState();
const [names, setNames] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>();
const fetchUrl = useMemo(() => getNamesUrl(serverUrl, job, instance), [serverUrl, job, instance]);
useEffect(() => {
if (!job) return;
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(fetchUrl);
const resp = await response.json();
const data = (resp.data || []) as string[];
setNames(data.sort((a, b) => a.localeCompare(b)));
if (response.ok) {
setError(undefined);
} else {
setError(`${resp.errorType}\r\n${resp?.error}`);
}
} catch (e) {
if (e instanceof Error) {
setError(`${e.name}: ${e.message}`);
}
}
setIsLoading(false);
};
fetchData().catch(console.error);
}, [fetchUrl]);
return { names, isLoading, error };
};

View file

@ -0,0 +1,22 @@
import { useEffect } from "react";
import { compactObject } from "../../../utils/object";
import { useTimeState } from "../../../state/time/TimeStateContext";
import { setQueryStringWithoutPageReload } from "../../../utils/query-string";
export const useSetQueryParams = () => {
const { duration, relativeTime, period: { date, step } } = useTimeState();
const setSearchParamsFromState = () => {
const params = compactObject({
["g0.range_input"]: duration,
["g0.end_input"]: date,
["g0.step_input"]: step,
["g0.relative_time"]: relativeTime
});
setQueryStringWithoutPageReload(params);
};
useEffect(setSearchParamsFromState, [duration, relativeTime, date, step]);
useEffect(setSearchParamsFromState, []);
};

View file

@ -0,0 +1,142 @@
import React, { FC, useEffect, useMemo, useState } from "preact/compat";
import { useSetQueryParams } from "./hooks/useSetQueryParams";
import { useFetchJobs } from "./hooks/useFetchJobs";
import Select from "../../components/Main/Select/Select";
import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert";
import { useFetchInstances } from "./hooks/useFetchInstances";
import { useFetchNames } from "./hooks/useFetchNames";
import "./style.scss";
import ExploreMetricItem from "./ExploreMetricItem/ExploreMetricItem";
import TextField from "../../components/Main/TextField/TextField";
import { CloseIcon, SearchIcon } from "../../components/Main/Icons";
import Switch from "../../components/Main/Switch/Switch";
const ExploreMetrics: FC = () => {
useSetQueryParams();
const [job, setJob] = useState("");
const [instance, setInstance] = useState("");
const [searchMetric, setSearchMetric] = useState("");
const [openMetrics, setOpenMetrics] = useState<string[]>([]);
const [onlyGraphs, setOnlyGraphs] = useState(false);
const { jobs, isLoading: loadingJobs, error: errorJobs } = useFetchJobs();
const { instances, isLoading: loadingInstances, error: errorInstances } = useFetchInstances(job);
const { names, isLoading: loadingNames, error: errorNames } = useFetchNames(job, instance);
const noInstanceText = useMemo(() => job ? "" : "No instances. Please select job", [job]);
const metrics = useMemo(() => {
const showMetrics = onlyGraphs ? names.filter((m) => openMetrics.includes(m)) : names;
if (!searchMetric) return showMetrics;
try {
const regexp = new RegExp(searchMetric, "i");
const found = showMetrics.filter((m) => regexp.test(m));
return found.sort((a,b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0));
} catch (e) {
return [];
}
}, [names, searchMetric, openMetrics, onlyGraphs]);
const isLoading = useMemo(() => {
return loadingJobs || loadingInstances || loadingNames;
}, [loadingJobs, loadingInstances, loadingNames]);
const error = useMemo(() => {
return errorJobs || errorInstances || errorNames;
}, [errorJobs, errorInstances, errorNames]);
const handleClearSearch = () => {
setSearchMetric("");
};
const handleOpenMetric = (val: boolean, id: string) => {
setOpenMetrics(prev => {
if (!val) {
return prev.filter(item => item !== id);
}
if (!prev.includes(id)) {
return [...prev, id];
}
return prev;
});
};
useEffect(() => {
setInstance("");
}, [job]);
return (
<div className="vm-explore-metrics">
<div className="vm-explore-metrics-header vm-block">
<div className="vm-explore-metrics-header-top">
<Select
value={job}
list={jobs}
label="Job"
placeholder="Please select job"
onChange={setJob}
searchable
/>
<Select
value={instance}
list={instances}
label="Instance"
placeholder="Please select instance"
onChange={setInstance}
noOptionsText={noInstanceText}
clearable
searchable
/>
<div className="vm-explore-metrics-header-top__switch-graphs">
<Switch
label={"Show only opened metrics"}
value={onlyGraphs}
onChange={setOnlyGraphs}
/>
</div>
</div>
<TextField
autofocus
label="Metric search"
value={searchMetric}
onChange={setSearchMetric}
startIcon={<SearchIcon/>}
endIcon={(
<div
className="vm-explore-metrics-header__clear-icon"
onClick={handleClearSearch}
>
<CloseIcon/>
</div>
)}
/>
</div>
{isLoading && <Spinner />}
{error && <Alert variant="error">{error}</Alert>}
{!job && <Alert variant="info">Please select job to see list of metric names.</Alert>}
{!metrics.length && onlyGraphs && job && (
<Alert variant="info">
Open graphs not found. Turn off &quot;Show only open metrics&quot; to see list of metric names.
</Alert>
)}
<div className="vm-explore-metrics-body">
{metrics.map((n) => (
<ExploreMetricItem
key={n}
name={n}
job={job}
instance={instance}
openMetrics={openMetrics}
onOpen={handleOpenMetric}
/>
))}
</div>
</div>
);
};
export default ExploreMetrics;

View file

@ -0,0 +1,44 @@
@use "src/styles/variables" as *;
.vm-explore-metrics {
display: grid;
align-items: flex-start;
gap: $padding-medium;
&-header {
display: grid;
gap: $padding-small;
&-top {
display: grid;
grid-template-columns: minmax(200px, 300px) minmax(200px, 500px) auto;
align-items: center;
gap: $padding-medium;
&__switch-graphs {
display: flex;
align-items: center;
justify-content: flex-end;
}
}
&__clear-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
cursor: pointer;
&:hover {
opacity: 0.7
}
}
}
&-body {
display: grid;
align-items: flex-start;
border-radius: $border-radius-small;
box-shadow: $box-shadow;
}
}

View file

@ -1,4 +1,4 @@
import React, { FC, useMemo, useState } from "preact/compat";
import React, { FC, useEffect, useMemo, useState } from "preact/compat";
import { ChangeEvent } from "react";
import Trace from "../../components/TraceQuery/Trace";
import TracingsView from "../../components/TraceQuery/TracingsView";
@ -10,6 +10,7 @@ import { CloseIcon } from "../../components/Main/Icons";
import Modal from "../../components/Main/Modal/Modal";
import JsonForm from "./JsonForm/JsonForm";
import { ErrorTypes } from "../../types";
import { setQueryStringWithoutPageReload } from "../../utils/query-string";
const TracePage: FC = () => {
const [openModal, setOpenModal] = useState(false);
@ -72,6 +73,10 @@ const TracePage: FC = () => {
handleCloseError(index);
};
useEffect(() => {
setQueryStringWithoutPageReload({});
}, []);
const UploadButtons = () => (
<div className="vm-trace-page-controls">
<Button
@ -143,6 +148,7 @@ const TracePage: FC = () => {
{"\n"}
In order to use tracing please refer to the doc:&nbsp;
<a
className="vm__link vm__link_colored"
href="https://docs.victoriametrics.com/#query-tracing"
target="_blank"
rel="noreferrer"

View file

@ -56,7 +56,7 @@
justify-content: center;
&__text {
margin-bottom: $padding-small;
margin-bottom: $padding-global;
font-size: $font-size-medium;
white-space: pre-line;
text-align: center;

View file

@ -3,10 +3,13 @@ const router = {
dashboards: "/dashboards",
cardinality: "/cardinality",
topQueries: "/top-queries",
trace: "/trace"
trace: "/trace",
metrics: "/metrics",
icons: "/icons"
};
export interface RouterOptions {
title?: string,
header: {
timeSelector?: boolean,
executionControls?: boolean,
@ -23,12 +26,37 @@ const routerOptionsDefault = {
};
export const routerOptions: {[key: string]: RouterOptions} = {
[router.home]: routerOptionsDefault,
[router.dashboards]: routerOptionsDefault,
[router.home]: {
title: "Custom panel",
...routerOptionsDefault
},
[router.dashboards]: {
title: "Dashboards",
...routerOptionsDefault,
},
[router.cardinality]: {
title: "Cardinality",
header: {
cardinalityDatePicker: true,
}
},
[router.topQueries]: {
title: "Top queries",
header: {}
},
[router.trace]: {
title: "Trace analyzer",
header: {}
},
[router.metrics]: {
title: "Explore",
header: {
timeSelector: true,
}
},
[router.icons]: {
title: "Icons",
header: {}
}
};

View file

@ -0,0 +1,15 @@
@use "src/styles/variables" as *;
.vm__link {
transition: color 200ms ease;
cursor: pointer;
&_colored {
color: $color-primary;
}
&:hover {
color: $color-primary;
text-decoration: underline;
}
}

View file

@ -6,8 +6,9 @@
@forward "./components/list";
@forward "./components/popper-header";
@forward "./components/block";
@forward "components/sectionheader";
@forward "./components/sectionheader";
@forward "./components/table";
@forward "./components/link";
:root {
/* base palette */

View file

@ -63,4 +63,4 @@ $border-radius-large: 16px;
/************* box-shadows *************/
$box-shadow: 1px 2px 12px rgba($color-black, 0.08);
$box-shadow-bottom: rgba($color-black, 0.04) 0px 3px 5px;
$box-shadow-popper: rgba($color-black, 0.2) 0px 2px 8px 0px;
$box-shadow-popper: rgba($color-black, 0.1) 0px 2px 8px 0px;

View file

@ -123,6 +123,7 @@ Released at 2022-12-11
* FEATURE: [vmctl](https://docs.victoriametrics.com/vmctl.html): add ability to copy data from sources via Prometheus `remote_read` protocol. See [these docs](https://docs.victoriametrics.com/vmctl.html#migrating-data-by-remote-read-protocol). The related issues: [one](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3132) and [two](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1101).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): allow changing timezones for the requested data. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3075).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): provide fast path for hiding results for all the queries except the given one by clicking `eye` icon with `ctrl` key pressed. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3446).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add explore tab for exploration of metrics, which belong to a particular job/instance. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3386).
* FEATURE: [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html): add `range_trim_spikes(phi, q)` function for trimming `phi` percent of the largest spikes per each time series returned by `q`. See [these docs](https://docs.victoriametrics.com/MetricsQL.html#range_trim_spikes).
* FEATURE: [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html): allow passing `inf` arg into [limitk](https://docs.victoriametrics.com/MetricsQL.html#limitk), [topk](https://docs.victoriametrics.com/MetricsQL.html#topk), [bottomk](https://docs.victoriametrics.com/MetricsQL.html) and other functions, which accept numeric arg, which limits the number of output time series. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3461).
* FEATURE: [vmgateway](https://docs.victoriametrics.com/vmgateway.html): add support for JWT token signature verification. See [these docs](https://docs.victoriametrics.com/vmgateway.html#jwt-signature-verification) for details.