app/vmui: update cardinality page (#3986)

vmui: update cardinality page

---------

Co-authored-by: Yury Moladau <yurymolodov@gmail.com>
This commit is contained in:
Dmytro Kozlov 2023-03-23 19:18:02 +02:00 committed by Aliaksandr Valialkin
parent 5f77efa915
commit 352dbd7e08
No known key found for this signature in database
GPG key ID: A72BEC6CD3D0DED1
27 changed files with 900 additions and 463 deletions

View file

@ -1,6 +1,5 @@
export interface CardinalityRequestsParams {
topN: number,
extraLabel: string | null,
match: string | null,
date: string | null,
focusLabel: string | null,

View file

@ -0,0 +1,67 @@
import React, { FC, useEffect, useState } from "preact/compat";
import Tooltip from "../../Main/Tooltip/Tooltip";
import "./style.scss";
type BarChartData = {
value: number,
name: string,
percentage?: number,
}
interface SimpleBarChartProps {
data: BarChartData[],
}
const SimpleBarChart: FC<SimpleBarChartProps> = ({ data }) => {
const [bars, setBars] = useState<BarChartData[]>([]);
const [yAxis, setYAxis] = useState([0, 0]);
const generateYAxis = (sortedValues: BarChartData[]) => {
const numbers = sortedValues.map(b => b.value);
const max = Math.ceil(numbers[0] || 1);
const ticks = 10;
const step = max / (ticks - 1);
return new Array(ticks + 1).fill(max + step).map((v, i) => Math.round(v - (step * i)));
};
useEffect(() => {
const sortedValues = data.sort((a, b) => b.value - a.value);
const yAxis = generateYAxis(sortedValues);
setYAxis(yAxis);
setBars(sortedValues.map(b => ({
...b,
percentage: (b.value / yAxis[0]) * 100,
})));
}, [data]);
return (
<div className="vm-simple-bar-chart">
<div className="vm-simple-bar-chart-y-axis">
{yAxis.map(v => (
<div
className="vm-simple-bar-chart-y-axis__tick"
key={v}
>{v}</div>
))}
</div>
<div className="vm-simple-bar-chart-data">
{bars.map(({ name, value, percentage }) => (
<Tooltip
title={`${name}: ${value}`}
key={`${name}_${value}`}
placement="top-center"
>
<div
className="vm-simple-bar-chart-data-item"
style={{ maxHeight: `${percentage || 0}%` }}
/>
</Tooltip>
))}
</div>
</div>
);
};
export default SimpleBarChart;

View file

@ -0,0 +1,74 @@
@use "src/styles/variables" as *;
$color-bar: #33BB55;
$color-bar-highest: #F79420;
.vm-simple-bar-chart {
display: grid;
grid-template-columns: auto 1fr;
height: 100%;
padding-bottom: #{$font-size-small/2};
overflow: hidden;
&-y-axis {
position: relative;
display: grid;
transform: translateY(#{$font-size-small});
&__tick {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
transform-style: preserve-3d;
text-align: right;
padding-right: $padding-small;
font-size: $font-size-small;
line-height: 2;
z-index: 1;
&:after {
content: '';
position: absolute;
top: auto;
left: 100%;
width: 100vw;
height: 0;
border-bottom: $border-divider;
transform: translateY(-1px) translateZ(-1);
}
}
}
&-data {
position: relative;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1%;
&-item {
display: flex;
align-items: flex-start;
justify-content: center;
flex-grow: 1;
width: 100%;
min-width: 1px;
height: calc(100% - ($font-size-small*4));
background-color: $color-bar;
transition: background-color 200ms ease-in;
&:hover {
background-color: lighten($color-bar, 10%);
}
&:first-child {
background-color: $color-bar-highest;
&:hover {
background-color: lighten($color-bar-highest, 10%);
}
}
}
}
}

View file

@ -1,5 +1,4 @@
import React, { FC, useMemo, useRef } from "preact/compat";
import { useCardinalityState, useCardinalityDispatch } from "../../../state/cardinality/CardinalityStateContext";
import React, { FC, useEffect, useMemo, useRef } from "preact/compat";
import dayjs from "dayjs";
import Button from "../../Main/Button/Button";
import { ArrowDownIcon, CalendarIcon } from "../../Main/Icons";
@ -8,21 +7,28 @@ import { getAppModeEnable } from "../../../utils/app-mode";
import { DATE_FORMAT } from "../../../constants/date";
import DatePicker from "../../Main/DatePicker/DatePicker";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import { useSearchParams } from "react-router-dom";
const CardinalityDatePicker: FC = () => {
const { isMobile } = useDeviceDetect();
const appModeEnable = getAppModeEnable();
const buttonRef = useRef<HTMLDivElement>(null);
const { date } = useCardinalityState();
const cardinalityDispatch = useCardinalityDispatch();
const [searchParams, setSearchParams] = useSearchParams();
const date = searchParams.get("date") || dayjs().tz().format(DATE_FORMAT);
const dateFormatted = useMemo(() => dayjs.tz(date).format(DATE_FORMAT), [date]);
const handleChangeDate = (val: string) => {
cardinalityDispatch({ type: "SET_DATE", payload: val });
searchParams.set("date", val);
setSearchParams(searchParams);
};
useEffect(() => {
handleChangeDate(date);
}, []);
return (
<div>
<div ref={buttonRef}>

View file

@ -387,3 +387,14 @@ export const TuneIcon = () => (
></path>
</svg>
);
export const TipIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M7 20h4c0 1.1-.9 2-2 2s-2-.9-2-2zm-2-1h8v-2H5v2zm11.5-9.5c0 3.82-2.66 5.86-3.77 6.5H5.27c-1.11-.64-3.77-2.68-3.77-6.5C1.5 5.36 4.86 2 9 2s7.5 3.36 7.5 7.5zm4.87-2.13L20 8l1.37.63L22 10l.63-1.37L24 8l-1.37-.63L22 6l-.63 1.37zM19 6l.94-2.06L22 3l-2.06-.94L19 0l-.94 2.06L16 3l2.06.94L19 6z"
></path>
</svg>
);

View file

@ -3,7 +3,6 @@ import { TimeStateProvider } from "../state/time/TimeStateContext";
import { QueryStateProvider } from "../state/query/QueryStateContext";
import { CustomPanelStateProvider } from "../state/customPanel/CustomPanelStateContext";
import { GraphStateProvider } from "../state/graph/GraphStateContext";
import { CardinalityStateProvider } from "../state/cardinality/CardinalityStateContext";
import { TopQueriesStateProvider } from "../state/topQueries/TopQueriesStateContext";
import { SnackbarProvider } from "./Snackbar";
@ -16,7 +15,6 @@ const providers = [
QueryStateProvider,
CustomPanelStateProvider,
GraphStateProvider,
CardinalityStateProvider,
TopQueriesStateProvider,
SnackbarProvider,
DashboardsStateProvider

View file

@ -1,96 +1,76 @@
import React, { FC, useMemo } from "react";
import QueryEditor from "../../../components/Configurators/QueryEditor/QueryEditor";
import { useFetchQueryOptions } from "../../../hooks/useFetchQueryOptions";
import { ErrorTypes } from "../../../types";
import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext";
import Switch from "../../../components/Main/Switch/Switch";
import { InfoIcon, PlayIcon, QuestionIcon, WikiIcon } from "../../../components/Main/Icons";
import { PlayIcon, QuestionIcon, RestartIcon, TipIcon, WikiIcon } from "../../../components/Main/Icons";
import Button from "../../../components/Main/Button/Button";
import TextField from "../../../components/Main/TextField/TextField";
import "./style.scss";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import classNames from "classnames";
import { useEffect, useState } from "preact/compat";
import { useSearchParams } from "react-router-dom";
import CardinalityTotals, { CardinalityTotalsProps } from "../CardinalityTotals/CardinalityTotals";
export interface CardinalityConfiguratorProps {
onSetHistory: (step: number) => void;
onSetQuery: (query: string) => void;
onRunQuery: () => void;
onTopNChange: (value: string) => void;
onFocusLabelChange: (value: string) => void;
query: string;
topN: number;
error?: ErrorTypes | string;
totalSeries: number;
totalLabelValuePairs: number;
date: string | null;
match: string | null;
focusLabel: string | null;
}
const CardinalityConfigurator: FC<CardinalityConfiguratorProps> = ({
topN,
error,
query,
onSetHistory,
onRunQuery,
onSetQuery,
onTopNChange,
onFocusLabelChange,
totalSeries,
totalLabelValuePairs,
date,
match,
focusLabel
}) => {
const { autocomplete } = useQueryState();
const queryDispatch = useQueryDispatch();
const CardinalityConfigurator: FC<CardinalityTotalsProps> = (props) => {
const { isMobile } = useDeviceDetect();
const [searchParams, setSearchParams] = useSearchParams();
const { queryOptions } = useFetchQueryOptions();
const showTips = searchParams.get("tips") || "";
const [match, setMatch] = useState(searchParams.get("match") || "");
const [focusLabel, setFocusLabel] = useState(searchParams.get("focusLabel") || "");
const [topN, setTopN] = useState(+(searchParams.get("topN") || 10));
const errorTopN = useMemo(() => topN < 1 ? "Number must be bigger than zero" : "", [topN]);
const errorTopN = useMemo(() => topN < 0 ? "Number must be bigger than zero" : "", [topN]);
const onChangeAutocomplete = () => {
queryDispatch({ type: "TOGGLE_AUTOCOMPLETE" });
const handleTopNChange = (val: string) => {
const num = +val;
setTopN(isNaN(num) ? 0 : num);
};
const handleArrowUp = () => {
onSetHistory(-1);
const handleRunQuery = () => {
searchParams.set("match", match);
searchParams.set("topN", topN.toString());
searchParams.set("focusLabel", focusLabel);
setSearchParams(searchParams);
};
const handleArrowDown = () => {
onSetHistory(1);
const handleResetQuery = () => {
searchParams.set("match", "");
searchParams.set("focusLabel", "");
setSearchParams(searchParams);
};
const handleToggleTips = () => {
const showTips = searchParams.get("tips") || "";
if (showTips) searchParams.delete("tips");
else searchParams.set("tips", "true");
setSearchParams(searchParams);
};
useEffect(() => {
const matchQuery = searchParams.get("match");
const topNQuery = +(searchParams.get("topN") || 10);
const focusLabelQuery = searchParams.get("focusLabel");
if (matchQuery !== match) setMatch(matchQuery || "");
if (topNQuery !== topN) setTopN(topNQuery);
if (focusLabelQuery !== focusLabel) setFocusLabel(focusLabelQuery || "");
}, [searchParams]);
return <div
className={classNames({
"vm-cardinality-configurator": true,
"vm-cardinality-configurator_mobile": isMobile,
"vm-block": true,
"vm-block_mobile": isMobile,
})}
>
<div className="vm-cardinality-configurator-controls">
<div className="vm-cardinality-configurator-controls__query">
<QueryEditor
value={query}
autocomplete={autocomplete}
options={queryOptions}
error={error}
onArrowUp={handleArrowUp}
onArrowDown={handleArrowDown}
onEnter={onRunQuery}
onChange={onSetQuery}
label={"Time series selector"}
/>
</div>
<div className="vm-cardinality-configurator-controls__item">
<TextField
label="Number of entries per table"
type="number"
value={topN}
error={errorTopN}
onChange={onTopNChange}
label="Time series selector"
type="string"
value={match}
onChange={setMatch}
onEnter={handleRunQuery}
/>
</div>
<div className="vm-cardinality-configurator-controls__item">
@ -98,41 +78,36 @@ const CardinalityConfigurator: FC<CardinalityConfiguratorProps> = ({
label="Focus label"
type="text"
value={focusLabel || ""}
onChange={onFocusLabelChange}
onChange={setFocusLabel}
onEnter={handleRunQuery}
endIcon={(
<Tooltip
title={(
<div>
<p>To identify values with the highest number of series for the selected label.</p>
<p>Adds a table showing the series with the highest number of series.</p>
</div>
)}
>
<InfoIcon/>
<QuestionIcon/>
</Tooltip>
)}
/>
</div>
</div>
<div className="vm-cardinality-configurator-additional">
<Switch
label={"Autocomplete"}
value={autocomplete}
onChange={onChangeAutocomplete}
/>
</div>
<div
className={classNames({
"vm-cardinality-configurator-bottom": true,
"vm-cardinality-configurator-bottom_mobile": isMobile,
})}
>
<div className="vm-cardinality-configurator-bottom__info">
Analyzed <b>{totalSeries}</b> series with <b>{totalLabelValuePairs}</b> &quot;label=value&quot; pairs
at <b>{date}</b>{match && <span> for series selector <b>{match}</b></span>}.
Show top {topN} entries per table.
<div className="vm-cardinality-configurator-controls__item vm-cardinality-configurator-controls__item_limit">
<TextField
label="Limit entries"
type="number"
value={topN}
error={errorTopN}
onChange={handleTopNChange}
onEnter={handleRunQuery}
/>
</div>
<div className="vm-cardinality-configurator-bottom__docs">
</div>
<div className="vm-cardinality-configurator-bottom">
<CardinalityTotals {...props}/>
<div className="vm-cardinality-configurator-bottom-helpful">
<a
className="vm-link vm-link_with-icon"
target="_blank"
@ -140,25 +115,33 @@ const CardinalityConfigurator: FC<CardinalityConfiguratorProps> = ({
rel="help noreferrer"
>
<WikiIcon/>
Documentation
</a>
<a
className="vm-link vm-link_with-icon"
target="_blank"
href="https://victoriametrics.com/blog/cardinality-explorer/"
rel="help noreferrer"
>
<QuestionIcon/>
Example of using
Documentation
</a>
</div>
<Button
startIcon={<PlayIcon/>}
onClick={onRunQuery}
fullWidth
>
Execute Query
</Button>
<div className="vm-cardinality-configurator-bottom__execute">
<Tooltip title={showTips ? "Hide tips" : "Show tips"}>
<Button
variant="text"
color={showTips ? "warning" : "gray"}
startIcon={<TipIcon/>}
onClick={handleToggleTips}
/>
</Tooltip>
<Button
variant="text"
startIcon={<RestartIcon/>}
onClick={handleResetQuery}
>
Reset
</Button>
<Button
startIcon={<PlayIcon/>}
onClick={handleRunQuery}
>
Execute Query
</Button>
</div>
</div>
</div>;
};

View file

@ -9,58 +9,65 @@
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0 $padding-medium;
gap: $padding-small $padding-medium;
&__query {
flex-grow: 8;
flex-grow: 10;
}
&__item {
flex-grow: 1;
}
}
flex-grow: 2;
&-additional {
display: flex;
align-items: center;
margin-bottom: $padding-small;
&_limit {
flex-grow: 1;
}
svg {
color: $color-text-disabled
}
}
}
&-bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: $padding-global;
width: 100%;
&__docs {
&-helpful {
display: flex;
align-items: center;
gap: $padding-global;
justify-content: flex-end;
flex-wrap: wrap;
gap: $padding-small $padding-global;
a {
color: $color-text-secondary;
}
}
&_mobile &__docs {
justify-content: space-between;
&__execute {
display: flex;
align-items: center;
gap: $padding-small;
}
}
&__info {
&_mobile &-bottom {
justify-content: center;
&-helpful {
flex-grow: 1;
font-size: $font-size;
justify-content: center;
}
a {
color: $color-text-secondary;
}
&__execute {
width: 100%;
button {
margin: 0 0 0 auto;
}
&_mobile {
display: grid;
grid-template-columns: 1fr;
button {
margin: 0;
button:nth-child(3) {
width: 100%;
}
}
}

View file

@ -0,0 +1,124 @@
import { TipIcon } from "../../../components/Main/Icons";
import React, { FC } from "preact/compat";
import { ReactNode } from "react";
import "./style.scss";
const Link: FC<{ href: string, children: ReactNode, target?: string }> = ({ href, children, target }) => (
<a
href={href}
className="vm-link vm-link_colored"
target={target}
>
{children}
</a>
);
const TipCard: FC<{ title?: string, children: ReactNode }> = ({ title, children }) => (
<div className="vm-cardinality-tip">
<div className="vm-cardinality-tip-header">
<div className="vm-cardinality-tip-header__tip-icon"><TipIcon/></div>
<h4 className="vm-cardinality-tip-header__title">{title || "Tips"}</h4>
</div>
<p className="vm-cardinality-tip__description">
{children}
</p>
</div>
);
export const TipDocumentation: FC = () => (
<TipCard title="Cardinality explorer">
<h6>Helpful for analyzing VictoriaMetrics TSDB data</h6>
<ul>
<li>
<Link href="https://docs.victoriametrics.com/#cardinality-explorer">
Cardinality explorer documentation
</Link>
</li>
<li>
See the <Link href="https://victoriametrics.com/blog/cardinality-explorer/">
example of using</Link> the cardinality explorer
</li>
</ul>
</TipCard>
);
export const TipHighNumberOfSeries: FC = () => (
<TipCard title="Metrics with a high number of series">
<ul>
<li>
Identify and eliminate labels with frequently changed values to reduce their&nbsp;
<Link
href='https://docs.victoriametrics.com/FAQ.html#what-is-high-cardinality'
target={"_blank"}
>cardinality</Link>&nbsp;and&nbsp;
<Link
href='https://docs.victoriametrics.com/FAQ.html#what-is-high-churn-rate'
target={"_blank"}
>high churn rate</Link>
</li>
<li>
Find unused time series and&nbsp;
<Link
href='https://docs.victoriametrics.com/relabeling.html'
target={"_blank"}
>drop entire metrics</Link>
</li>
<li>
Aggregate time series before they got ingested into the database via&nbsp;
<Link
href='https://docs.victoriametrics.com/stream-aggregation.html'
target={"_blank"}
>streaming aggregation</Link>
</li>
</ul>
</TipCard>
);
export const TipHighNumberOfValues: FC = () => (
<TipCard title="Labels with a high number of unique values">
<ul>
<li>Decrease the number of unique label values to reduce cardinality</li>
<li>Drop the label entirely via&nbsp;
<Link
href='https://docs.victoriametrics.com/relabeling.html'
target={"_blank"}
>relabeling</Link></li>
<li>For volatile label values (such as URL path, user session, etc.)
consider printing them to the log file instead of adding to time series</li>
</ul>
</TipCard>
);
export const TipCardinalityOfSingle: FC = () => (
<TipCard title="Dashboard of a single metric">
<p>This dashboard helps to understand the cardinality of a single metric.</p>
<p>
Each time series is a unique combination of key-value label pairs.
Therefore a label key with many values can create a lot of time series for a particular metric.
If youre trying to decrease the cardinality of a metric,
start by looking at the labels with the highest number of values.
</p>
<p>Use the series selector at the top of the page to apply additional filters.</p>
</TipCard>
);
export const TipCardinalityOfLabel: FC = () => (
<TipCard title="Dashboard of a label">
<p>
This dashboard helps you understand the count of time series per label.
</p>
<p>
Use the selector at the top of the page to pick a label name youd like to inspect.
For the selected label name, youll see the label values that have the highest number of series associated with
them.
So if youve chosen `instance` as your label name, you may see that `657` time series have value
host-1 attached to them and `580` time series have value `host-2` attached to them.
</p>
<p>
This can be helpful in allowing you to determine where the bulk of your time series are coming from.
If the label instance=host-1 was applied to 657 series and the label instance=host-2
was only applied to 580 series, youd know, for example, that host-01 was responsible for sending
the majority of the time series.
</p>
</TipCard>
);

View file

@ -0,0 +1,87 @@
@use "src/styles/variables" as *;
.vm-cardinality-tip {
display: grid;
grid-template-rows: auto 1fr;
background-color: $color-background-block;
border-radius: $border-radius-medium;
box-shadow: $box-shadow;
overflow: hidden;
color: $color-text-secondary;
flex-grow: 1;
width: 300px;
&-header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: $padding-small $padding-global;
border-bottom: $border-divider;
gap: 4px;
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: $color-warning;
opacity: 0.1;
pointer-events: none
}
&__tip-icon {
width: 12px;
display: flex;
align-items: center;
justify-content: center;
color: $color-warning;
}
&__title {
font-weight: bold;
text-align: center;
color: $color-text;
}
&__tooltip {
max-width: 280px;
white-space: normal;
padding: $padding-small;
line-height: 130%;
font-size: $font-size;
}
}
&__description {
padding: $padding-small $padding-global;
line-height: 130%;
p {
margin-bottom: $padding-small;
&:last-child {
margin-bottom: 0;
}
}
h5 {
font-size: $font-size-medium;
margin-bottom: $padding-small;
}
h6 {
margin-bottom: $padding-small;
}
ul, ol {
list-style-position: inside;
li {
margin-bottom: calc($padding-small/2);
}
}
}
}

View file

@ -0,0 +1,80 @@
import React, { FC } from "preact/compat";
import { InfoIcon } from "../../../components/Main/Icons";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import { TopHeapEntry } from "../types";
import { useSearchParams } from "react-router-dom";
import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import "./style.scss";
export interface CardinalityTotalsProps {
totalSeries: number;
totalSeriesAll: number;
totalLabelValuePairs: number;
seriesCountByMetricName: TopHeapEntry[];
}
const CardinalityTotals: FC<CardinalityTotalsProps> = ({
totalSeries,
totalSeriesAll,
seriesCountByMetricName
}) => {
const { isMobile } = useDeviceDetect();
const [searchParams] = useSearchParams();
const match = searchParams.get("match");
const focusLabel = searchParams.get("focusLabel");
const isMetric = /__name__/.test(match || "");
const progress = seriesCountByMetricName[0]?.value / totalSeriesAll * 100;
const totals = [
{
title: "Total series",
value: totalSeries.toLocaleString("en-US"),
display: !focusLabel,
info: `The total number of active time series.
A time series is uniquely identified by its name plus a set of its labels.
For example, temperature{city="NY",country="US"} and temperature{city="SF",country="US"}
are two distinct series, since they differ by the city label.`
},
{
title: "Percentage from total",
value: isNaN(progress) ? "-" : `${progress.toFixed(2)}%`,
display: isMetric,
info: "The share of these series in the total number of time series."
}
].filter(t => t.display);
if (!totals.length) {
return null;
}
return (
<div
className={classNames({
"vm-cardinality-totals": true,
"vm-cardinality-totals_mobile": isMobile
})}
>
{totals.map(({ title, value, info }) => (
<div
className="vm-cardinality-totals-card"
key={title}
>
<div className="vm-cardinality-totals-card-header">
{info && (
<Tooltip title={<p className="vm-cardinality-totals-card-header__tooltip">{info}</p>}>
<div className="vm-cardinality-totals-card-header__info-icon"><InfoIcon/></div>
</Tooltip>
)}
<h4 className="vm-cardinality-totals-card-header__title">{title}</h4>
</div>
<span className="vm-cardinality-totals-card__value">{value}</span>
</div>
))}
</div>
);
};
export default CardinalityTotals;

View file

@ -0,0 +1,60 @@
@use "src/styles/variables" as *;
.vm-cardinality-totals {
display: inline-flex;
flex-wrap: wrap;
align-content: flex-start;
justify-content: flex-start;
gap: $padding-global;
flex-grow: 1;
&_mobile {
gap: $padding-small;
justify-content: center;
}
&-card {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
&-header {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
&__info-icon {
width: 12px;
display: flex;
align-items: center;
justify-content: center;
color: $color-primary;
}
&__title {
font-weight: bold;
color: $color-text;
&:after {
content: ':';
}
}
&__tooltip {
max-width: 280px;
white-space: normal;
padding: $padding-small;
line-height: 130%;
font-size: $font-size;
}
}
&__value {
font-weight: bold;
color: $color-primary;
font-size: $font-size-medium;
}
}
}

View file

@ -1,43 +1,40 @@
import React, { FC } from "react";
import EnhancedTable from "../Table/Table";
import TableCells from "../Table/TableCells/TableCells";
import BarChart from "../../../components/Chart/BarChart/BarChart";
import { barOptions } from "../../../components/Chart/BarChart/consts";
import { Data, HeadCell } from "../Table/types";
import { MutableRef } from "preact/hooks";
import Tabs from "../../../components/Main/Tabs/Tabs";
import { useMemo } from "preact/compat";
import { ChartIcon, TableIcon } from "../../../components/Main/Icons";
import { useMemo, useState } from "preact/compat";
import { ChartIcon, InfoIcon, TableIcon } from "../../../components/Main/Icons";
import "./style.scss";
import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import SimpleBarChart from "../../../components/Chart/SimpleBarChart/SimpleBarChart";
interface MetricsProperties {
rows: Data[];
activeTab: number;
onChange: (newValue: string, tabId: string) => void;
onActionClick: (name: string) => void;
tabs: string[];
chartContainer: MutableRef<HTMLDivElement> | undefined;
totalSeries: number,
tabId: string;
sectionTitle: string;
tip?: string;
tableHeaderCells: HeadCell[];
}
const MetricsContent: FC<MetricsProperties> = ({
rows,
activeTab,
onChange,
tabs: tabsProps,
tabs: tabsProps = [],
chartContainer,
totalSeries,
tabId,
onActionClick,
sectionTitle,
tip,
tableHeaderCells,
}) => {
const { isMobile } = useDeviceDetect();
const [activeTab, setActiveTab] = useState("table");
const tableCells = (row: Data) => (
<TableCells
@ -48,15 +45,11 @@ const MetricsContent: FC<MetricsProperties> = ({
);
const tabs = useMemo(() => tabsProps.map((t, i) => ({
value: String(i),
value: t,
label: t,
icon: i === 0 ? <TableIcon /> : <ChartIcon />
})), [tabsProps]);
const handleChangeTab = (newValue: string) => {
onChange(newValue, tabId);
};
return (
<div
className={classNames({
@ -69,47 +62,53 @@ const MetricsContent: FC<MetricsProperties> = ({
<div className="vm-metrics-content-header vm-section-header">
<h5
className={classNames({
"vm-metrics-content-header__title": true,
"vm-section-header__title": true,
"vm-section-header__title_mobile": isMobile,
})}
>{sectionTitle}</h5>
>
{!isMobile && tip && (
<Tooltip
title={<p
dangerouslySetInnerHTML={{ __html: tip }}
className="vm-metrics-content-header__tip"
/>}
>
<div className="vm-metrics-content-header__tip-icon"><InfoIcon/></div>
</Tooltip>
)}
{sectionTitle}
</h5>
<div className="vm-section-header__tabs">
<Tabs
activeItem={String(activeTab)}
activeItem={activeTab}
items={tabs}
onChange={handleChangeTab}
onChange={setActiveTab}
/>
</div>
</div>
<div
ref={chartContainer}
className={classNames({
"vm-metrics-content__table": true,
"vm-metrics-content__table_mobile": isMobile
})}
>
{activeTab === 0 && (
{activeTab === "table" && (
<div
ref={chartContainer}
className={classNames({
"vm-metrics-content__table": true,
"vm-metrics-content__table_mobile": isMobile
})}
>
<EnhancedTable
rows={rows}
headerCells={tableHeaderCells}
defaultSortColumn={"value"}
tableCells={tableCells}
/>
)}
{activeTab === 1 && (
<BarChart
data={[
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
rows.map((v) => v.name),
rows.map((v) => v.value),
rows.map((_, i) => i % 12 == 0 ? 1 : i % 10 == 0 ? 2 : 0),
]}
container={chartContainer?.current || null}
configs={barOptions}
/>
)}
</div>
</div>
)}
{activeTab === "graph" && (
<div className="vm-metrics-content__chart">
<SimpleBarChart data={rows.map(({ name, value }) => ({ name, value }))}/>
</div>
)}
</div>
);
};

View file

@ -3,6 +3,33 @@
.vm-metrics-content {
&-header {
margin: -$padding-medium 0-$padding-medium 0;
&__title {
display: flex;
align-items: center;
justify-content: flex-start;
}
&__tip {
max-width: 300px;
white-space: normal;
padding: $padding-small;
line-height: 130%;
font-size: $font-size;
p {
margin-bottom: $padding-small;
}
}
&__tip-icon {
width: 12px;
display: flex;
align-items: center;
justify-content: center;
color: $color-primary;
margin-right: 4px;
}
}
&_mobile &-header {
@ -30,4 +57,8 @@
&_mobile &__table {
width: calc(100vw - ($padding-global * 2) - var(--scrollbar-width));
}
&__chart {
padding-top: $padding-medium;
}
}

View file

@ -1,9 +1,8 @@
import React, { FC, useState } from "preact/compat";
import { ChangeEvent, MouseEvent } from "react";
import { MouseEvent } from "react";
import { Data, Order, TableProps, } from "./types";
import { EnhancedTableHead } from "./TableHead";
import { getComparator, stableSort } from "./helpers";
import classNames from "classnames";
const EnhancedTable: FC<TableProps> = ({
rows,
@ -14,7 +13,6 @@ const EnhancedTable: FC<TableProps> = ({
const [order, setOrder] = useState<Order>("desc");
const [orderBy, setOrderBy] = useState<keyof Data>(defaultSortColumn);
const [selected, setSelected] = useState<readonly string[]>([]);
const handleRequestSort = (
event: MouseEvent<unknown>,
@ -25,45 +23,13 @@ const EnhancedTable: FC<TableProps> = ({
setOrderBy(property);
};
const handleSelectAllClick = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelecteds = rows.map((n) => n.name) as string[];
setSelected(newSelecteds);
return;
}
setSelected([]);
};
const handleClick = (name: string) => () => {
const selectedIndex = selected.indexOf(name);
let newSelected: readonly string[] = [];
if (selectedIndex === -1) {
newSelected = newSelected.concat(selected, name);
} else if (selectedIndex === 0) {
newSelected = newSelected.concat(selected.slice(1));
} else if (selectedIndex === selected.length - 1) {
newSelected = newSelected.concat(selected.slice(0, -1));
} else if (selectedIndex > 0) {
newSelected = newSelected.concat(
selected.slice(0, selectedIndex),
selected.slice(selectedIndex + 1),
);
}
setSelected(newSelected);
};
const isSelected = (name: string) => selected.indexOf(name) !== -1;
const sortedData = stableSort(rows, getComparator(order, orderBy));
return (
<table className="vm-table">
<EnhancedTableHead
numSelected={selected.length}
order={order}
orderBy={orderBy}
onSelectAllClick={handleSelectAllClick}
onRequestSort={handleRequestSort}
rowCount={rows.length}
headerCells={headerCells}
@ -71,12 +37,8 @@ const EnhancedTable: FC<TableProps> = ({
<tbody className="vm-table-header">
{sortedData.map((row) => (
<tr
className={classNames({
"vm-table__row": true,
"vm-table__row_selected": isSelected(row.name)
})}
className="vm-table__row"
key={row.name}
onClick={handleClick(row.name)}
>
{tableCells(row)}
</tr>

View file

@ -23,7 +23,12 @@ const TableCells: FC<CardinalityTableCells> = ({ row, totalSeries, onActionClick
className="vm-table-cell"
key={row.name}
>
{row.name}
<span
className="vm-link vm-link_colored"
onClick={handleActionClick}
>
{row.name}
</span>
</td>
<td
className="vm-table-cell"

View file

@ -2,7 +2,8 @@ import { MouseEvent } from "react";
import React from "preact/compat";
import { Data, EnhancedHeaderTableProps } from "./types";
import classNames from "classnames";
import { ArrowDropDownIcon } from "../../../components/Main/Icons";
import { ArrowDropDownIcon, InfoIcon } from "../../../components/Main/Icons";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
export function EnhancedTableHead(props: EnhancedHeaderTableProps) {
const { order, orderBy, onRequestSort, headerCells } = props;
@ -24,7 +25,13 @@ export function EnhancedTableHead(props: EnhancedHeaderTableProps) {
onClick={createSortHandler(headCell.id as keyof Data)}
>
<div className="vm-table-cell__content">
{headCell.label}
{
headCell.info ?
<Tooltip title={headCell.info}>
<div className="vm-metrics-content-header__tip-icon"><InfoIcon /></div>
{headCell.label}
</Tooltip>: <>{headCell.label}</>
}
{headCell.id !== "action" && headCell.id !== "percentage" && (
<div
className={classNames({

View file

@ -1,16 +1,15 @@
import { ChangeEvent, MouseEvent, ReactNode } from "react";
import { MouseEvent, ReactNode } from "react";
export type Order = "asc" | "desc";
export interface HeadCell {
id: string;
label: string | ReactNode;
info?: string;
}
export interface EnhancedHeaderTableProps {
numSelected: number;
onRequestSort: (event: MouseEvent<unknown>, property: keyof Data) => void;
onSelectAllClick: (event: ChangeEvent<HTMLInputElement>) => void;
order: Order;
orderBy: string;
rowCount: number;

View file

@ -1,11 +1,10 @@
import { Containers, DefaultActiveTab, Tabs, TSDBStatus } from "./types";
import { Containers, Tabs, TSDBStatus } from "./types";
import { useRef } from "preact/compat";
import { HeadCell } from "./Table/types";
interface AppState {
tabs: Tabs;
containerRefs: Containers<HTMLDivElement>;
defaultActiveTab: DefaultActiveTab,
}
export default class AppConfigurator {
@ -15,6 +14,7 @@ export default class AppConfigurator {
constructor() {
this.tsdbStatus = this.defaultTSDBStatus;
this.tabsNames = ["table", "graph"];
this.getDefaultState = this.getDefaultState.bind(this);
}
set tsdbStatusData(tsdbStatus: TSDBStatus) {
@ -29,6 +29,7 @@ export default class AppConfigurator {
return {
totalSeries: 0,
totalLabelValuePairs: 0,
totalSeriesByAll: 0,
seriesCountByMetricName: [],
seriesCountByLabelName: [],
seriesCountByFocusLabelValue: [],
@ -37,22 +38,26 @@ export default class AppConfigurator {
};
}
keys(focusLabel: string | null): string[] {
keys(match?: string | null, focusLabel?: string | null): string[] {
const isMetric = match && /__name__=".+"/.test(match);
const isLabel = match && /{.+=".+"}/g.test(match);
const isMetricWithLabel = match && /__name__=".+", .+!=""/g.test(match);
let keys: string[] = [];
if (focusLabel) {
if (focusLabel || isMetricWithLabel) {
keys = keys.concat("seriesCountByFocusLabelValue");
} else if (isMetric) {
keys = keys.concat("labelValueCountByLabelName");
} else if (isLabel) {
keys = keys.concat("seriesCountByMetricName", "seriesCountByLabelName");
} else {
keys = keys.concat("seriesCountByMetricName", "seriesCountByLabelName", "seriesCountByLabelValuePair");
}
keys = keys.concat(
"seriesCountByMetricName",
"seriesCountByLabelName",
"seriesCountByLabelValuePair",
"labelValueCountByLabelName",
);
return keys;
}
get defaultState(): AppState {
return this.keys("job").reduce((acc, cur) => {
getDefaultState(match?: string | null, label?: string | null): AppState {
return this.keys(match, label).reduce((acc, cur) => {
return {
...acc,
tabs: {
@ -63,15 +68,10 @@ export default class AppConfigurator {
...acc.containerRefs,
[cur]: useRef<HTMLDivElement>(null),
},
defaultActiveTab: {
...acc.defaultActiveTab,
[cur]: 0,
},
};
}, {
tabs: {} as Tabs,
containerRefs: {} as Containers<HTMLDivElement>,
defaultActiveTab: {} as DefaultActiveTab,
} as AppState);
}
@ -85,6 +85,53 @@ export default class AppConfigurator {
};
}
get sectionsTips(): Record<string, string> {
return {
seriesCountByMetricName: `
<p>
This table returns a list of metrics with the highest cardinality.
The cardinality of a metric is the number of time series associated with that metric,
where each time series is defined as a unique combination of key-value label pairs.
</p>
<p>
When looking to reduce the number of active series in your data source,
you can start by inspecting individual metrics with high cardinality
(i.e. that have lots of active time series associated with them),
since that single metric contributes a large fraction of the series that make up your total series count.
</p>`,
seriesCountByLabelName: `
<p>
This table returns a list of the labels with the highest number of series.
</p>
<p>
Use this table to identify labels that are storing dimensions with high cardinality
(many different label values).
</p>
<p>
It is recommended to choose labels such that they have a finite set of values,
since every unique combination of key-value label pairs creates a new time series
and therefore can dramatically increase the number of time series in your system.
</p>`,
seriesCountByFocusLabelValue: `
<p>
This table returns a list of unique label values per selected label.
</p>
<p>
Use this table to identify label values that are storing per each selected series.
</p>`,
labelValueCountByLabelName: "",
seriesCountByLabelValuePair: `
<p>
This table returns a list of the label values pairs with the highest number of series.
</p>
<p>
Use this table to identify unique label values pairs. This helps to identify same labels
is applied to count timeseries in your system, since every unique combination of key-value label pairs
creates a new time series and therefore can dramatically increase the number of time series in your system
</p>`,
};
}
get tablesHeaders(): Record<string, HeadCell[]> {
return {
seriesCountByMetricName: METRIC_NAMES_HEADERS,
@ -114,11 +161,12 @@ const METRIC_NAMES_HEADERS = [
},
{
id: "percentage",
label: "Percent of series",
label: "Share in total",
info: "Shows the share of a metric to the total number of series"
},
{
id: "action",
label: "Action",
label: "",
}
] as HeadCell[];
@ -133,11 +181,12 @@ const LABEL_NAMES_HEADERS = [
},
{
id: "percentage",
label: "Percent of series",
label: "Share in total",
info: "Shows the share of the label to the total number of series"
},
{
id: "action",
label: "Action",
label: "",
}
] as HeadCell[];
@ -152,12 +201,12 @@ const FOCUS_LABEL_VALUES_HEADERS = [
},
{
id: "percentage",
label: "Percent of series",
label: "Share in total",
},
{
disablePadding: false,
id: "action",
label: "Action",
label: "",
numeric: false,
}
] as HeadCell[];
@ -173,11 +222,12 @@ export const LABEL_VALUE_PAIRS_HEADERS = [
},
{
id: "percentage",
label: "Percent of series",
label: "Share in total",
info: "Shows the share of the label value pair to the total number of series"
},
{
id: "action",
label: "Action",
label: "",
}
] as HeadCell[];
@ -192,6 +242,6 @@ export const LABEL_NAMES_WITH_UNIQUE_VALUES_HEADERS = [
},
{
id: "action",
label: "Action",
label: "",
}
] as HeadCell[];

View file

@ -1,20 +1,24 @@
import { QueryUpdater } from "./types";
export const queryUpdater: QueryUpdater = {
seriesCountByMetricName: (focusLabel: string | null, query: string): string => {
seriesCountByMetricName: ({ query }): string => {
return getSeriesSelector("__name__", query);
},
seriesCountByLabelName: (focusLabel: string | null, query: string): string => `{${query}!=""}`,
seriesCountByFocusLabelValue: (focusLabel: string | null, query: string): string => {
seriesCountByLabelName: ({ query }): string => {
return `{${query}!=""}`;
},
seriesCountByFocusLabelValue: ({ query, focusLabel }): string => {
return getSeriesSelector(focusLabel, query);
},
seriesCountByLabelValuePair: (focusLabel: string | null, query: string): string => {
seriesCountByLabelValuePair: ({ query }): string => {
const a = query.split("=");
const label = a[0];
const value = a.slice(1).join("=");
return getSeriesSelector(label, value);
},
labelValueCountByLabelName: (focusLabel: string | null, query: string): string => `{${query}!=""}`,
labelValueCountByLabelName: ({ query, match }): string => {
return `${match.replace("}", "")}, ${query}!=""}`;
},
};
const getSeriesSelector = (label: string | null, value: string): string => {

View file

@ -3,8 +3,10 @@ import { useAppState } from "../../../state/common/StateContext";
import { useEffect, useState } from "preact/compat";
import { CardinalityRequestsParams, getCardinalityInfo } from "../../../api/tsdb";
import { TSDBStatus } from "../types";
import { useCardinalityState } from "../../../state/cardinality/CardinalityStateContext";
import AppConfigurator from "../appConfigurator";
import { useSearchParams } from "react-router-dom";
import dayjs from "dayjs";
import { DATE_FORMAT } from "../../../constants/date";
export const useFetchQuery = (): {
fetchUrl?: string[],
@ -13,33 +15,43 @@ export const useFetchQuery = (): {
appConfigurator: AppConfigurator,
} => {
const appConfigurator = new AppConfigurator();
const { topN, extraLabel, match, date, runQuery, focusLabel } = useCardinalityState();
const [searchParams] = useSearchParams();
const match = searchParams.get("match");
const focusLabel = searchParams.get("focusLabel");
const topN = +(searchParams.get("topN") || 10);
const date = searchParams.get("date") || dayjs().tz().format(DATE_FORMAT);
const { serverUrl } = useAppState();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>();
const [tsdbStatus, setTSDBStatus] = useState<TSDBStatus>(appConfigurator.defaultTSDBStatus);
useEffect(() => {
if (error) {
setTSDBStatus(appConfigurator.defaultTSDBStatus);
setIsLoading(false);
}
}, [error]);
const fetchCardinalityInfo = async (requestParams: CardinalityRequestsParams) => {
if (!serverUrl) return;
setError("");
setIsLoading(true);
setTSDBStatus(appConfigurator.defaultTSDBStatus);
const defaultParams = { date: requestParams.date, topN: 0, match: "", focusLabel: "" } as CardinalityRequestsParams;
const url = getCardinalityInfo(serverUrl, requestParams);
const urlDefault = getCardinalityInfo(serverUrl, defaultParams);
try {
const response = await fetch(url);
const resp = await response.json();
const responseTotal = await fetch(urlDefault);
const respTotals = await responseTotal.json();
if (response.ok) {
const { data } = resp;
setTSDBStatus({ ...data });
const { totalSeries } = respTotals.data;
const result = { ...data } as TSDBStatus;
result.totalSeriesByAll = totalSeries;
const name = match?.replace(/[{}"]/g, "");
result.seriesCountByLabelValuePair = result.seriesCountByLabelValuePair.filter(s => s.name !== name);
setTSDBStatus(result);
setIsLoading(false);
} else {
setError(resp.error);
@ -54,8 +66,15 @@ export const useFetchQuery = (): {
useEffect(() => {
fetchCardinalityInfo({ topN, extraLabel, match, date, focusLabel });
}, [serverUrl, runQuery, date]);
fetchCardinalityInfo({ topN, match, date, focusLabel });
}, [serverUrl, match, focusLabel, topN, date]);
useEffect(() => {
if (error) {
setTSDBStatus(appConfigurator.defaultTSDBStatus);
setIsLoading(false);
}
}, [error]);
appConfigurator.tsdbStatusData = tsdbStatus;
return { isLoading, appConfigurator: appConfigurator, error };

View file

@ -1,24 +0,0 @@
import { useEffect } from "react";
import { useCardinalityState } from "../../../state/cardinality/CardinalityStateContext";
import { compactObject } from "../../../utils/object";
import { useSearchParams } from "react-router-dom";
export const useSetQueryParams = () => {
const { topN, match, date, focusLabel, extraLabel } = useCardinalityState();
const [, setSearchParams] = useSearchParams();
const setSearchParamsFromState = () => {
const params = compactObject({
topN,
date,
match,
extraLabel,
focusLabel,
});
setSearchParams(params as Record<string, string>);
};
useEffect(setSearchParamsFromState, [topN, match, date, focusLabel, extraLabel]);
useEffect(setSearchParamsFromState, []);
};

View file

@ -1,75 +1,48 @@
import React, { FC, useState } from "react";
import React, { FC } from "react";
import { useFetchQuery } from "./hooks/useCardinalityFetch";
import { queryUpdater } from "./helpers";
import { Data } from "./Table/types";
import CardinalityConfigurator from "./CardinalityConfigurator/CardinalityConfigurator";
import Spinner from "../../components/Main/Spinner/Spinner";
import { useCardinalityDispatch, useCardinalityState } from "../../state/cardinality/CardinalityStateContext";
import MetricsContent from "./MetricsContent/MetricsContent";
import { DefaultActiveTab, Tabs, TSDBStatus, Containers } from "./types";
import { useSetQueryParams } from "./hooks/useSetQueryParams";
import { Tabs, TSDBStatus, Containers } from "./types";
import Alert from "../../components/Main/Alert/Alert";
import "./style.scss";
import classNames from "classnames";
import useDeviceDetect from "../../hooks/useDeviceDetect";
import { useSearchParams } from "react-router-dom";
import {
TipCardinalityOfLabel,
TipCardinalityOfSingle,
TipHighNumberOfSeries,
TipHighNumberOfValues
} from "./CardinalityTips";
const spinnerMessage = `Please wait while cardinality stats is calculated.
This may take some time if the db contains big number of time series.`;
const Index: FC = () => {
const { isMobile } = useDeviceDetect();
const { topN, match, date, focusLabel } = useCardinalityState();
const cardinalityDispatch = useCardinalityDispatch();
useSetQueryParams();
const configError = "";
const [query, setQuery] = useState(match || "");
const [queryHistoryIndex, setQueryHistoryIndex] = useState(0);
const [queryHistory, setQueryHistory] = useState<string[]>([]);
const onRunQuery = () => {
setQueryHistory(prev => [...prev, query]);
setQueryHistoryIndex(prev => prev + 1);
cardinalityDispatch({ type: "SET_MATCH", payload: query });
cardinalityDispatch({ type: "RUN_QUERY" });
};
const onSetHistory = (step: number) => {
const newIndexHistory = queryHistoryIndex + step;
if (newIndexHistory < 0 || newIndexHistory >= queryHistory.length) return;
setQueryHistoryIndex(newIndexHistory);
setQuery(queryHistory[newIndexHistory]);
};
const onTopNChange = (value: string) => {
cardinalityDispatch({ type: "SET_TOP_N", payload: +value });
};
const onFocusLabelChange = (value: string) => {
cardinalityDispatch({ type: "SET_FOCUS_LABEL", payload: value });
};
const [searchParams, setSearchParams] = useSearchParams();
const showTips = searchParams.get("tips") || "";
const match = searchParams.get("match") || "";
const focusLabel = searchParams.get("focusLabel") || "";
const { isLoading, appConfigurator, error } = useFetchQuery();
const [stateTabs, setTab] = useState(appConfigurator.defaultState.defaultActiveTab);
const { tsdbStatusData, defaultState, tablesHeaders } = appConfigurator;
const handleTabChange = (newValue: string, tabId: string) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
setTab({ ...stateTabs, [tabId]: +newValue });
};
const { tsdbStatusData, getDefaultState, tablesHeaders, sectionsTips } = appConfigurator;
const defaultState = getDefaultState(match, focusLabel);
const handleFilterClick = (key: string) => (name: string) => {
const query = queryUpdater[key](focusLabel, name);
setQuery(query);
setQueryHistory(prev => [...prev, query]);
setQueryHistoryIndex(prev => prev + 1);
cardinalityDispatch({ type: "SET_MATCH", payload: query });
let newFocusLabel = "";
const handleFilterClick = (key: string) => (query: string) => {
const value = queryUpdater[key]({ query, focusLabel, match });
searchParams.set("match", value);
if (key === "labelValueCountByLabelName" || key == "seriesCountByLabelName") {
newFocusLabel = name;
searchParams.set("focusLabel", query);
}
cardinalityDispatch({ type: "SET_FOCUS_LABEL", payload: newFocusLabel });
cardinalityDispatch({ type: "RUN_QUERY" });
if (key == "seriesCountByFocusLabelValue") {
searchParams.set("focusLabel", "");
}
setSearchParams(searchParams);
};
return (
@ -81,35 +54,33 @@ const Index: FC = () => {
>
{isLoading && <Spinner message={spinnerMessage}/>}
<CardinalityConfigurator
error={configError}
query={query}
topN={topN}
date={date}
match={match}
totalSeries={tsdbStatusData.totalSeries}
totalSeriesAll={tsdbStatusData.totalSeriesByAll}
totalLabelValuePairs={tsdbStatusData.totalLabelValuePairs}
focusLabel={focusLabel}
onRunQuery={onRunQuery}
onSetQuery={setQuery}
onSetHistory={onSetHistory}
onTopNChange={onTopNChange}
onFocusLabelChange={onFocusLabelChange}
seriesCountByMetricName={tsdbStatusData.seriesCountByMetricName}
/>
{showTips && (
<div className="vm-cardinality-panel-tips">
{!match && !focusLabel && <TipHighNumberOfSeries/>}
{match && !focusLabel && <TipCardinalityOfSingle/>}
{!match && !focusLabel && <TipHighNumberOfValues/>}
{focusLabel && <TipCardinalityOfLabel/>}
</div>
)}
{error && <Alert variant="error">{error}</Alert>}
{appConfigurator.keys(focusLabel).map((keyName) => (
{appConfigurator.keys(match, focusLabel).map((keyName) => (
<MetricsContent
key={keyName}
sectionTitle={appConfigurator.sectionsTitles(focusLabel)[keyName]}
activeTab={stateTabs[keyName as keyof DefaultActiveTab]}
tip={sectionsTips[keyName]}
rows={tsdbStatusData[keyName as keyof TSDBStatus] as unknown as Data[]}
onChange={handleTabChange}
onActionClick={handleFilterClick(keyName)}
tabs={defaultState.tabs[keyName as keyof Tabs]}
chartContainer={defaultState.containerRefs[keyName as keyof Containers<HTMLDivElement>]}
totalSeries={appConfigurator.totalSeries(keyName)}
tabId={keyName}
tableHeaderCells={tablesHeaders[keyName]}
/>
))}

View file

@ -5,7 +5,17 @@
align-items: flex-start;
gap: $padding-medium;
&_mobile {
&_mobile, &_mobile &-tips {
gap: $padding-small;
}
&-tips {
display: inline-flex;
flex-wrap: wrap;
align-content: flex-start;
justify-content: flex-start;
gap: $padding-medium;
flex-grow: 1;
width: 100%;
}
}

View file

@ -3,6 +3,7 @@ import { MutableRef } from "preact/hooks";
export interface TSDBStatus {
totalSeries: number;
totalLabelValuePairs: number;
totalSeriesByAll: number,
seriesCountByMetricName: TopHeapEntry[];
seriesCountByLabelName: TopHeapEntry[];
seriesCountByFocusLabelValue: TopHeapEntry[];
@ -12,11 +13,17 @@ export interface TSDBStatus {
export interface TopHeapEntry {
name: string;
count: number;
value: number;
}
interface QueryUpdaterArgs {
query: string;
focusLabel: string;
match: string;
}
export type QueryUpdater = {
[key: string]: (focusLabel: string | null, query: string) => string,
[key: string]: (args: QueryUpdaterArgs) => string,
}
export interface Tabs {
@ -34,11 +41,3 @@ export interface Containers<T> {
seriesCountByLabelValuePair: MutableRef<T>;
labelValueCountByLabelName: MutableRef<T>;
}
export interface DefaultActiveTab {
seriesCountByMetricName: number;
seriesCountByLabelName: number;
seriesCountByFocusLabelValue: number;
seriesCountByLabelValuePair: number;
labelValueCountByLabelName: number;
}

View file

@ -1,24 +0,0 @@
import React, { createContext, FC, useContext, useMemo, useReducer } from "preact/compat";
import { Action, CardinalityState, initialState, reducer } from "./reducer";
import { Dispatch } from "react";
type CardinalityStateContextType = { state: CardinalityState, dispatch: Dispatch<Action> };
export const CardinalityStateContext = createContext<CardinalityStateContextType>({} as CardinalityStateContextType);
export const useCardinalityState = (): CardinalityState => useContext(CardinalityStateContext).state;
export const useCardinalityDispatch = (): Dispatch<Action> => useContext(CardinalityStateContext).dispatch;
export const CardinalityStateProvider: FC = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const contextValue = useMemo(() => {
return { state, dispatch };
}, [state, dispatch]);
return <CardinalityStateContext.Provider value={contextValue}>
{children}
</CardinalityStateContext.Provider>;
};

View file

@ -1,67 +0,0 @@
import dayjs from "dayjs";
import { getQueryStringValue } from "../../utils/query-string";
import { DATE_FORMAT } from "../../constants/date";
export interface CardinalityState {
runQuery: number,
topN: number
date: string | null
match: string | null
extraLabel: string | null
focusLabel: string | null
}
export type Action =
| { type: "SET_TOP_N", payload: number }
| { type: "SET_DATE", payload: string | null }
| { type: "SET_MATCH", payload: string | null }
| { type: "SET_EXTRA_LABEL", payload: string | null }
| { type: "SET_FOCUS_LABEL", payload: string | null }
| { type: "RUN_QUERY" }
export const initialState: CardinalityState = {
runQuery: 0,
topN: getQueryStringValue("topN", 10) as number,
date: getQueryStringValue("date", dayjs().tz().format(DATE_FORMAT)) as string,
focusLabel: getQueryStringValue("focusLabel", "") as string,
match: getQueryStringValue("match", "") as string,
extraLabel: getQueryStringValue("extra_label", "") as string,
};
export function reducer(state: CardinalityState, action: Action): CardinalityState {
switch (action.type) {
case "SET_TOP_N":
return {
...state,
topN: action.payload
};
case "SET_DATE":
return {
...state,
date: action.payload
};
case "SET_MATCH":
return {
...state,
match: action.payload
};
case "SET_EXTRA_LABEL":
return {
...state,
extraLabel: action.payload
};
case "SET_FOCUS_LABEL":
return {
...state,
focusLabel: action.payload,
};
case "RUN_QUERY":
return {
...state,
runQuery: state.runQuery + 1
};
default:
throw new Error();
}
}