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` ### `npm start`
Runs the app in the development mode.\ 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.\ The page will reload if you make edits.\
You will also see any lint errors in the console. 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 ThemeProvider from "./components/Main/ThemeProvider/ThemeProvider";
import Spinner from "./components/Main/Spinner/Spinner"; import Spinner from "./components/Main/Spinner/Spinner";
import TracePage from "./pages/TracePage"; import TracePage from "./pages/TracePage";
import ExploreMetrics from "./pages/ExploreMetrics";
import PreviewIcons from "./components/Main/Icons/PreviewIcons";
const App: FC = () => { const App: FC = () => {
@ -50,6 +52,14 @@ const App: FC = () => {
path={router.trace} path={router.trace}
element={<TracePage/>} element={<TracePage/>}
/> />
<Route
path={router.metrics}
element={<ExploreMetrics/>}
/>
<Route
path={router.icons}
element={<PreviewIcons/>}
/>
</Route> </Route>
</Routes> </Routes>
</AppContextProvider> </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"> return <footer className="vm-footer">
<a <a
className="vm-footer__link vm-footer__website" className="vm__link vm-footer__website"
target="_blank" target="_blank"
href="https://victoriametrics.com/" href="https://victoriametrics.com/"
rel="noreferrer" rel="noreferrer"
@ -17,7 +17,7 @@ const Footer: FC = () => {
victoriametrics.com victoriametrics.com
</a> </a>
<a <a
className="vm-footer__link" className="vm__link"
target="_blank" target="_blank"
href="https://github.com/VictoriaMetrics/VictoriaMetrics/issues/new" href="https://github.com/VictoriaMetrics/VictoriaMetrics/issues/new"
rel="noreferrer" rel="noreferrer"

View file

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

View file

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

View file

@ -1,14 +1,22 @@
import Header from "./Header/Header"; import Header from "./Header/Header";
import React, { FC } from "preact/compat"; import React, { FC, useEffect } from "preact/compat";
import { Outlet } from "react-router-dom"; import { Outlet, useLocation } from "react-router-dom";
import "./style.scss"; import "./style.scss";
import { getAppModeEnable } from "../../utils/app-mode"; import { getAppModeEnable } from "../../utils/app-mode";
import classNames from "classnames"; import classNames from "classnames";
import Footer from "./Footer/Footer"; import Footer from "./Footer/Footer";
import { routerOptions } from "../../router";
const Layout: FC = () => { const Layout: FC = () => {
const appModeEnable = getAppModeEnable(); 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"> return <section className="vm-container">
<Header/> <Header/>
<div <div

View file

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

View file

@ -3,4 +3,10 @@
.vm-autocomplete { .vm-autocomplete {
max-height: 300px; max-height: 300px;
overflow: auto; 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> </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 = () => ( export const ArrowDropDownIcon = () => (
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -318,3 +309,14 @@ export const DragIcon = () => (
<path d="M20 9H4v2h16V9zM4 15h16v-2H4v2z"></path> <path d="M20 9H4v2h16V9zM4 15h16v-2H4v2z"></path>
</svg> </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; $padding-modal: 22px;

View file

@ -12,7 +12,8 @@ interface PopperProps {
placement?: "bottom-right" | "bottom-left" | "top-left" | "top-right" placement?: "bottom-right" | "bottom-left" | "top-left" | "top-right"
animation?: string animation?: string
offset?: {top: number, left: number} offset?: {top: number, left: number}
clickOutside?: boolean clickOutside?: boolean,
fullWidth?: boolean
} }
const Popper: FC<PopperProps> = ({ const Popper: FC<PopperProps> = ({
@ -23,7 +24,8 @@ const Popper: FC<PopperProps> = ({
onClose, onClose,
animation, animation,
offset = { top: 6, left: 0 }, offset = { top: 6, left: 0 },
clickOutside = true clickOutside = true,
fullWidth
}) => { }) => {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
@ -68,7 +70,8 @@ const Popper: FC<PopperProps> = ({
const position = { const position = {
top: 0, top: 0,
left: 0 left: 0,
width: "auto"
}; };
const needAlignRight = placement === "bottom-right" || placement === "top-right"; 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 (isOverflowRight) position.left = buttonPos.right - popperSize.width - offsetLeft;
if (isOverflowLeft) position.left = buttonPos.left + offsetLeft; if (isOverflowLeft) position.left = buttonPos.left + offsetLeft;
if (fullWidth) position.width = `${buttonPos.width}px`;
return position; return position;
},[buttonRef, placement, isOpen, children]); },[buttonRef, placement, isOpen, children, fullWidth]);
if (clickOutside) useClickOutside(popperRef, () => setIsOpen(false), buttonRef); 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 classNames from "classnames";
import "./style.scss"; import "./style.scss";
import { FC } from "preact/compat"; import { FC } from "preact/compat";
@ -7,7 +7,7 @@ interface SwitchProps {
value: boolean value: boolean
color?: "primary" | "secondary" | "error" color?: "primary" | "secondary" | "error"
disabled?: boolean disabled?: boolean
label?: string label?: string | ReactNode
onChange: (value: boolean) => void onChange: (value: boolean) => void
} }

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import React, { FC, useState } from "preact/compat"; import React, { FC, useState } from "preact/compat";
import LineProgress from "../../Main/LineProgress/LineProgress"; import LineProgress from "../../Main/LineProgress/LineProgress";
import Trace from "../Trace"; import Trace from "../Trace";
import { ArrowUpIcon } from "../../Main/Icons"; import { ArrowDownIcon } from "../../Main/Icons";
import "./style.scss"; import "./style.scss";
import classNames from "classnames"; import classNames from "classnames";
@ -38,7 +38,7 @@ const NestedNav: FC<RecursiveProps> = ({ trace, totalMsec }) => {
"vm-nested-nav-header__icon_open": openLevels[trace.idValue] "vm-nested-nav-header__icon_open": openLevels[trace.idValue]
})} })}
> >
<ArrowUpIcon /> <ArrowDownIcon />
</div> </div>
)} )}
<div className="vm-nested-nav-header__progress"> <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 { ChangeEvent } from "react";
import Trace from "../../components/TraceQuery/Trace"; import Trace from "../../components/TraceQuery/Trace";
import TracingsView from "../../components/TraceQuery/TracingsView"; import TracingsView from "../../components/TraceQuery/TracingsView";
@ -10,6 +10,7 @@ import { CloseIcon } from "../../components/Main/Icons";
import Modal from "../../components/Main/Modal/Modal"; 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 { setQueryStringWithoutPageReload } from "../../utils/query-string";
const TracePage: FC = () => { const TracePage: FC = () => {
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
@ -72,6 +73,10 @@ const TracePage: FC = () => {
handleCloseError(index); handleCloseError(index);
}; };
useEffect(() => {
setQueryStringWithoutPageReload({});
}, []);
const UploadButtons = () => ( const UploadButtons = () => (
<div className="vm-trace-page-controls"> <div className="vm-trace-page-controls">
<Button <Button
@ -143,6 +148,7 @@ const TracePage: FC = () => {
{"\n"} {"\n"}
In order to use tracing please refer to the doc:&nbsp; In order to use tracing please refer to the doc:&nbsp;
<a <a
className="vm__link vm__link_colored"
href="https://docs.victoriametrics.com/#query-tracing" href="https://docs.victoriametrics.com/#query-tracing"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"

View file

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

View file

@ -3,10 +3,13 @@ const router = {
dashboards: "/dashboards", dashboards: "/dashboards",
cardinality: "/cardinality", cardinality: "/cardinality",
topQueries: "/top-queries", topQueries: "/top-queries",
trace: "/trace" trace: "/trace",
metrics: "/metrics",
icons: "/icons"
}; };
export interface RouterOptions { export interface RouterOptions {
title?: string,
header: { header: {
timeSelector?: boolean, timeSelector?: boolean,
executionControls?: boolean, executionControls?: boolean,
@ -23,12 +26,37 @@ const routerOptionsDefault = {
}; };
export const routerOptions: {[key: string]: RouterOptions} = { export const routerOptions: {[key: string]: RouterOptions} = {
[router.home]: routerOptionsDefault, [router.home]: {
[router.dashboards]: routerOptionsDefault, title: "Custom panel",
...routerOptionsDefault
},
[router.dashboards]: {
title: "Dashboards",
...routerOptionsDefault,
},
[router.cardinality]: { [router.cardinality]: {
title: "Cardinality",
header: { header: {
cardinalityDatePicker: true, 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/list";
@forward "./components/popper-header"; @forward "./components/popper-header";
@forward "./components/block"; @forward "./components/block";
@forward "components/sectionheader"; @forward "./components/sectionheader";
@forward "./components/table"; @forward "./components/table";
@forward "./components/link";
:root { :root {
/* base palette */ /* base palette */

View file

@ -63,4 +63,4 @@ $border-radius-large: 16px;
/************* box-shadows *************/ /************* box-shadows *************/
$box-shadow: 1px 2px 12px rgba($color-black, 0.08); $box-shadow: 1px 2px 12px rgba($color-black, 0.08);
$box-shadow-bottom: rgba($color-black, 0.04) 0px 3px 5px; $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: [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): 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): 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): 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: [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. * 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.