From 04c2232e457b14b81946535361220190c12d68e0 Mon Sep 17 00:00:00 2001 From: Yury Molodov Date: Tue, 6 Aug 2024 16:28:44 +0200 Subject: [PATCH] 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/). --- app/vmui/packages/vmui/src/api/types.ts | 1 + .../Chart/BarHitsChart/BarHitsChart.tsx | 82 +++++++++--- .../BarHitsLegend/BarHitsLegend.tsx | 68 ++++++++++ .../BarHitsChart/BarHitsLegend/style.scss | 35 +++++ .../BarHitsOptions/BarHitsOptions.tsx | 116 ++++++++++++++++ .../BarHitsChart/BarHitsOptions/style.scss | 35 +++++ .../BarHitsTooltip/BarHitsTooltip.tsx | 125 ++++++++++++++++++ .../BarHitsChart/BarHitsTooltip/style.scss | 12 ++ .../BarHitsChart/TooltipBarHitsChart.tsx | 89 ------------- .../BarHitsChart/hooks/useBarHitsOptions.ts | 71 +++++++--- .../components/Chart/BarHitsChart/style.scss | 22 ++- .../components/Chart/BarHitsChart/types.ts | 12 ++ .../components/Chart/ChartTooltip/style.scss | 18 +++ .../src/components/Main/Popper/style.scss | 8 +- .../packages/vmui/src/constants/palette.ts | 17 ++- .../src/pages/ExploreLogs/ExploreLogs.tsx | 27 +++- .../ExploreLogsBarChart.tsx | 31 ++++- .../ExploreLogsBarChart/style.scss | 1 - .../ExploreLogsBody/ExploreLogsBody.tsx | 3 + .../ExploreLogs/ExploreLogsBody/style.scss | 8 ++ .../ExploreLogs/hooks/useFetchLogHits.ts | 38 +++++- .../packages/vmui/src/utils/uplot/bars.ts | 14 -- .../packages/vmui/src/utils/uplot/paths.ts | 38 ++++++ .../packages/vmui/src/utils/uplot/stack.ts | 33 +++++ docs/VictoriaLogs/CHANGELOG.md | 2 + 25 files changed, 734 insertions(+), 172 deletions(-) create mode 100644 app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsLegend/BarHitsLegend.tsx create mode 100644 app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsLegend/style.scss create mode 100644 app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsOptions/BarHitsOptions.tsx create mode 100644 app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsOptions/style.scss create mode 100644 app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsTooltip/BarHitsTooltip.tsx create mode 100644 app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsTooltip/style.scss delete mode 100644 app/vmui/packages/vmui/src/components/Chart/BarHitsChart/TooltipBarHitsChart.tsx create mode 100644 app/vmui/packages/vmui/src/components/Chart/BarHitsChart/types.ts delete mode 100644 app/vmui/packages/vmui/src/utils/uplot/bars.ts create mode 100644 app/vmui/packages/vmui/src/utils/uplot/paths.ts create mode 100644 app/vmui/packages/vmui/src/utils/uplot/stack.ts diff --git a/app/vmui/packages/vmui/src/api/types.ts b/app/vmui/packages/vmui/src/api/types.ts index e9765ee10..322bfb3f3 100644 --- a/app/vmui/packages/vmui/src/api/types.ts +++ b/app/vmui/packages/vmui/src/api/types.ts @@ -38,6 +38,7 @@ export interface Logs { export interface LogHits { timestamps: string[]; values: number[]; + total?: number; fields: { [key: string]: string; }; diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsChart.tsx b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsChart.tsx index 748bf46f2..2742fa64a 100644 --- a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsChart.tsx +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsChart.tsx @@ -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 "uplot/dist/uPlot.min.css"; import useElementSize from "../../../hooks/useElementSize"; import uPlot, { AlignedData } from "uplot"; import { useEffect } from "react"; import useBarHitsOptions from "./hooks/useBarHitsOptions"; -import TooltipBarHitsChart from "./TooltipBarHitsChart"; +import BarHitsTooltip from "./BarHitsTooltip/BarHitsTooltip"; import { TimeParams } from "../../../types"; import usePlotScale from "../../../hooks/uplot/usePlotScale"; import useReadyChart from "../../../hooks/uplot/useReadyChart"; import useZoomChart from "../../../hooks/uplot/useZoomChart"; 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 { + logHits: LogHits[]; data: AlignedData; period: TimeParams; setPeriod: ({ from, to }: { from: Date, to: Date }) => void; + onApplyFilter: (value: string) => void; } - -const BarHitsChart: FC = ({ data, period, setPeriod }) => { +const BarHitsChart: FC = ({ logHits, data: _data, period, setPeriod, onApplyFilter }) => { const [containerRef, containerSize] = useElementSize(); const uPlotRef = useRef(null); const [uPlotInst, setUPlotInst] = useState(); + const [graphOptions, setGraphOptions] = useState({ + graphStyle: GRAPH_STYLES.LINE_STEPPED, + stacked: false, + fill: false, + }); const { xRange, setPlotScale } = usePlotScale({ period, setPeriod }); const { onReadyChart, isPanning } = useReadyChart(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(() => { if (!uPlotRef.current) return; @@ -54,21 +88,31 @@ const BarHitsChart: FC = ({ data, period, setPeriod }) => { }, [data]); return ( -
+
- + className={classNames({ + "vm-bar-hits-chart": true, + "vm-bar-hits-chart_panning": isPanning + })} + ref={containerRef} + > +
+ +
+ + {uPlotInst && ( + + )}
); }; diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsLegend/BarHitsLegend.tsx b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsLegend/BarHitsLegend.tsx new file mode 100644 index 000000000..e4d82cc98 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsLegend/BarHitsLegend.tsx @@ -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 = ({ uPlotInst, onApplyFilter }) => { + const [series, setSeries] = useState([]); + + const updateSeries = useCallback(() => { + const series = uPlotInst.series.filter(s => s.scale !== "x"); + setSeries(series); + }, [uPlotInst]); + + const handleClick = (target: Series) => (e: MouseEvent) => { + const metaKey = e.metaKey || e.ctrlKey; + if (!metaKey) { + target.show = !target.show; + } else { + onApplyFilter(target.label || ""); + } + + updateSeries(); + uPlotInst.redraw(); + }; + + useEffect(updateSeries, [uPlotInst]); + + return ( +
+ {series.map(s => ( + +
  • Click to {s.show ? "hide" : "show"} the _stream.
  • +
  • {isMacOs() ? "Cmd" : "Ctrl"} + Click to filter by the _stream.
  • + + )} + > +
    +
    string)?.()}` }} + /> +
    {s.label}
    +
    + + ))} +
    + ); +}; + +export default BarHitsLegend; diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsLegend/style.scss b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsLegend/style.scss new file mode 100644 index 000000000..77b912c5e --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsLegend/style.scss @@ -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; + } + } +} diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsOptions/BarHitsOptions.tsx b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsOptions/BarHitsOptions.tsx new file mode 100644 index 000000000..a1fe73cf2 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsOptions/BarHitsOptions.tsx @@ -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 = ({ onChange }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const optionsButtonRef = useRef(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 ( +
    + +
    + ); +}; + +export default BarHitsOptions; diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsOptions/style.scss b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsOptions/style.scss new file mode 100644 index 000000000..899e6be01 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsOptions/style.scss @@ -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; + } + + } + } +} diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsTooltip/BarHitsTooltip.tsx b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsTooltip/BarHitsTooltip.tsx new file mode 100644 index 000000000..7a9ce4269 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsTooltip/BarHitsTooltip.tsx @@ -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 = ({ data, focusDataIdx, uPlotInst }) => { + const tooltipRef = useRef(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 ( +
    +
    + {tooltipData.values.map((item, i) => ( +
    + +

    + {item.label}: {item.value} +

    +
    + ))} +
    + {tooltipData.values.length > 1 && ( +
    +

    + Total records: {tooltipData.total} +

    +
    + )} +
    +
    + {tooltipData.timestamp} +
    +
    +
    + ); +}; + +export default BarHitsTooltip; diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsTooltip/style.scss b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsTooltip/style.scss new file mode 100644 index 000000000..ca3c54770 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsTooltip/style.scss @@ -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; + } +} diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/TooltipBarHitsChart.tsx b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/TooltipBarHitsChart.tsx deleted file mode 100644 index 2d96c32d0..000000000 --- a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/TooltipBarHitsChart.tsx +++ /dev/null @@ -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 = ({ focusDataIdx, uPlotInst }) => { - const tooltipRef = useRef(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 ( -
    -
    - Count of records: -

    - {tooltipData.value} -

    -
    -
    -
    - {tooltipData.timestamp} -
    -
    -
    - ); -}; - -export default TooltipBarHitsChart; diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/hooks/useBarHitsOptions.ts b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/hooks/useBarHitsOptions.ts index f72b6cacd..ab699db3e 100644 --- a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/hooks/useBarHitsOptions.ts +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/hooks/useBarHitsOptions.ts @@ -2,42 +2,81 @@ import { useMemo, useState } from "preact/compat"; import { getAxes, handleDestroy, setSelect } from "../../../../utils/uplot"; import dayjs from "dayjs"; 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 { barPaths } from "../../../../utils/uplot/bars"; import { useAppState } from "../../../../state/common/StateContext"; 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 { + data: AlignedData; + logHits: LogHits[]; xRange: MinMax; + bands?: Band[]; containerSize: { width: number, height: number }; setPlotScale: SetMinMax; 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 [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 dataIdx = u.cursor.idx ?? -1; 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(() => ({ series, + bands, width: containerSize.width || (window.innerWidth / 2), height: containerSize.height || 200, cursor: { @@ -55,6 +94,7 @@ const useBarHitsOptions = ({ xRange, containerSize, onReadyChart, setPlotScale } } }, hooks: { + drawSeries: [], ready: [onReadyChart], setCursor: [setCursor], setSelect: [setSelect(setPlotScale)], @@ -63,10 +103,11 @@ const useBarHitsOptions = ({ xRange, containerSize, onReadyChart, setPlotScale } legend: { show: false }, axes: getAxes([{}, { scale: "y" }]), tzDate: ts => dayjs(formatDateForNativeInput(dateFromSeconds(ts))).local().toDate(), - }), [isDarkTheme]); + }), [isDarkTheme, series, bands]); return { options, + series, focusDataIdx, }; }; diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/style.scss b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/style.scss index 509a5517e..0cd938e3f 100644 --- a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/style.scss +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/style.scss @@ -1,22 +1,18 @@ @use "src/styles/variables" as *; .vm-bar-hits-chart { - height: 100%; + position: relative; width: 100%; + height: 200px; + + &__wrapper { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + } &_panning { pointer-events: none; } - - &-tooltip { - opacity: 0; - pointer-events: none; - width: 240px; - gap: $padding-small; - - &_visible { - opacity: 1; - pointer-events: auto; - } - } } diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/types.ts b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/types.ts new file mode 100644 index 000000000..d2b9d0fa1 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/types.ts @@ -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; +} diff --git a/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/style.scss b/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/style.scss index 25554abcf..02d533aa0 100644 --- a/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/style.scss +++ b/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/style.scss @@ -25,6 +25,12 @@ $chart-tooltip-y: -1 * ($padding-global + $chart-tooltip-half-icon); user-select: text; pointer-events: none; + &_hits { + white-space: pre-wrap; + word-break: break-all; + width: auto; + } + &_sticky { pointer-events: auto; z-index: 99; @@ -74,10 +80,22 @@ $chart-tooltip-y: -1 * ($padding-global + $chart-tooltip-half-icon); justify-content: flex-start; gap: $padding-small; + &_margin-bottom { + margin-bottom: $padding-global; + } + + &_margin-top { + margin-top: $padding-global; + } + &__marker { width: $font-size; height: $font-size; border: 1px solid rgba($color-white, 0.5); + + &_tranparent { + opacity: 0; + } } &__value { diff --git a/app/vmui/packages/vmui/src/components/Main/Popper/style.scss b/app/vmui/packages/vmui/src/components/Main/Popper/style.scss index 8e5dc5146..92c0b9d67 100644 --- a/app/vmui/packages/vmui/src/components/Main/Popper/style.scss +++ b/app/vmui/packages/vmui/src/components/Main/Popper/style.scss @@ -32,19 +32,19 @@ &-header { display: grid; - grid-template-columns: 1fr auto; - gap: $padding-small; + grid-template-columns: 1fr 25px; + gap: $padding-global; align-items: center; justify-content: space-between; 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; color: $color-text; border-bottom: $border-divider; margin-bottom: $padding-global; - min-height: 51px; &__title { + font-size: $font-size-small; font-weight: bold; user-select: none; } diff --git a/app/vmui/packages/vmui/src/constants/palette.ts b/app/vmui/packages/vmui/src/constants/palette.ts index 11f78d637..3cb451615 100644 --- a/app/vmui/packages/vmui/src/constants/palette.ts +++ b/app/vmui/packages/vmui/src/constants/palette.ts @@ -15,7 +15,13 @@ export const darkPalette = { "box-shadow-popper": "rgba(0, 0, 0, 0.2) 0px 2px 8px 0px", "border-divider": "1px solid rgba(99, 110, 123, 0.5)", "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 = { @@ -35,5 +41,12 @@ export const lightPalette = { "box-shadow-popper": "rgba(0, 0, 0, 0.1) 0px 2px 8px 0px", "border-divider": "1px solid rgba(0, 0, 0, 0.15)", "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", + }; diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx index e0e12f22c..a1b02334c 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx @@ -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 useStateSearchParams from "../../hooks/useStateSearchParams"; import useSearchParamsFromObject from "../../hooks/useSearchParamsFromObject"; @@ -9,7 +9,6 @@ import Alert from "../../components/Main/Alert/Alert"; import ExploreLogsHeader from "./ExploreLogsHeader/ExploreLogsHeader"; import "./style.scss"; import { ErrorTypes, TimeParams } from "../../types"; -import { useState } from "react"; import { useTimeState } from "../../state/time/TimeStateContext"; import { getFromStorage, saveToStorage } from "../../utils/storage"; import ExploreLogsBarChart from "./ExploreLogsBarChart/ExploreLogsBarChart"; @@ -27,10 +26,12 @@ const ExploreLogs: FC = () => { const [limit, setLimit] = useStateSearchParams(defaultLimit, "limit"); const [query, setQuery] = useStateSearchParams("*", "query"); + const [tmpQuery, setTmpQuery] = useState(""); const [period, setPeriod] = useState(periodState); + const [queryError, setQueryError] = useState(""); + const { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query, limit); const { fetchLogHits, ...dataLogHits } = useFetchLogHits(serverUrl, query); - const [queryError, setQueryError] = useState(""); const getPeriod = useCallback(() => { const relativeTimeOpts = relativeTimeOptions.find(d => d.id === relativeTime); @@ -44,6 +45,7 @@ const ExploreLogs: FC = () => { setQueryError(ErrorTypes.validQuery); return; } + setQueryError(""); const newPeriod = getPeriod(); setPeriod(newPeriod); @@ -64,23 +66,33 @@ const ExploreLogs: FC = () => { saveToStorage("LOGS_LIMIT", `${limit}`); }; + const handleApplyFilter = (val: string) => { + setQuery(prev => `_stream: ${val === "other" ? "{}" : val} AND (${prev})`); + }; + + const handleUpdateQuery = () => { + setQuery(tmpQuery); + handleRunQuery(); + }; + useEffect(() => { if (query) handleRunQuery(); }, [periodState]); useEffect(() => { - setQueryError(""); + handleRunQuery(); + setTmpQuery(query); }, [query]); return (
    {isLoading && } {error && {error}} @@ -89,6 +101,7 @@ const ExploreLogs: FC = () => { {...dataLogHits} query={query} period={period} + onApplyFilter={handleApplyFilter} isLoading={isLoading ? false : dataLogHits.isLoading} /> )} diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/ExploreLogsBarChart.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/ExploreLogsBarChart.tsx index 5c0e0fcd3..40b2cae0f 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/ExploreLogsBarChart.tsx +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/ExploreLogsBarChart.tsx @@ -17,19 +17,34 @@ interface Props { period: TimeParams; error?: string; isLoading: boolean; + onApplyFilter: (value: string) => void; } -const ExploreLogsBarChart: FC = ({ logHits, period, error, isLoading }) => { +const ExploreLogsBarChart: FC = ({ logHits, period, error, isLoading, onApplyFilter }) => { const { isMobile } = useDeviceDetect(); 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 hits = logHits[0]; - if (!hits) return [[], []] as AlignedData; - const { values, timestamps } = hits; - const xAxis = timestamps.map(t => t ? dayjs(t).unix() : null).filter(Boolean); - const yAxis = values.map(v => v || null); - return [xAxis, yAxis] as AlignedData; + if (!logHits.length) return [[], []] as AlignedData; + const timestamps = Array.from(new Set(logHits.map(l => l.timestamps).flat())); + const xAxis = getXAxis(timestamps); + const yAxes = getYAxes(logHits, timestamps); + return [xAxis, ...yAxes] as AlignedData; }, [logHits]); const noDataMessage: string = useMemo(() => { @@ -75,9 +90,11 @@ const ExploreLogsBarChart: FC = ({ logHits, period, error, isLoading }) = {data && ( )} diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/style.scss b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/style.scss index d1e839bea..1c0b9c9de 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/style.scss +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/style.scss @@ -5,7 +5,6 @@ display: flex; align-items: center; justify-content: center; - height: 200px; padding: 0 0 0 $padding-small !important; width: calc(100vw - ($padding-medium * 2)); diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx index cfc604237..472b4dfb0 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx @@ -88,6 +88,9 @@ const ExploreLogsBody: FC = ({ data }) => { items={tabs} onChange={handleChangeTab} /> +
    + Total logs returned: {data.length} +
    {activeTab === DisplayType.table && (
    diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/style.scss b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/style.scss index 170cf678f..22952fc6e 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/style.scss +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/style.scss @@ -13,6 +13,14 @@ align-items: center; gap: $padding-small; } + + &__log-info { + flex-grow: 1; + text-align: right; + padding-right: $padding-global; + color: $color-text-secondary; + + } } &__empty { diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogHits.ts b/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogHits.ts index 0ef23d544..b9953f7ad 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogHits.ts +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogHits.ts @@ -34,10 +34,46 @@ export const useFetchLogHits = (server: string, query: string) => { step: `${step}ms`, start: start.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) => { abortControllerRef.current.abort(); abortControllerRef.current = new AbortController(); @@ -66,7 +102,7 @@ export const useFetchLogHits = (server: string, query: string) => { setError(error); } - setLogHits(!hits ? [] : hits); + setLogHits(!hits ? [] : getHitsWithTop(hits)); } catch (e) { if (e instanceof Error && e.name !== "AbortError") { setError(String(e)); diff --git a/app/vmui/packages/vmui/src/utils/uplot/bars.ts b/app/vmui/packages/vmui/src/utils/uplot/bars.ts deleted file mode 100644 index be7fc1444..000000000 --- a/app/vmui/packages/vmui/src/utils/uplot/bars.ts +++ /dev/null @@ -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; -}; - diff --git a/app/vmui/packages/vmui/src/utils/uplot/paths.ts b/app/vmui/packages/vmui/src/utils/uplot/paths.ts new file mode 100644 index 000000000..afbc1c949 --- /dev/null +++ b/app/vmui/packages/vmui/src/utils/uplot/paths.ts @@ -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; + diff --git a/app/vmui/packages/vmui/src/utils/uplot/stack.ts b/app/vmui/packages/vmui/src/utils/uplot/stack.ts new file mode 100644 index 000000000..b3f3559c5 --- /dev/null +++ b/app/vmui/packages/vmui/src/utils/uplot/stack.ts @@ -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; diff --git a/docs/VictoriaLogs/CHANGELOG.md b/docs/VictoriaLogs/CHANGELOG.md index b02c0a909..77f69001d 100644 --- a/docs/VictoriaLogs/CHANGELOG.md +++ b/docs/VictoriaLogs/CHANGELOG.md @@ -16,6 +16,8 @@ according to [these docs](https://docs.victoriametrics.com/victorialogs/quicksta ## 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 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.