vmui: improve Explore metrics (#3598)

* feat: add multiple select

* feat: improve explore interface

* app/vmselect/vmui: `make vmui-update`

Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
Yury Molodov 2023-01-05 11:23:04 +01:00 committed by GitHub
parent 0e1f0ade31
commit 2460e0f51e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 776 additions and 403 deletions

View file

@ -1,12 +1,12 @@
{
"files": {
"main.css": "./static/css/main.9a291a47.css",
"main.js": "./static/js/main.e3ded72d.js",
"main.css": "./static/css/main.e9e7cdb7.css",
"main.js": "./static/js/main.d34bbb5e.js",
"static/js/27.c1ccfd29.chunk.js": "./static/js/27.c1ccfd29.chunk.js",
"index.html": "./index.html"
},
"entrypoints": [
"static/css/main.9a291a47.css",
"static/js/main.e3ded72d.js"
"static/css/main.e9e7cdb7.css",
"static/js/main.d34bbb5e.js"
]
}

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="VM-UI is a metric explorer for Victoria Metrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&family=Lato:wght@300;400;700&display=swap" rel="stylesheet"><script src="./dashboards/index.js" type="module"></script><script defer="defer" src="./static/js/main.e3ded72d.js"></script><link href="./static/css/main.9a291a47.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="VM-UI is a metric explorer for Victoria Metrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&family=Lato:wght@300;400;700&display=swap" rel="stylesheet"><script src="./dashboards/index.js" type="module"></script><script defer="defer" src="./static/js/main.d34bbb5e.js"></script><link href="./static/css/main.e9e7cdb7.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -30,7 +30,8 @@ export interface LineChartProps {
series: uPlotSeries[];
unit?: string;
setPeriod: ({ from, to }: {from: Date, to: Date}) => void;
container: HTMLDivElement | null
container: HTMLDivElement | null;
height?: number;
}
enum typeChartUpdate {xRange = "xRange", yRange = "yRange", data = "data"}
@ -43,7 +44,8 @@ const LineChart: FC<LineChartProps> = ({
yaxis,
unit,
setPeriod,
container
container,
height
}) => {
const uPlotRef = useRef<HTMLDivElement>(null);
const [isPanning, setPanning] = useState(false);
@ -172,6 +174,7 @@ const LineChart: FC<LineChartProps> = ({
axes: getAxes( [{}, { scale: "1" }], unit),
scales: { ...getScales() },
width: layoutSize.width || 400,
height: height || 500,
plugins: [{ hooks: { ready: onReadyChart, setCursor, setSeries: seriesFocus } }],
hooks: {
setSelect: [
@ -213,7 +216,7 @@ const LineChart: FC<LineChartProps> = ({
setUPlotInst(u);
setXRange({ min: period.start, max: period.end });
return u.destroy;
}, [uPlotRef.current, series, layoutSize]);
}, [uPlotRef.current, series, layoutSize, height]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);

View file

@ -1,7 +1,6 @@
@use "src/styles/variables" as *;
.vm-line-chart {
height: 500px;
pointer-events: auto;
&_panning {

View file

@ -47,7 +47,7 @@
&-list {
min-width: 600px;
max-height: 300px;
max-height: 200px;
background-color: $color-background-block;
border-radius: $border-radius-medium;
overflow: auto;

View file

@ -128,8 +128,8 @@ export const ExecutionControls: FC = () => {
{delayOptions.map(d => (
<div
className={classNames({
"vm-list__item": true,
"vm-list__item_active": d.seconds === selectedDelay.seconds
"vm-list-item": true,
"vm-list-item_active": d.seconds === selectedDelay.seconds
})}
key={d.seconds}
onClick={createHandlerChange(d)}

View file

@ -19,8 +19,8 @@ const TimeDurationSelector: FC<TimeDurationSelector> = ({ relativeTime, setDurat
{relativeTimeOptions.map(({ id, duration, until, title }) => (
<div
className={classNames({
"vm-list__item": true,
"vm-list__item_active": id === relativeTime
"vm-list-item": true,
"vm-list-item_active": id === relativeTime
})}
key={id}
onClick={createHandlerClick({ duration, until: until(), id })}

View file

@ -1,12 +1,12 @@
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 GraphView from "../../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 Spinner from "../../Main/Spinner/Spinner";
import Alert from "../../Main/Alert/Alert";
import Button from "../../Main/Button/Button";
import "./style.scss";
interface ExploreMetricItemGraphProps {
@ -15,6 +15,8 @@ interface ExploreMetricItemGraphProps {
instance: string,
rateEnabled: boolean,
isBucket: boolean,
showLegend: boolean
height?: number
}
const ExploreMetricItem: FC<ExploreMetricItemGraphProps> = ({
@ -22,7 +24,9 @@ const ExploreMetricItem: FC<ExploreMetricItemGraphProps> = ({
job,
instance,
rateEnabled,
isBucket
isBucket,
showLegend,
height
}) => {
const { customStep, yaxis } = useGraphState();
const { period } = useTimeState();
@ -90,11 +94,11 @@ with (q = ${queryBase}) (
};
return (
<div className="vm-explore-metrics-item-graph">
<div className="vm-explore-metrics-graph">
{isLoading && <Spinner />}
{error && <Alert variant="error">{error}</Alert>}
{warning && <Alert variant="warning">
<div className="vm-explore-metrics-item-graph__warning">
<div className="vm-explore-metrics-graph__warning">
<p>{warning}</p>
<Button
color="warning"
@ -114,6 +118,8 @@ with (q = ${queryBase}) (
yaxis={yaxis}
setYaxisLimits={setYaxisLimits}
setPeriod={setPeriod}
showLegend={showLegend}
height={height}
/>
)}
</div>

View file

@ -0,0 +1,12 @@
@use "src/styles/variables" as *;
.vm-explore-metrics-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,89 @@
import React, { FC, useEffect, useMemo, useState } from "preact/compat";
import ExploreMetricItemGraph from "../ExploreMetricGraph/ExploreMetricItemGraph";
import ExploreMetricItemHeader from "../ExploreMetricItemHeader/ExploreMetricItemHeader";
import "./style.scss";
import useResize from "../../../hooks/useResize";
interface ExploreMetricItemProps {
name: string
job: string
instance: string
index: number
onRemoveItem: (name: string) => void
onChangeOrder: (name: string, oldIndex: number, newIndex: number) => void
}
export const sizeVariants = [
{
id: "small",
height: () => window.innerHeight * 0.2
},
{
id: "medium",
isDefault: true,
height: () => window.innerHeight * 0.4
},
{
id: "large",
height: () => window.innerHeight * 0.8
},
];
const ExploreMetricItem: FC<ExploreMetricItemProps> = ({
name,
job,
instance,
index,
onRemoveItem,
onChangeOrder,
}) => {
const isCounter = useMemo(() => /_sum?|_total?|_count?/.test(name), [name]);
const isBucket = useMemo(() => /_bucket?/.test(name), [name]);
const [rateEnabled, setRateEnabled] = useState(isCounter);
const [showLegend, setShowLegend] = useState(false);
const [size, setSize] = useState(sizeVariants.find(v => v.isDefault) || sizeVariants[0]);
const windowSize = useResize(document.body);
const graphHeight = useMemo(size.height, [size, windowSize]);
const handleChangeSize = (id: string) => {
const target = sizeVariants.find(variant => variant.id === id);
if (target) setSize(target);
};
useEffect(() => {
setRateEnabled(isCounter);
}, [job]);
return (
<div className="vm-explore-metrics-item vm-block vm-block_empty-padding">
<ExploreMetricItemHeader
name={name}
index={index}
isBucket={isBucket}
rateEnabled={rateEnabled}
showLegend={showLegend}
size={size.id}
onChangeRate={setRateEnabled}
onChangeLegend={setShowLegend}
onRemoveItem={onRemoveItem}
onChangeOrder={onChangeOrder}
onChangeSize={handleChangeSize}
/>
<ExploreMetricItemGraph
key={`${name}_${job}_${instance}_${rateEnabled}`}
name={name}
job={job}
instance={instance}
rateEnabled={rateEnabled}
isBucket={isBucket}
showLegend={showLegend}
height={graphHeight}
/>
</div>
);
};
export default ExploreMetricItem;

View file

@ -0,0 +1,5 @@
@use "src/styles/variables" as *;
.vm-explore-metrics-item {
position: relative;
}

View file

@ -0,0 +1,143 @@
import React, { FC, useRef, useState } from "preact/compat";
import "./style.scss";
import Switch from "../../Main/Switch/Switch";
import Tooltip from "../../Main/Tooltip/Tooltip";
import Button from "../../Main/Button/Button";
import { ArrowDownIcon, CloseIcon, ResizeIcon } from "../../Main/Icons";
import Popper from "../../Main/Popper/Popper";
import ExploreMetricLayouts from "../ExploreMetricLayouts/ExploreMetricLayouts";
interface ExploreMetricItemControlsProps {
name: string
index: number
isBucket: boolean
rateEnabled: boolean
showLegend: boolean
size: string
onChangeRate: (val: boolean) => void
onChangeLegend: (val: boolean) => void
onRemoveItem: (name: string) => void
onChangeOrder: (name: string, oldIndex: number, newIndex: number) => void
onChangeSize: (id: string) => void
}
const ExploreMetricItemHeader: FC<ExploreMetricItemControlsProps> = ({
name,
index,
isBucket,
rateEnabled,
showLegend,
size,
onChangeRate,
onChangeLegend,
onRemoveItem,
onChangeOrder,
onChangeSize
}) => {
const layoutButtonRef = useRef<HTMLDivElement>(null);
const [openPopper, setOpenPopper] = useState(false);
const handleClickRemove = () => {
onRemoveItem(name);
};
const handleOrderDown = () => {
onChangeOrder(name, index, index + 1);
};
const handleOrderUp = () => {
onChangeOrder(name, index, index - 1);
};
const handleTogglePopper = () => {
setOpenPopper(prev => !prev);
};
const handleClosePopper = () => {
setOpenPopper(false);
};
const handleChangeSize = (id: string) => {
onChangeSize(id);
handleClosePopper();
};
return (
<div className="vm-explore-metrics-item-header">
<div className="vm-explore-metrics-item-header-order">
<Tooltip title="move graph up">
<Button
className="vm-explore-metrics-item-header-order__up"
startIcon={<ArrowDownIcon/>}
variant="text"
color="gray"
size="small"
onClick={handleOrderUp}
/>
</Tooltip>
<div className="vm-explore-metrics-item-header__index">#{index+1}</div>
<Tooltip title="move graph down">
<Button
className="vm-explore-metrics-item-header-order__down"
startIcon={<ArrowDownIcon/>}
variant="text"
color="gray"
size="small"
onClick={handleOrderDown}
/>
</Tooltip>
</div>
<div className="vm-explore-metrics-item-header__name">{name}</div>
{!isBucket && (
<Tooltip title="calculates the average per-second speed of metric's change">
<Switch
label={<span>enable <code>rate()</code></span>}
value={rateEnabled}
onChange={onChangeRate}
/>
</Tooltip>
)}
<Switch
label="show legend"
value={showLegend}
onChange={onChangeLegend}
/>
<div className="vm-explore-metrics-item-header__layout">
<Tooltip title="change size the graph">
<div ref={layoutButtonRef}>
<Button
startIcon={<ResizeIcon/>}
variant="text"
color="gray"
size="small"
onClick={handleTogglePopper}
/>
</div>
</Tooltip>
<Tooltip title="close graph">
<Button
startIcon={<CloseIcon/>}
variant="text"
color="gray"
size="small"
onClick={handleClickRemove}
/>
</Tooltip>
</div>
<Popper
open={openPopper}
onClose={handleClosePopper}
placement="bottom-right"
buttonRef={layoutButtonRef}
>
<ExploreMetricLayouts
value={size}
onChange={handleChangeSize}
/>
</Popper>
</div>
);
};
export default ExploreMetricItemHeader;

View file

@ -0,0 +1,46 @@
@use "src/styles/variables" as *;
.vm-explore-metrics-item-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
padding: $padding-global;
border-bottom: $border-divider;
gap: $padding-global;
&__index {
color: $color-text-secondary;
font-size: $font-size-small;
}
&__name {
flex-grow: 1;
font-weight: bold;
}
&-order {
display: grid;
grid-template-columns: auto 20px auto;
align-items: center;
justify-content: flex-start;
text-align: center;
&__up {
transform: rotate(180deg);
}
}
&__layout {
display: grid;
grid-template-columns: auto auto;
align-items: center;
}
code {
padding: 0.2em 0.4em;
font-size: 85%;
background-color: rgba($color-black, 0.05);
border-radius: 6px;
}
}

View file

@ -0,0 +1,41 @@
import React, { FC } from "preact/compat";
import "./style.scss";
import { sizeVariants } from "../ExploreMetricItem/ExploreMetricItem";
import classNames from "classnames";
import { DoneIcon } from "../../Main/Icons";
interface ExploreMetricLayoutsProps {
value: string
onChange: (id: string) => void
}
const ExploreMetricLayouts: FC<ExploreMetricLayoutsProps> = ({
value,
onChange
}) => {
const createHandlerClick = (id: string) => () => {
onChange(id);
};
return (
<div className="vm-explore-metrics-layouts">
{sizeVariants.map(variant => (
<div
className={classNames({
"vm-list-item": true,
"vm-list-item_multiselect": true,
"vm-list-item_multiselect_selected": variant.id === value
})}
key={variant.id}
onClick={createHandlerClick(variant.id)}
>
{variant.id === value && <DoneIcon/>}
<span>{variant.id}</span>
</div>
))}
</div>
);
};
export default ExploreMetricLayouts;

View file

@ -0,0 +1,5 @@
@use "src/styles/variables" as *;
.vm-explore-metrics-layouts {
display: grid;
}

View file

@ -0,0 +1,98 @@
import React, { FC, useEffect, useMemo } from "preact/compat";
import Select from "../../Main/Select/Select";
import StepConfigurator from "../../Configurators/StepConfigurator/StepConfigurator";
import "./style.scss";
import { useTimeState } from "../../../state/time/TimeStateContext";
import { useGraphDispatch, useGraphState } from "../../../state/graph/GraphStateContext";
import usePrevious from "../../../hooks/usePrevious";
interface ExploreMetricsHeaderProps {
jobs: string[]
instances: string[]
names: string[]
job: string
instance: string
selectedMetrics: string[]
onChangeJob: (job: string) => void
onChangeInstance: (instance: string) => void
onToggleMetric: (name: string) => void
}
const ExploreMetricsHeader: FC<ExploreMetricsHeaderProps> = ({
jobs,
instances,
names,
job,
instance,
selectedMetrics,
onChangeJob,
onChangeInstance,
onToggleMetric
}) => {
const { period: { step }, duration } = useTimeState();
const { customStep } = useGraphState();
const graphDispatch = useGraphDispatch();
const prevDuration = usePrevious(duration);
const noInstanceText = useMemo(() => job ? "" : "No instances. Please select job", [job]);
const noMetricsText = useMemo(() => job ? "" : "No metric names. Please select job", [job]);
const handleChangeStep = (value: string) => {
graphDispatch({ type: "SET_CUSTOM_STEP", payload: value });
};
useEffect(() => {
if (duration === prevDuration || !prevDuration) return;
if (customStep) handleChangeStep(step || "1s");
}, [duration, prevDuration]);
useEffect(() => {
if (!customStep && step) handleChangeStep(step);
}, [step]);
return (
<div className="vm-explore-metrics-header vm-block">
<div className="vm-explore-metrics-header__job">
<Select
value={job}
list={jobs}
label="Job"
placeholder="Please select job"
onChange={onChangeJob}
autofocus
/>
</div>
<div className="vm-explore-metrics-header__instance">
<Select
value={instance}
list={instances}
label="Instance"
placeholder="Please select instance"
onChange={onChangeInstance}
noOptionsText={noInstanceText}
clearable
/>
</div>
<div className="vm-explore-metrics-header__step">
<StepConfigurator
defaultStep={step}
setStep={handleChangeStep}
value={customStep}
/>
</div>
<div className="vm-explore-metrics-header-metrics">
<Select
value={selectedMetrics}
list={names}
placeholder="Search metric name"
onChange={onToggleMetric}
noOptionsText={noMetricsText}
clearable
/>
</div>
</div>
);
};
export default ExploreMetricsHeader;

View file

@ -0,0 +1,42 @@
@use "src/styles/variables" as *;
.vm-explore-metrics-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: $padding-small calc($padding-small + 10px);
&__job {
min-width: 200px;
max-width: 300px;
width: 100%;
}
&__instance {
min-width: 200px;
max-width: 500px;
width: 100%;
}
&__step {
}
&-metrics {
flex-grow: 1;
width: 100%;
}
&__clear-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
cursor: pointer;
&:hover {
opacity: 0.7
}
}
}

View file

@ -1,4 +1,4 @@
@use "../../../styles/variables" as *;
@use "src/styles/variables" as *;
.vm-header {
display: flex;

View file

@ -3,6 +3,7 @@ import classNames from "classnames";
import useClickOutside from "../../../hooks/useClickOutside";
import Popper from "../Popper/Popper";
import "./style.scss";
import { DoneIcon } from "../Icons";
interface AutocompleteProps {
value: string
@ -13,6 +14,7 @@ interface AutocompleteProps {
minLength?: number
fullWidth?: boolean
noOptionsText?: string
selected?: string[]
onSelect: (val: string) => void,
onOpenAutocomplete?: (val: boolean) => void
}
@ -25,6 +27,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
maxWords = 1,
minLength = 2,
fullWidth,
selected,
noOptionsText,
onSelect,
onOpenAutocomplete
@ -56,7 +59,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
const createHandlerSelect = (item: string) => () => {
if (disabled) return;
onSelect(item);
handleCloseAutocomplete();
if (!selected) handleCloseAutocomplete();
};
const scrollToValue = () => {
@ -84,7 +87,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
if (key === "Enter") {
const value = foundOptions[focusOption];
value && onSelect(value);
handleCloseAutocomplete();
if (!selected) handleCloseAutocomplete();
}
if (key === "Escape") {
@ -115,7 +118,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
onOpenAutocomplete && onOpenAutocomplete(openAutocomplete);
}, [openAutocomplete]);
useClickOutside(wrapperEl, handleCloseAutocomplete);
useClickOutside(wrapperEl, handleCloseAutocomplete, anchor);
return (
<Popper
@ -133,14 +136,17 @@ const Autocomplete: FC<AutocompleteProps> = ({
{foundOptions.map((option, i) =>
<div
className={classNames({
"vm-list__item": true,
"vm-list__item_active": i === focusOption
"vm-list-item": true,
"vm-list-item_active": i === focusOption,
"vm-list-item_multiselect": selected,
"vm-list-item_multiselect_selected": selected?.includes(option)
})}
id={`$autocomplete$${option}`}
key={option}
onClick={createHandlerSelect(option)}
>
{option}
{selected?.includes(option) && <DoneIcon/>}
<span>{option}</span>
</div>
)}
</div>

View file

@ -1,4 +1,4 @@
@use "../../../../styles/variables" as *;
@use "src/styles/variables" as *;
.vm-calendar {
display: grid;

View file

@ -320,3 +320,16 @@ export const SearchIcon = () => (
></path>
</svg>
);
export const ResizeIcon = () => (
<svg
className="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiBox-root css-1om0hkc"
focusable="false"
aria-hidden="true"
viewBox="0 0 24 24"
data-testid="OpenInFullIcon"
fill="currentColor"
>
<path d="M21 11V3h-8l3.29 3.29-10 10L3 13v8h8l-3.29-3.29 10-10z"></path>
</svg>
);

View file

@ -22,7 +22,6 @@ const Popper: FC<PopperProps> = ({
placement = "bottom-left",
open = false,
onClose,
animation,
offset = { top: 6, left: 0 },
clickOutside = true,
fullWidth
@ -109,7 +108,6 @@ const Popper: FC<PopperProps> = ({
const popperClasses = classNames({
"vm-popper": true,
"vm-popper_open": isOpen,
[`vm-popper_open_${animation}`]: animation,
});
return (

View file

@ -13,12 +13,17 @@
&_open {
z-index: 101;
opacity: 1;
animation: scale 150ms cubic-bezier(0.280, 0.840, 0.420, 1);
transform-origin: top center;
animation: vm-slider 150ms cubic-bezier(0.280, 0.840, 0.420, 1.1);
pointer-events: auto;
&_slider {
transform-origin: top center;
animation: slidePopper 0.3s cubic-bezier(0.280, 0.840, 0.420, 1.1);
}
}
}
@keyframes vm-slider {
0% {
transform: scaleY(0);
}
100% {
transform: scaleY(1);
}
}

View file

@ -1,32 +1,29 @@
import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
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 { FormEvent, MouseEvent } from "react";
import Autocomplete from "../Autocomplete/Autocomplete";
import "./style.scss";
interface JobSelectorProps {
value: string
interface SelectProps {
value: string | string[]
list: string[]
label?: string
placeholder?: string
noOptionsText?: string
error?: string
clearable?: boolean
searchable?: boolean
autofocus?: boolean
onChange: (value: string) => void
}
const Select: FC<JobSelectorProps> = ({
const Select: FC<SelectProps> = ({
value,
list,
label,
placeholder,
error,
noOptionsText,
clearable = false,
searchable,
autofocus,
onChange
}) => {
@ -34,12 +31,21 @@ const Select: FC<JobSelectorProps> = ({
const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
const [openList, setOpenList] = useState(false);
const textFieldValue = useMemo(() => openList ? search : value, [value, search, openList]);
const inputRef = useRef<HTMLInputElement>(null);
const isMultiple = useMemo(() => Array.isArray(value), [value]);
const selectedValues = useMemo(() => Array.isArray(value) ? value : undefined, [isMultiple, value]);
const textFieldValue = useMemo(() => {
if (openList) return search;
return Array.isArray(value) ? "" : value;
}, [value, search, openList, isMultiple]);
const autocompleteValue = useMemo(() => !openList ? "" : search || "(.+)", [search, openList]);
const clearFocus = () => {
if (document.activeElement instanceof HTMLInputElement) {
document.activeElement.blur();
if (inputRef.current) {
inputRef.current.blur();
}
};
@ -52,65 +58,109 @@ const Select: FC<JobSelectorProps> = ({
setOpenList(true);
};
const handleClickJob = (job: string) => {
onChange(job);
handleCloseList();
const handleToggleList = (e: MouseEvent<HTMLDivElement>) => {
if (e.target instanceof HTMLInputElement) return;
setOpenList(prev => !prev);
};
const createHandleClick = (job: string) => (e: MouseEvent<HTMLDivElement>) => {
handleClickJob(job);
const handleSelected = (val: string) => {
onChange(val);
if (!isMultiple) handleCloseList();
if (isMultiple && inputRef.current) inputRef.current.focus();
};
const handleChange = (e: FormEvent<HTMLInputElement>) => {
setSearch((e.target as HTMLInputElement).value);
};
const createHandleClick = (value: string) => (e: MouseEvent) => {
handleSelected(value);
e.stopPropagation();
};
const handleKeyUp = (e: KeyboardEvent) => {
if (inputRef.current !== e.target) {
setOpenList(false);
}
};
useEffect(() => {
setSearch("");
}, [openList]);
if (openList && inputRef.current) {
inputRef.current.focus();
}
if (!openList) clearFocus();
}, [openList, inputRef]);
useEffect(() => {
if (!autofocus || !inputRef.current) return;
inputRef.current.focus();
}, [autofocus, inputRef]);
useEffect(() => {
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
return (
<div className="vm-select">
<div
className="vm-select-input"
onClick={handleToggleList}
ref={autocompleteAnchorEl}
>
<TextField
label={label}
type="text"
value={textFieldValue}
placeholder={placeholder}
error={error}
disabled={!searchable}
onFocus={handleFocus}
onEnter={handleCloseList}
onChange={setSearch}
endIcon={(
<div className="vm-select-input-content">
{selectedValues && selectedValues.map(item => (
<div
className={classNames({
"vm-select-input__icon": true,
"vm-select-input__icon_open": openList
})}
className="vm-select-input-content__selected"
key={item}
>
<ArrowDropDownIcon/>
{item}
<div onClick={createHandleClick(item)}>
<CloseIcon/>
</div>
</div>
)}
/>
{clearable && (
))}
<input
value={textFieldValue}
type="text"
placeholder={placeholder}
onInput={handleChange}
onFocus={handleFocus}
ref={inputRef}
/>
</div>
{label && <span className="vm-text-field__label">{label}</span>}
{clearable && value && (
<div
className="vm-select-input__clear"
className="vm-select-input__icon"
onClick={createHandleClick("")}
>
<CloseIcon/>
</div>
)}
<div
className={classNames({
"vm-select-input__icon": true,
"vm-select-input__icon_open": openList
})}
>
<ArrowDropDownIcon/>
</div>
</div>
<Autocomplete
value={autocompleteValue}
options={list}
anchor={autocompleteAnchorEl}
selected={selectedValues}
maxWords={10}
minLength={0}
fullWidth
noOptionsText={noOptionsText}
onSelect={handleClickJob}
onSelect={handleSelected}
onOpenAutocomplete={setOpenList}
/>
</div>

View file

@ -3,18 +3,82 @@
.vm-select {
&-input {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 0 5px $padding-global;
cursor: pointer;
border: $border-divider;
border-radius: $border-radius-small;
min-height: 36px;
&-content {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: $padding-small;
width: 100%;
&__selected {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: rgba($color-black, 0.06);
padding: 2px 2px 2px 6px;
border-radius: $border-radius-small;
font-size: $font-size;
line-height: $font-size;
svg {
width: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 10px;
background-color: transparent;
border-radius: $border-radius-small;
transition: background-color 200ms ease-in-out;
padding: 4px;
&:hover {
background-color: rgba($color-black, 0.1);
}
}
}
}
input {
pointer-events: none;
display: inline-block;
position: relative;
border-radius: $border-radius-small;
font-size: $font-size;
line-height: 18px;
height: 18px;
padding: 0;
border: none;
z-index: 2;
min-width: 100px;
flex-grow: 1;
&:placeholder-shown {
width: auto;
}
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: flex-end;
margin: 0 0 0 auto;
transition: transform 200ms ease-in;
color: $color-text-secondary;
border-right: $border-divider;
transition: transform 200ms ease-in, opacity 200ms ease-in;
cursor: pointer;
padding: 0 $padding-small;
&:last-child {
border: none;
}
svg {
width: 14px;
@ -23,24 +87,6 @@
&_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

@ -113,8 +113,8 @@ const TextField: FC<TextFieldProps> = ({
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
/>)
/>
)
}
{label && <span className="vm-text-field__label">{label}</span>}
<span

View file

@ -25,6 +25,7 @@ export interface GraphViewProps {
setYaxisLimits: (val: AxisRange) => void
setPeriod: ({ from, to }: {from: Date, to: Date}) => void
fullWidth?: boolean
height?: number
}
const promValueToNumber = (s: string): number => {
@ -53,7 +54,8 @@ const GraphView: FC<GraphViewProps> = ({
setYaxisLimits,
setPeriod,
alias = [],
fullWidth = true
fullWidth = true,
height
}) => {
const { timezone } = useTimeState();
const currentStep = useMemo(() => customStep || period.step || "1s", [period.step, customStep]);
@ -157,6 +159,7 @@ const GraphView: FC<GraphViewProps> = ({
unit={unit}
setPeriod={setPeriod}
container={containerRef?.current}
height={height}
/>}
{showLegend && <Legend
labels={legend}

View file

@ -1,4 +1,4 @@
@use "../../../../styles/variables" as *;
@use "src/styles/variables" as *;
.vm-table-settings-popper {
display: grid;

View file

@ -1,79 +0,0 @@
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>rate()</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}
isBucket={isBucket}
/>
</Accordion>
</div>
);
};
export default ExploreMetricItem;

View file

@ -1,36 +0,0 @@
@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

@ -1,53 +1,25 @@
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";
import StepConfigurator from "../../components/Configurators/StepConfigurator/StepConfigurator";
import { useTimeState } from "../../state/time/TimeStateContext";
import { useGraphDispatch, useGraphState } from "../../state/graph/GraphStateContext";
import usePrevious from "../../hooks/usePrevious";
import ExploreMetricItem from "../../components/ExploreMetrics/ExploreMetricItem/ExploreMetricItem";
import ExploreMetricsHeader from "../../components/ExploreMetrics/ExploreMetricsHeader/ExploreMetricsHeader";
const ExploreMetrics: FC = () => {
useSetQueryParams();
const { period: { step }, duration } = useTimeState();
const { customStep } = useGraphState();
const graphDispatch = useGraphDispatch();
const prevDuration = usePrevious(duration);
const [job, setJob] = useState("");
const [instance, setInstance] = useState("");
const [searchMetric, setSearchMetric] = useState("");
const [openMetrics, setOpenMetrics] = useState<string[]>([]);
const [onlyGraphs, setOnlyGraphs] = useState(false);
const [metrics, setMetrics] = useState<string[]>([]);
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]);
@ -56,115 +28,58 @@ const ExploreMetrics: FC = () => {
return errorJobs || errorInstances || errorNames;
}, [errorJobs, errorInstances, errorNames]);
const handleClearSearch = () => {
setSearchMetric("");
const handleToggleMetric = (name: string) => {
if (!name) {
setMetrics([]);
} else {
setMetrics((prev) => prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]);
}
};
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;
const handleChangeOrder = (name: string, oldIndex: number, newIndex: number) => {
const maxIndex = newIndex > (metrics.length - 1);
const minIndex = newIndex < 0;
if (minIndex || maxIndex) return;
setMetrics(prev => {
const updatedList = [...prev];
const [reorderedItem] = updatedList.splice(oldIndex, 1);
updatedList.splice(newIndex, 0, reorderedItem);
return updatedList;
});
};
const handleChangeStep = (value: string) => {
graphDispatch({ type: "SET_CUSTOM_STEP", payload: value });
};
useEffect(() => {
setInstance("");
}, [job]);
useEffect(() => {
if (!customStep && step) handleChangeStep(step);
}, [step]);
useEffect(() => {
if (duration === prevDuration || !prevDuration) return;
if (customStep) handleChangeStep(step || "1s");
}, [duration, prevDuration]);
return (
<div className="vm-explore-metrics">
<div className="vm-explore-metrics-header vm-block">
<div className="vm-explore-metrics-header__job">
<Select
value={job}
list={jobs}
label="Job"
placeholder="Please select job"
onChange={setJob}
searchable
/>
</div>
<div className="vm-explore-metrics-header__instance">
<Select
value={instance}
list={instances}
label="Instance"
placeholder="Please select instance"
onChange={setInstance}
noOptionsText={noInstanceText}
clearable
searchable
/>
</div>
<div className="vm-explore-metrics-header__step">
<StepConfigurator
defaultStep={step}
setStep={handleChangeStep}
value={customStep}
/>
</div>
<div className="vm-explore-metrics-header__switch-graphs">
<Switch
label={"Show only opened metrics"}
value={onlyGraphs}
onChange={setOnlyGraphs}
/>
</div>
<div className="vm-explore-metrics-header__search">
<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>
</div>
<ExploreMetricsHeader
jobs={jobs}
instances={instances}
names={names}
job={job}
instance={instance}
selectedMetrics={metrics}
onChangeJob={setJob}
onChangeInstance={setInstance}
onToggleMetric={handleToggleMetric}
/>
{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 opened metrics&quot; to see list of metric names.
</Alert>
)}
{job && !metrics.length && <Alert variant="info">Please select metric names to see the graphs.</Alert>}
<div className="vm-explore-metrics-body">
{metrics.map((n) => (
{metrics.map((n, i) => (
<ExploreMetricItem
key={n}
name={n}
job={job}
instance={instance}
openMetrics={openMetrics}
onOpen={handleOpenMetric}
index={i}
onRemoveItem={handleToggleMetric}
onChangeOrder={handleChangeOrder}
/>
))}
</div>

View file

@ -5,67 +5,9 @@
align-items: flex-start;
gap: $padding-medium;
&-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: $padding-small $padding-medium;
&__job {
min-width: 200px;
max-width: 300px;
width: 100%;
}
&__instance {
min-width: 200px;
max-width: 500px;
width: 100%;
}
&__step {
}
&__switch-graphs {
}
&__search {
flex-grow: 1;
width: 100%;
}
//&-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;
// gap: $padding-medium;
// }
//}
&__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;
gap: $padding-medium;
}
}

View file

@ -1,18 +1,35 @@
@use "src/styles/variables" as *;
.vm-list {
&__item {
&-item {
padding: 12px $padding-global;
cursor: pointer;
background-color: transparent;
transition: background-color 200ms ease;
&:hover,
&_active {
background-color: rgba($color-black, 0.1);
background-color: rgba($color-black, 0.06);
}
&:hover {
background-color: rgba($color-black, 0.1);
&_multiselect {
display: grid;
grid-template-columns: 10px 1fr;
gap: $padding-small;
align-items: center;
justify-content: flex-start;
svg {
animation: vm-scale 150ms cubic-bezier(0.280, 0.840, 0.420, 1);
}
span {
grid-column: 2;
}
&_selected {
color: $color-primary;
}
}
}
}

View file

@ -33,6 +33,11 @@ input {
cursor: text;
}
input::placeholder,
textarea::placeholder {
user-select: none;
}
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;

View file

@ -2,7 +2,6 @@ import uPlot, { Axis } from "uplot";
import { getColorFromString } from "../color";
export const defaultOptions = {
height: 500,
legend: {
show: false
},