vmui/logs: add display top streams in the hits graph (#6647)

### Describe Your Changes

- Adds support for displaying the top 5 log streams in the hits graph,
grouping the remaining streams into an "other" label.
   #6545

- Adds options to customize the graph display with bar, line, stepped
line, and points views.

### Checklist

The following checks are **mandatory**:

- [x] My change adheres [VictoriaMetrics contributing
guidelines](https://docs.victoriametrics.com/contributing/).
This commit is contained in:
Yury Molodov 2024-08-06 16:28:44 +02:00 committed by GitHub
parent 2e16732fdb
commit 04c2232e45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 734 additions and 172 deletions

View file

@ -38,6 +38,7 @@ export interface Logs {
export interface LogHits { export interface LogHits {
timestamps: string[]; timestamps: string[];
values: number[]; values: number[];
total?: number;
fields: { fields: {
[key: string]: string; [key: string]: string;
}; };

View file

@ -1,32 +1,66 @@
import React, { FC, useRef, useState } from "preact/compat"; import React, { FC, useMemo, useRef, useState } from "preact/compat";
import "./style.scss"; import "./style.scss";
import "uplot/dist/uPlot.min.css"; import "uplot/dist/uPlot.min.css";
import useElementSize from "../../../hooks/useElementSize"; import useElementSize from "../../../hooks/useElementSize";
import uPlot, { AlignedData } from "uplot"; import uPlot, { AlignedData } from "uplot";
import { useEffect } from "react"; import { useEffect } from "react";
import useBarHitsOptions from "./hooks/useBarHitsOptions"; import useBarHitsOptions from "./hooks/useBarHitsOptions";
import TooltipBarHitsChart from "./TooltipBarHitsChart"; import BarHitsTooltip from "./BarHitsTooltip/BarHitsTooltip";
import { TimeParams } from "../../../types"; import { TimeParams } from "../../../types";
import usePlotScale from "../../../hooks/uplot/usePlotScale"; import usePlotScale from "../../../hooks/uplot/usePlotScale";
import useReadyChart from "../../../hooks/uplot/useReadyChart"; import useReadyChart from "../../../hooks/uplot/useReadyChart";
import useZoomChart from "../../../hooks/uplot/useZoomChart"; import useZoomChart from "../../../hooks/uplot/useZoomChart";
import classNames from "classnames"; import classNames from "classnames";
import { LogHits } from "../../../api/types";
import { addSeries, delSeries, setBand } from "../../../utils/uplot";
import { GraphOptions, GRAPH_STYLES } from "./types";
import BarHitsOptions from "./BarHitsOptions/BarHitsOptions";
import stack from "../../../utils/uplot/stack";
import BarHitsLegend from "./BarHitsLegend/BarHitsLegend";
interface Props { interface Props {
logHits: LogHits[];
data: AlignedData; data: AlignedData;
period: TimeParams; period: TimeParams;
setPeriod: ({ from, to }: { from: Date, to: Date }) => void; setPeriod: ({ from, to }: { from: Date, to: Date }) => void;
onApplyFilter: (value: string) => void;
} }
const BarHitsChart: FC<Props> = ({ logHits, data: _data, period, setPeriod, onApplyFilter }) => {
const BarHitsChart: FC<Props> = ({ data, period, setPeriod }) => {
const [containerRef, containerSize] = useElementSize(); const [containerRef, containerSize] = useElementSize();
const uPlotRef = useRef<HTMLDivElement>(null); const uPlotRef = useRef<HTMLDivElement>(null);
const [uPlotInst, setUPlotInst] = useState<uPlot>(); const [uPlotInst, setUPlotInst] = useState<uPlot>();
const [graphOptions, setGraphOptions] = useState<GraphOptions>({
graphStyle: GRAPH_STYLES.LINE_STEPPED,
stacked: false,
fill: false,
});
const { xRange, setPlotScale } = usePlotScale({ period, setPeriod }); const { xRange, setPlotScale } = usePlotScale({ period, setPeriod });
const { onReadyChart, isPanning } = useReadyChart(setPlotScale); const { onReadyChart, isPanning } = useReadyChart(setPlotScale);
useZoomChart({ uPlotInst, xRange, setPlotScale }); useZoomChart({ uPlotInst, xRange, setPlotScale });
const { options, focusDataIdx } = useBarHitsOptions({ xRange, containerSize, onReadyChart, setPlotScale });
const { data, bands } = useMemo(() => {
return graphOptions.stacked ? stack(_data, () => false) : { data: _data, bands: [] };
}, [graphOptions, _data]);
const { options, series, focusDataIdx } = useBarHitsOptions({
data,
logHits,
bands,
xRange,
containerSize,
onReadyChart,
setPlotScale,
graphOptions
});
useEffect(() => {
if (!uPlotInst) return;
delSeries(uPlotInst);
addSeries(uPlotInst, series, true);
setBand(uPlotInst, series);
uPlotInst.redraw();
}, [series]);
useEffect(() => { useEffect(() => {
if (!uPlotRef.current) return; if (!uPlotRef.current) return;
@ -54,6 +88,7 @@ const BarHitsChart: FC<Props> = ({ data, period, setPeriod }) => {
}, [data]); }, [data]);
return ( return (
<div className="vm-bar-hits-chart__wrapper">
<div <div
className={classNames({ className={classNames({
"vm-bar-hits-chart": true, "vm-bar-hits-chart": true,
@ -65,11 +100,20 @@ const BarHitsChart: FC<Props> = ({ data, period, setPeriod }) => {
className="vm-line-chart__u-plot" className="vm-line-chart__u-plot"
ref={uPlotRef} ref={uPlotRef}
/> />
<TooltipBarHitsChart <BarHitsTooltip
uPlotInst={uPlotInst} uPlotInst={uPlotInst}
data={_data}
focusDataIdx={focusDataIdx} focusDataIdx={focusDataIdx}
/> />
</div> </div>
<BarHitsOptions onChange={setGraphOptions}/>
{uPlotInst && (
<BarHitsLegend
uPlotInst={uPlotInst}
onApplyFilter={onApplyFilter}
/>
)}
</div>
); );
}; };

View file

@ -0,0 +1,68 @@
import React, { FC, useCallback, useEffect, useState } from "preact/compat";
import uPlot, { Series } from "uplot";
import "./style.scss";
import "../../Line/Legend/style.scss";
import classNames from "classnames";
import { MouseEvent } from "react";
import { isMacOs } from "../../../../utils/detect-device";
import Tooltip from "../../../Main/Tooltip/Tooltip";
interface Props {
uPlotInst: uPlot;
onApplyFilter: (value: string) => void;
}
const BarHitsLegend: FC<Props> = ({ uPlotInst, onApplyFilter }) => {
const [series, setSeries] = useState<Series[]>([]);
const updateSeries = useCallback(() => {
const series = uPlotInst.series.filter(s => s.scale !== "x");
setSeries(series);
}, [uPlotInst]);
const handleClick = (target: Series) => (e: MouseEvent<HTMLDivElement>) => {
const metaKey = e.metaKey || e.ctrlKey;
if (!metaKey) {
target.show = !target.show;
} else {
onApplyFilter(target.label || "");
}
updateSeries();
uPlotInst.redraw();
};
useEffect(updateSeries, [uPlotInst]);
return (
<div className="vm-bar-hits-legend">
{series.map(s => (
<Tooltip
key={s.label}
title={(
<ul className="vm-bar-hits-legend-info">
<li>Click to {s.show ? "hide" : "show"} the _stream.</li>
<li>{isMacOs() ? "Cmd" : "Ctrl"} + Click to filter by the _stream.</li>
</ul>
)}
>
<div
className={classNames({
"vm-bar-hits-legend-item": true,
"vm-bar-hits-legend-item_hide": !s.show,
})}
onClick={handleClick(s)}
>
<div
className="vm-bar-hits-legend-item__marker"
style={{ backgroundColor: `${(s?.stroke as () => string)?.()}` }}
/>
<div>{s.label}</div>
</div>
</Tooltip>
))}
</div>
);
};
export default BarHitsLegend;

View file

@ -0,0 +1,35 @@
@use "src/styles/variables" as *;
.vm-bar-hits-legend {
display: flex;
flex-wrap: wrap;
gap: 0;
padding: 0 $padding-small $padding-small;
&-item {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 4px;
font-size: 12px;
padding: $padding-small;
border-radius: $border-radius-small;
cursor: pointer;
transition: 0.2s;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
&_hide {
text-decoration: line-through;
opacity: 0.5;
}
&__marker {
width: 14px;
height: 14px;
border: $color-background-block;
}
}
}

View file

@ -0,0 +1,116 @@
import React, { FC, useEffect, useMemo, useRef } from "preact/compat";
import { GraphOptions, GRAPH_STYLES } from "../types";
import Switch from "../../../Main/Switch/Switch";
import "./style.scss";
import useStateSearchParams from "../../../../hooks/useStateSearchParams";
import { useSearchParams } from "react-router-dom";
import Button from "../../../Main/Button/Button";
import classNames from "classnames";
import { SettingsIcon } from "../../../Main/Icons";
import Tooltip from "../../../Main/Tooltip/Tooltip";
import Popper from "../../../Main/Popper/Popper";
import useBoolean from "../../../../hooks/useBoolean";
interface Props {
onChange: (options: GraphOptions) => void;
}
const BarHitsOptions: FC<Props> = ({ onChange }) => {
const [searchParams, setSearchParams] = useSearchParams();
const optionsButtonRef = useRef<HTMLDivElement>(null);
const {
value: openOptions,
toggle: toggleOpenOptions,
setFalse: handleCloseOptions,
} = useBoolean(false);
const [graphStyle, setGraphStyle] = useStateSearchParams(GRAPH_STYLES.LINE_STEPPED, "graph");
const [stacked, setStacked] = useStateSearchParams(false, "stacked");
const [fill, setFill] = useStateSearchParams(false, "fill");
const options: GraphOptions = useMemo(() => ({
graphStyle,
stacked,
fill,
}), [graphStyle, stacked, fill]);
const handleChangeGraphStyle = (val: string) => () => {
setGraphStyle(val as GRAPH_STYLES);
searchParams.set("graph", val);
setSearchParams(searchParams);
};
const handleChangeFill = (val: boolean) => {
setFill(val);
val ? searchParams.set("fill", "true") : searchParams.delete("fill");
setSearchParams(searchParams);
};
const handleChangeStacked = (val: boolean) => {
setStacked(val);
val ? searchParams.set("stacked", "true") : searchParams.delete("stacked");
setSearchParams(searchParams);
};
useEffect(() => {
onChange(options);
}, [options]);
return (
<div
className="vm-bar-hits-options"
ref={optionsButtonRef}
>
<Tooltip title="Graph settings">
<Button
variant="text"
color="primary"
startIcon={<SettingsIcon/>}
onClick={toggleOpenOptions}
ariaLabel="settings"
/>
</Tooltip>
<Popper
open={openOptions}
placement="bottom-right"
onClose={handleCloseOptions}
buttonRef={optionsButtonRef}
title={"Graph settings"}
>
<div className="vm-bar-hits-options-settings">
<div className="vm-bar-hits-options-settings-item vm-bar-hits-options-settings-item_list">
<p className="vm-bar-hits-options-settings-item__title">Graph style:</p>
{Object.values(GRAPH_STYLES).map(style => (
<div
key={style}
className={classNames({
"vm-list-item": true,
"vm-list-item_active": graphStyle === style,
})}
onClick={handleChangeGraphStyle(style)}
>
{style}
</div>
))}
</div>
<div className="vm-bar-hits-options-settings-item">
<Switch
label={"Stacked"}
value={stacked}
onChange={handleChangeStacked}
/>
</div>
<div className="vm-bar-hits-options-settings-item">
<Switch
label={"Fill"}
value={fill}
onChange={handleChangeFill}
/>
</div>
</div>
</Popper>
</div>
);
};
export default BarHitsOptions;

View file

@ -0,0 +1,35 @@
@use "src/styles/variables" as *;
.vm-bar-hits-options {
position: absolute;
top: $padding-small;
right: $padding-small;
z-index: 2;
&-settings {
display: grid;
align-items: flex-start;
gap: $padding-global;
min-width: 200px;
&-item {
border-bottom: $border-divider;
padding: 0 $padding-global $padding-global;
&_list {
padding: 0;
}
&__title {
font-size: $font-size-small;
color: $color-text-secondary;
padding: 0 $padding-small $padding-small;
}
&:last-child {
border-bottom: none;
}
}
}
}

View file

@ -0,0 +1,125 @@
import React, { FC, useMemo, useRef } from "preact/compat";
import uPlot, { AlignedData } from "uplot";
import dayjs from "dayjs";
import { DATE_TIME_FORMAT } from "../../../../constants/date";
import classNames from "classnames";
import "./style.scss";
import "../../ChartTooltip/style.scss";
interface Props {
data: AlignedData;
uPlotInst?: uPlot;
focusDataIdx: number;
}
const BarHitsTooltip: FC<Props> = ({ data, focusDataIdx, uPlotInst }) => {
const tooltipRef = useRef<HTMLDivElement>(null);
const tooltipData = useMemo(() => {
const series = uPlotInst?.series || [];
const [time, ...values] = data.map((d) => d[focusDataIdx] || 0);
const tooltipItems = values.map((value, i) => {
const targetSeries = series[i + 1];
const stroke = (targetSeries?.stroke as () => string)?.();
const label = targetSeries?.label || "other";
const show = targetSeries?.show;
return {
label,
stroke,
value,
show
};
}).filter(item => item.value > 0 && item.show).sort((a, b) => b.value - a.value);
const point = {
top: tooltipItems[0] ? uPlotInst?.valToPos?.(tooltipItems[0].value, "y") || 0 : 0,
left: uPlotInst?.valToPos?.(time, "x") || 0,
};
return {
point,
values: tooltipItems,
total: tooltipItems.reduce((acc, item) => acc + item.value, 0),
timestamp: dayjs(time * 1000).tz().format(DATE_TIME_FORMAT),
};
}, [focusDataIdx, uPlotInst, data]);
const tooltipPosition = useMemo(() => {
if (!uPlotInst || !tooltipData.total || !tooltipRef.current) return;
const { top, left } = tooltipData.point;
const uPlotPosition = {
left: parseFloat(uPlotInst.over.style.left),
top: parseFloat(uPlotInst.over.style.top)
};
const {
width: uPlotWidth,
height: uPlotHeight
} = uPlotInst.over.getBoundingClientRect();
const {
width: tooltipWidth,
height: tooltipHeight
} = tooltipRef.current.getBoundingClientRect();
const margin = 50;
const overflowX = left + tooltipWidth >= uPlotWidth ? tooltipWidth + (2 * margin) : 0;
const overflowY = top + tooltipHeight >= uPlotHeight ? tooltipHeight + (2 * margin) : 0;
const position = {
top: top + uPlotPosition.top + margin - overflowY,
left: left + uPlotPosition.left + margin - overflowX
};
if (position.left < 0) position.left = 20;
if (position.top < 0) position.top = 20;
return position;
}, [tooltipData, uPlotInst, tooltipRef.current]);
return (
<div
className={classNames({
"vm-chart-tooltip": true,
"vm-chart-tooltip_hits": true,
"vm-bar-hits-tooltip": true,
"vm-bar-hits-tooltip_visible": focusDataIdx !== -1 && tooltipData.values.length
})}
ref={tooltipRef}
style={tooltipPosition}
>
<div>
{tooltipData.values.map((item, i) => (
<div
className="vm-chart-tooltip-data"
key={i}
>
<span
className="vm-chart-tooltip-data__marker"
style={{ background: item.stroke }}
/>
<p>
{item.label}: <b>{item.value}</b>
</p>
</div>
))}
</div>
{tooltipData.values.length > 1 && (
<div className="vm-chart-tooltip-data">
<p>
Total records: <b>{tooltipData.total}</b>
</p>
</div>
)}
<div className="vm-chart-tooltip-header">
<div className="vm-chart-tooltip-header__title">
{tooltipData.timestamp}
</div>
</div>
</div>
);
};
export default BarHitsTooltip;

View file

@ -0,0 +1,12 @@
@use "src/styles/variables" as *;
.vm-bar-hits-tooltip {
opacity: 0;
pointer-events: none;
gap: $padding-small;
&_visible {
opacity: 1;
pointer-events: auto;
}
}

View file

@ -1,89 +0,0 @@
import React, { FC, useMemo, useRef } from "preact/compat";
import uPlot from "uplot";
import dayjs from "dayjs";
import { DATE_TIME_FORMAT } from "../../../constants/date";
import classNames from "classnames";
import "./style.scss";
import "../../../components/Chart/ChartTooltip/style.scss";
interface Props {
uPlotInst?: uPlot;
focusDataIdx: number
}
const TooltipBarHitsChart: FC<Props> = ({ focusDataIdx, uPlotInst }) => {
const tooltipRef = useRef<HTMLDivElement>(null);
const tooltipData = useMemo(() => {
const value = uPlotInst?.data?.[1]?.[focusDataIdx];
const timestamp = uPlotInst?.data?.[0]?.[focusDataIdx] || 0;
const top = uPlotInst?.valToPos?.((value || 0), "y") || 0;
const left = uPlotInst?.valToPos?.(timestamp, "x") || 0;
return {
point: { top, left },
value,
timestamp: dayjs(timestamp * 1000).tz().format(DATE_TIME_FORMAT),
};
}, [focusDataIdx, uPlotInst]);
const tooltipPosition = useMemo(() => {
if (!uPlotInst || !tooltipData.value || !tooltipRef.current) return;
const { top, left } = tooltipData.point;
const uPlotPosition = {
left: parseFloat(uPlotInst.over.style.left),
top: parseFloat(uPlotInst.over.style.top)
};
const {
width: uPlotWidth,
height: uPlotHeight
} = uPlotInst.over.getBoundingClientRect();
const {
width: tooltipWidth,
height: tooltipHeight
} = tooltipRef.current.getBoundingClientRect();
const margin = 10;
const overflowX = left + tooltipWidth >= uPlotWidth ? tooltipWidth + (2 * margin) : 0;
const overflowY = top + tooltipHeight >= uPlotHeight ? tooltipHeight + (2 * margin) : 0;
const position = {
top: top + uPlotPosition.top + margin - overflowY,
left: left + uPlotPosition.left + margin - overflowX
};
if (position.left < 0) position.left = 20;
if (position.top < 0) position.top = 20;
return position;
}, [tooltipData, uPlotInst, tooltipRef.current]);
return (
<div
className={classNames({
"vm-chart-tooltip": true,
"vm-bar-hits-chart-tooltip": true,
"vm-bar-hits-chart-tooltip_visible": focusDataIdx !== -1
})}
ref={tooltipRef}
style={tooltipPosition}
>
<div className="vm-chart-tooltip-data">
Count of records:
<p className="vm-chart-tooltip-data__value">
<b>{tooltipData.value}</b>
</p>
</div>
<div className="vm-chart-tooltip-header">
<div className="vm-chart-tooltip-header__title">
{tooltipData.timestamp}
</div>
</div>
</div>
);
};
export default TooltipBarHitsChart;

View file

@ -2,42 +2,81 @@ import { useMemo, useState } from "preact/compat";
import { getAxes, handleDestroy, setSelect } from "../../../../utils/uplot"; import { getAxes, handleDestroy, setSelect } from "../../../../utils/uplot";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { dateFromSeconds, formatDateForNativeInput } from "../../../../utils/time"; import { dateFromSeconds, formatDateForNativeInput } from "../../../../utils/time";
import uPlot, { Options } from "uplot"; import uPlot, { AlignedData, Band, Options, Series } from "uplot";
import { getCssVariable } from "../../../../utils/theme"; import { getCssVariable } from "../../../../utils/theme";
import { barPaths } from "../../../../utils/uplot/bars";
import { useAppState } from "../../../../state/common/StateContext"; import { useAppState } from "../../../../state/common/StateContext";
import { MinMax, SetMinMax } from "../../../../types"; import { MinMax, SetMinMax } from "../../../../types";
import { LogHits } from "../../../../api/types";
import getSeriesPaths from "../../../../utils/uplot/paths";
import { GraphOptions, GRAPH_STYLES } from "../types";
const seriesColors = [
"color-log-hits-bar-1",
"color-log-hits-bar-2",
"color-log-hits-bar-3",
"color-log-hits-bar-4",
"color-log-hits-bar-5",
];
const strokeWidth = {
[GRAPH_STYLES.BAR]: 0.8,
[GRAPH_STYLES.LINE_STEPPED]: 1.2,
[GRAPH_STYLES.LINE]: 1.2,
[GRAPH_STYLES.POINTS]: 0,
};
interface UseGetBarHitsOptionsArgs { interface UseGetBarHitsOptionsArgs {
data: AlignedData;
logHits: LogHits[];
xRange: MinMax; xRange: MinMax;
bands?: Band[];
containerSize: { width: number, height: number }; containerSize: { width: number, height: number };
setPlotScale: SetMinMax; setPlotScale: SetMinMax;
onReadyChart: (u: uPlot) => void; onReadyChart: (u: uPlot) => void;
graphOptions: GraphOptions;
} }
const useBarHitsOptions = ({ xRange, containerSize, onReadyChart, setPlotScale }: UseGetBarHitsOptionsArgs) => { const useBarHitsOptions = ({
data,
logHits,
xRange,
bands,
containerSize,
onReadyChart,
setPlotScale,
graphOptions
}: UseGetBarHitsOptionsArgs) => {
const { isDarkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const [focusDataIdx, setFocusDataIdx] = useState(-1); const [focusDataIdx, setFocusDataIdx] = useState(-1);
const series = useMemo(() => [
{},
{
label: "y",
width: 1,
stroke: getCssVariable("color-log-hits-bar"),
fill: getCssVariable("color-log-hits-bar"),
paths: barPaths,
}
], [isDarkTheme]);
const setCursor = (u: uPlot) => { const setCursor = (u: uPlot) => {
const dataIdx = u.cursor.idx ?? -1; const dataIdx = u.cursor.idx ?? -1;
setFocusDataIdx(dataIdx); setFocusDataIdx(dataIdx);
}; };
const series: Series[] = useMemo(() => {
let colorN = 0;
return data.map((_d, i) => {
if (i === 0) return {}; // 0 index is xAxis(timestamps)
const fields = Object.values(logHits?.[i - 1]?.fields || {});
const label = fields.map((value) => value || "\"\"").join(", ");
const color = getCssVariable(label ? seriesColors[colorN] : "color-log-hits-bar-0");
if (label) colorN++;
return {
label: label || "other",
width: strokeWidth[graphOptions.graphStyle],
spanGaps: true,
stroke: color,
fill: graphOptions.fill ? color + "80" : "",
paths: getSeriesPaths(graphOptions.graphStyle),
};
});
}, [isDarkTheme, data, graphOptions]);
const options: Options = useMemo(() => ({ const options: Options = useMemo(() => ({
series, series,
bands,
width: containerSize.width || (window.innerWidth / 2), width: containerSize.width || (window.innerWidth / 2),
height: containerSize.height || 200, height: containerSize.height || 200,
cursor: { cursor: {
@ -55,6 +94,7 @@ const useBarHitsOptions = ({ xRange, containerSize, onReadyChart, setPlotScale }
} }
}, },
hooks: { hooks: {
drawSeries: [],
ready: [onReadyChart], ready: [onReadyChart],
setCursor: [setCursor], setCursor: [setCursor],
setSelect: [setSelect(setPlotScale)], setSelect: [setSelect(setPlotScale)],
@ -63,10 +103,11 @@ const useBarHitsOptions = ({ xRange, containerSize, onReadyChart, setPlotScale }
legend: { show: false }, legend: { show: false },
axes: getAxes([{}, { scale: "y" }]), axes: getAxes([{}, { scale: "y" }]),
tzDate: ts => dayjs(formatDateForNativeInput(dateFromSeconds(ts))).local().toDate(), tzDate: ts => dayjs(formatDateForNativeInput(dateFromSeconds(ts))).local().toDate(),
}), [isDarkTheme]); }), [isDarkTheme, series, bands]);
return { return {
options, options,
series,
focusDataIdx, focusDataIdx,
}; };
}; };

View file

@ -1,22 +1,18 @@
@use "src/styles/variables" as *; @use "src/styles/variables" as *;
.vm-bar-hits-chart { .vm-bar-hits-chart {
height: 100%; position: relative;
width: 100%; width: 100%;
height: 200px;
&__wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
&_panning { &_panning {
pointer-events: none; pointer-events: none;
} }
&-tooltip {
opacity: 0;
pointer-events: none;
width: 240px;
gap: $padding-small;
&_visible {
opacity: 1;
pointer-events: auto;
}
}
} }

View file

@ -0,0 +1,12 @@
export enum GRAPH_STYLES {
BAR = "Bars",
LINE = "Lines",
LINE_STEPPED = "Stepped lines",
POINTS = "Points",
}
export interface GraphOptions {
graphStyle: GRAPH_STYLES;
stacked: boolean;
fill: boolean;
}

View file

@ -25,6 +25,12 @@ $chart-tooltip-y: -1 * ($padding-global + $chart-tooltip-half-icon);
user-select: text; user-select: text;
pointer-events: none; pointer-events: none;
&_hits {
white-space: pre-wrap;
word-break: break-all;
width: auto;
}
&_sticky { &_sticky {
pointer-events: auto; pointer-events: auto;
z-index: 99; z-index: 99;
@ -74,10 +80,22 @@ $chart-tooltip-y: -1 * ($padding-global + $chart-tooltip-half-icon);
justify-content: flex-start; justify-content: flex-start;
gap: $padding-small; gap: $padding-small;
&_margin-bottom {
margin-bottom: $padding-global;
}
&_margin-top {
margin-top: $padding-global;
}
&__marker { &__marker {
width: $font-size; width: $font-size;
height: $font-size; height: $font-size;
border: 1px solid rgba($color-white, 0.5); border: 1px solid rgba($color-white, 0.5);
&_tranparent {
opacity: 0;
}
} }
&__value { &__value {

View file

@ -32,19 +32,19 @@
&-header { &-header {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr 25px;
gap: $padding-small; gap: $padding-global;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: $color-background-block; background-color: $color-background-block;
padding: $padding-small $padding-small $padding-small $padding-global; padding: $padding-small $padding-global;
border-radius: $border-radius-small $border-radius-small 0 0; border-radius: $border-radius-small $border-radius-small 0 0;
color: $color-text; color: $color-text;
border-bottom: $border-divider; border-bottom: $border-divider;
margin-bottom: $padding-global; margin-bottom: $padding-global;
min-height: 51px;
&__title { &__title {
font-size: $font-size-small;
font-weight: bold; font-weight: bold;
user-select: none; user-select: none;
} }

View file

@ -15,7 +15,13 @@ export const darkPalette = {
"box-shadow-popper": "rgba(0, 0, 0, 0.2) 0px 2px 8px 0px", "box-shadow-popper": "rgba(0, 0, 0, 0.2) 0px 2px 8px 0px",
"border-divider": "1px solid rgba(99, 110, 123, 0.5)", "border-divider": "1px solid rgba(99, 110, 123, 0.5)",
"color-hover-black": "rgba(0, 0, 0, 0.12)", "color-hover-black": "rgba(0, 0, 0, 0.12)",
"color-log-hits-bar": "rgba(255, 255, 255, 0.18)" // log hits chart colors
"color-log-hits-bar-0": "rgba(255, 255, 255, 0.18)",
"color-log-hits-bar-1": "#FFB74D",
"color-log-hits-bar-2": "#81C784",
"color-log-hits-bar-3": "#64B5F6",
"color-log-hits-bar-4": "#E57373",
"color-log-hits-bar-5": "#8a62f0",
}; };
export const lightPalette = { export const lightPalette = {
@ -35,5 +41,12 @@ export const lightPalette = {
"box-shadow-popper": "rgba(0, 0, 0, 0.1) 0px 2px 8px 0px", "box-shadow-popper": "rgba(0, 0, 0, 0.1) 0px 2px 8px 0px",
"border-divider": "1px solid rgba(0, 0, 0, 0.15)", "border-divider": "1px solid rgba(0, 0, 0, 0.15)",
"color-hover-black": "rgba(0, 0, 0, 0.06)", "color-hover-black": "rgba(0, 0, 0, 0.06)",
"color-log-hits-bar": "rgba(0, 0, 0, 0.18)" // log hits chart colors
"color-log-hits-bar-0": "rgba(0, 0, 0, 0.18)",
"color-log-hits-bar-1": "#FFB74D",
"color-log-hits-bar-2": "#81C784",
"color-log-hits-bar-3": "#64B5F6",
"color-log-hits-bar-4": "#E57373",
"color-log-hits-bar-5": "#8a62f0",
}; };

View file

@ -1,4 +1,4 @@
import React, { FC, useCallback, useEffect } from "preact/compat"; import React, { FC, useCallback, useEffect, useState } from "preact/compat";
import ExploreLogsBody from "./ExploreLogsBody/ExploreLogsBody"; import ExploreLogsBody from "./ExploreLogsBody/ExploreLogsBody";
import useStateSearchParams from "../../hooks/useStateSearchParams"; import useStateSearchParams from "../../hooks/useStateSearchParams";
import useSearchParamsFromObject from "../../hooks/useSearchParamsFromObject"; import useSearchParamsFromObject from "../../hooks/useSearchParamsFromObject";
@ -9,7 +9,6 @@ import Alert from "../../components/Main/Alert/Alert";
import ExploreLogsHeader from "./ExploreLogsHeader/ExploreLogsHeader"; import ExploreLogsHeader from "./ExploreLogsHeader/ExploreLogsHeader";
import "./style.scss"; import "./style.scss";
import { ErrorTypes, TimeParams } from "../../types"; import { ErrorTypes, TimeParams } from "../../types";
import { useState } from "react";
import { useTimeState } from "../../state/time/TimeStateContext"; import { useTimeState } from "../../state/time/TimeStateContext";
import { getFromStorage, saveToStorage } from "../../utils/storage"; import { getFromStorage, saveToStorage } from "../../utils/storage";
import ExploreLogsBarChart from "./ExploreLogsBarChart/ExploreLogsBarChart"; import ExploreLogsBarChart from "./ExploreLogsBarChart/ExploreLogsBarChart";
@ -27,10 +26,12 @@ const ExploreLogs: FC = () => {
const [limit, setLimit] = useStateSearchParams(defaultLimit, "limit"); const [limit, setLimit] = useStateSearchParams(defaultLimit, "limit");
const [query, setQuery] = useStateSearchParams("*", "query"); const [query, setQuery] = useStateSearchParams("*", "query");
const [tmpQuery, setTmpQuery] = useState("");
const [period, setPeriod] = useState<TimeParams>(periodState); const [period, setPeriod] = useState<TimeParams>(periodState);
const [queryError, setQueryError] = useState<ErrorTypes | string>("");
const { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query, limit); const { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query, limit);
const { fetchLogHits, ...dataLogHits } = useFetchLogHits(serverUrl, query); const { fetchLogHits, ...dataLogHits } = useFetchLogHits(serverUrl, query);
const [queryError, setQueryError] = useState<ErrorTypes | string>("");
const getPeriod = useCallback(() => { const getPeriod = useCallback(() => {
const relativeTimeOpts = relativeTimeOptions.find(d => d.id === relativeTime); const relativeTimeOpts = relativeTimeOptions.find(d => d.id === relativeTime);
@ -44,6 +45,7 @@ const ExploreLogs: FC = () => {
setQueryError(ErrorTypes.validQuery); setQueryError(ErrorTypes.validQuery);
return; return;
} }
setQueryError("");
const newPeriod = getPeriod(); const newPeriod = getPeriod();
setPeriod(newPeriod); setPeriod(newPeriod);
@ -64,23 +66,33 @@ const ExploreLogs: FC = () => {
saveToStorage("LOGS_LIMIT", `${limit}`); saveToStorage("LOGS_LIMIT", `${limit}`);
}; };
const handleApplyFilter = (val: string) => {
setQuery(prev => `_stream: ${val === "other" ? "{}" : val} AND (${prev})`);
};
const handleUpdateQuery = () => {
setQuery(tmpQuery);
handleRunQuery();
};
useEffect(() => { useEffect(() => {
if (query) handleRunQuery(); if (query) handleRunQuery();
}, [periodState]); }, [periodState]);
useEffect(() => { useEffect(() => {
setQueryError(""); handleRunQuery();
setTmpQuery(query);
}, [query]); }, [query]);
return ( return (
<div className="vm-explore-logs"> <div className="vm-explore-logs">
<ExploreLogsHeader <ExploreLogsHeader
query={query} query={tmpQuery}
error={queryError} error={queryError}
limit={limit} limit={limit}
onChange={setQuery} onChange={setTmpQuery}
onChangeLimit={handleChangeLimit} onChangeLimit={handleChangeLimit}
onRun={handleRunQuery} onRun={handleUpdateQuery}
/> />
{isLoading && <Spinner message={"Loading logs..."}/>} {isLoading && <Spinner message={"Loading logs..."}/>}
{error && <Alert variant="error">{error}</Alert>} {error && <Alert variant="error">{error}</Alert>}
@ -89,6 +101,7 @@ const ExploreLogs: FC = () => {
{...dataLogHits} {...dataLogHits}
query={query} query={query}
period={period} period={period}
onApplyFilter={handleApplyFilter}
isLoading={isLoading ? false : dataLogHits.isLoading} isLoading={isLoading ? false : dataLogHits.isLoading}
/> />
)} )}

View file

@ -17,19 +17,34 @@ interface Props {
period: TimeParams; period: TimeParams;
error?: string; error?: string;
isLoading: boolean; isLoading: boolean;
onApplyFilter: (value: string) => void;
} }
const ExploreLogsBarChart: FC<Props> = ({ logHits, period, error, isLoading }) => { const ExploreLogsBarChart: FC<Props> = ({ logHits, period, error, isLoading, onApplyFilter }) => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const timeDispatch = useTimeDispatch(); const timeDispatch = useTimeDispatch();
const getXAxis = (timestamps: string[]): number[] => {
return (timestamps.map(t => t ? dayjs(t).unix() : null)
.filter(Boolean) as number[])
.sort((a, b) => a - b);
};
const getYAxes = (logHits: LogHits[], timestamps: string[]) => {
return logHits.map(hits => {
return timestamps.map(t => {
const index = hits.timestamps.findIndex(ts => ts === t);
return index === -1 ? null : hits.values[index] || null;
});
});
};
const data = useMemo(() => { const data = useMemo(() => {
const hits = logHits[0]; if (!logHits.length) return [[], []] as AlignedData;
if (!hits) return [[], []] as AlignedData; const timestamps = Array.from(new Set(logHits.map(l => l.timestamps).flat()));
const { values, timestamps } = hits; const xAxis = getXAxis(timestamps);
const xAxis = timestamps.map(t => t ? dayjs(t).unix() : null).filter(Boolean); const yAxes = getYAxes(logHits, timestamps);
const yAxis = values.map(v => v || null); return [xAxis, ...yAxes] as AlignedData;
return [xAxis, yAxis] as AlignedData;
}, [logHits]); }, [logHits]);
const noDataMessage: string = useMemo(() => { const noDataMessage: string = useMemo(() => {
@ -75,9 +90,11 @@ const ExploreLogsBarChart: FC<Props> = ({ logHits, period, error, isLoading }) =
{data && ( {data && (
<BarHitsChart <BarHitsChart
logHits={logHits}
data={data} data={data}
period={period} period={period}
setPeriod={setPeriod} setPeriod={setPeriod}
onApplyFilter={onApplyFilter}
/> />
)} )}
</section> </section>

View file

@ -5,7 +5,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 200px;
padding: 0 0 0 $padding-small !important; padding: 0 0 0 $padding-small !important;
width: calc(100vw - ($padding-medium * 2)); width: calc(100vw - ($padding-medium * 2));

View file

@ -88,6 +88,9 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data }) => {
items={tabs} items={tabs}
onChange={handleChangeTab} onChange={handleChangeTab}
/> />
<div className="vm-explore-logs-body-header__log-info">
Total logs returned: <b>{data.length}</b>
</div>
</div> </div>
{activeTab === DisplayType.table && ( {activeTab === DisplayType.table && (
<div className="vm-explore-logs-body-header__settings"> <div className="vm-explore-logs-body-header__settings">

View file

@ -13,6 +13,14 @@
align-items: center; align-items: center;
gap: $padding-small; gap: $padding-small;
} }
&__log-info {
flex-grow: 1;
text-align: right;
padding-right: $padding-global;
color: $color-text-secondary;
}
} }
&__empty { &__empty {

View file

@ -34,10 +34,46 @@ export const useFetchLogHits = (server: string, query: string) => {
step: `${step}ms`, step: `${step}ms`,
start: start.toISOString(), start: start.toISOString(),
end: end.toISOString(), end: end.toISOString(),
field: "_stream" // In the future, this field can be made configurable
}) })
}; };
}; };
const accumulateHits = (resultHit: LogHits, hit: LogHits) => {
resultHit.total = (resultHit.total || 0) + (hit.total || 0);
hit.timestamps.forEach((timestamp, i) => {
const index = resultHit.timestamps.findIndex(t => t === timestamp);
if (index === -1) {
resultHit.timestamps.push(timestamp);
resultHit.values.push(hit.values[i]);
} else {
resultHit.values[index] += hit.values[i];
}
});
return resultHit;
};
const getHitsWithTop = (hits: LogHits[]) => {
const topN = 5;
const defaultHit = { fields: {}, timestamps: [], values: [], total: 0 };
const hitsByTotal = hits.sort((a, b) => (b.total || 0) - (a.total || 0));
const result = [];
const otherHits: LogHits = hitsByTotal.slice(topN).reduce(accumulateHits, defaultHit);
if (otherHits.total) {
result.push(otherHits);
}
const topHits: LogHits[] = hitsByTotal.slice(0, topN);
if (topHits.length) {
result.push(...topHits);
}
return result;
};
const fetchLogHits = useCallback(async (period: TimeParams) => { const fetchLogHits = useCallback(async (period: TimeParams) => {
abortControllerRef.current.abort(); abortControllerRef.current.abort();
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
@ -66,7 +102,7 @@ export const useFetchLogHits = (server: string, query: string) => {
setError(error); setError(error);
} }
setLogHits(!hits ? [] : hits); setLogHits(!hits ? [] : getHitsWithTop(hits));
} catch (e) { } catch (e) {
if (e instanceof Error && e.name !== "AbortError") { if (e instanceof Error && e.name !== "AbortError") {
setError(String(e)); setError(String(e));

View file

@ -1,14 +0,0 @@
import uPlot from "uplot";
import { LOGS_BARS_VIEW } from "../../constants/logs";
export const barPaths = (
u: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
): uPlot.Series.Paths | null => {
const barSize = (u.under.clientWidth/LOGS_BARS_VIEW ) - 1;
const barsPathBuilderFactory = uPlot?.paths?.bars?.({ size: [0.96, barSize] });
return barsPathBuilderFactory ? barsPathBuilderFactory(u, seriesIdx, idx0, idx1) : null;
};

View file

@ -0,0 +1,38 @@
import uPlot, { Series } from "uplot";
import { LOGS_BARS_VIEW } from "../../constants/logs";
import { GRAPH_STYLES } from "../../components/Chart/BarHitsChart/types";
const barPaths = (
u: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
): Series.Paths | null => {
const barSize = (u.under.clientWidth/LOGS_BARS_VIEW ) - 1;
const pathBuilderFactory = uPlot?.paths?.bars?.({ size: [0.96, barSize] });
return pathBuilderFactory ? pathBuilderFactory(u, seriesIdx, idx0, idx1) : null;
};
const lineSteppedPaths = (
u: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
): Series.Paths | null => {
const pathBuilderFactory = uPlot?.paths?.stepped?.({ align: 1 });
return pathBuilderFactory ? pathBuilderFactory(u, seriesIdx, idx0, idx1) : null;
};
const getSeriesPaths = (type?: GRAPH_STYLES) => {
switch (type) {
case GRAPH_STYLES.BAR:
return barPaths;
case GRAPH_STYLES.LINE_STEPPED:
return lineSteppedPaths;
default:
return;
}
};
export default getSeriesPaths;

View file

@ -0,0 +1,33 @@
// taken from https://github.com/leeoniya/uPlot/blob/master/demos/stack.js
import { AlignedData, Band } from "uplot";
function stack(data: AlignedData, omit: (i: number) => boolean) {
const data2 = [];
let bands = [];
const d0Len = data[0].length;
const accum = Array(d0Len);
for (let i = 0; i < d0Len; i++)
accum[i] = 0;
for (let i = 1; i < data.length; i++)
data2.push(omit(i) ? data[i] : data[i].map((v, i) => (accum[i] += +(v ?? 0))));
for (let i = 1; i < data.length; i++)
!omit(i) && bands.push({
series: [
data.findIndex((_s, j) => j > i && !omit(j)),
i,
],
});
bands = bands.filter(b => b.series[1] > -1);
return {
data: [data[0]].concat(data2) as AlignedData,
bands: bands as Band[],
};
}
export default stack;

View file

@ -16,6 +16,8 @@ according to [these docs](https://docs.victoriametrics.com/victorialogs/quicksta
## tip ## tip
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): add support for displaying the top 5 log streams in the hits graph. The remaining log streams are grouped into an "other" label. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6545).
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): add the ability to customize the graph display with options for bar, line, stepped line, and points.
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): add fields for setting AccountID and ProjectID. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6631). * FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): add fields for setting AccountID and ProjectID. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6631).
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): add a toggle button to the "Group" tab that allows users to expand or collapse all groups at once. * FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): add a toggle button to the "Group" tab that allows users to expand or collapse all groups at once.
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): introduce the ability to select a key for grouping logs within the "Group" tab. * FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): introduce the ability to select a key for grouping logs within the "Group" tab.