mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-12-12 12:46:23 +01:00
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/).
(cherry picked from commit 04c2232e45
)
This commit is contained in:
parent
c885f3e7dc
commit
b4aec9ee05
@ -38,6 +38,7 @@ export interface Logs {
|
||||
export interface LogHits {
|
||||
timestamps: string[];
|
||||
values: number[];
|
||||
total?: number;
|
||||
fields: {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
@ -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<Props> = ({ data, period, setPeriod }) => {
|
||||
const BarHitsChart: FC<Props> = ({ logHits, data: _data, period, setPeriod, onApplyFilter }) => {
|
||||
const [containerRef, containerSize] = useElementSize();
|
||||
const uPlotRef = useRef<HTMLDivElement>(null);
|
||||
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 { 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<Props> = ({ data, period, setPeriod }) => {
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-bar-hits-chart": true,
|
||||
"vm-bar-hits-chart_panning": isPanning
|
||||
})}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="vm-bar-hits-chart__wrapper">
|
||||
<div
|
||||
className="vm-line-chart__u-plot"
|
||||
ref={uPlotRef}
|
||||
/>
|
||||
<TooltipBarHitsChart
|
||||
uPlotInst={uPlotInst}
|
||||
focusDataIdx={focusDataIdx}
|
||||
/>
|
||||
className={classNames({
|
||||
"vm-bar-hits-chart": true,
|
||||
"vm-bar-hits-chart_panning": isPanning
|
||||
})}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div
|
||||
className="vm-line-chart__u-plot"
|
||||
ref={uPlotRef}
|
||||
/>
|
||||
<BarHitsTooltip
|
||||
uPlotInst={uPlotInst}
|
||||
data={_data}
|
||||
focusDataIdx={focusDataIdx}
|
||||
/>
|
||||
</div>
|
||||
<BarHitsOptions onChange={setGraphOptions}/>
|
||||
{uPlotInst && (
|
||||
<BarHitsLegend
|
||||
uPlotInst={uPlotInst}
|
||||
onApplyFilter={onApplyFilter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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",
|
||||
|
||||
};
|
||||
|
@ -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<TimeParams>(periodState);
|
||||
const [queryError, setQueryError] = useState<ErrorTypes | string>("");
|
||||
|
||||
const { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query, limit);
|
||||
const { fetchLogHits, ...dataLogHits } = useFetchLogHits(serverUrl, query);
|
||||
const [queryError, setQueryError] = useState<ErrorTypes | string>("");
|
||||
|
||||
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 (
|
||||
<div className="vm-explore-logs">
|
||||
<ExploreLogsHeader
|
||||
query={query}
|
||||
query={tmpQuery}
|
||||
error={queryError}
|
||||
limit={limit}
|
||||
onChange={setQuery}
|
||||
onChange={setTmpQuery}
|
||||
onChangeLimit={handleChangeLimit}
|
||||
onRun={handleRunQuery}
|
||||
onRun={handleUpdateQuery}
|
||||
/>
|
||||
{isLoading && <Spinner message={"Loading logs..."}/>}
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
@ -89,6 +101,7 @@ const ExploreLogs: FC = () => {
|
||||
{...dataLogHits}
|
||||
query={query}
|
||||
period={period}
|
||||
onApplyFilter={handleApplyFilter}
|
||||
isLoading={isLoading ? false : dataLogHits.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
@ -17,19 +17,34 @@ interface Props {
|
||||
period: TimeParams;
|
||||
error?: string;
|
||||
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 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<Props> = ({ logHits, period, error, isLoading }) =
|
||||
|
||||
{data && (
|
||||
<BarHitsChart
|
||||
logHits={logHits}
|
||||
data={data}
|
||||
period={period}
|
||||
setPeriod={setPeriod}
|
||||
onApplyFilter={onApplyFilter}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
@ -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));
|
||||
|
||||
|
@ -88,6 +88,9 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data }) => {
|
||||
items={tabs}
|
||||
onChange={handleChangeTab}
|
||||
/>
|
||||
<div className="vm-explore-logs-body-header__log-info">
|
||||
Total logs returned: <b>{data.length}</b>
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === DisplayType.table && (
|
||||
<div className="vm-explore-logs-body-header__settings">
|
||||
|
@ -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 {
|
||||
|
@ -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));
|
||||
|
@ -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;
|
||||
};
|
||||
|
38
app/vmui/packages/vmui/src/utils/uplot/paths.ts
Normal file
38
app/vmui/packages/vmui/src/utils/uplot/paths.ts
Normal 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;
|
||||
|
33
app/vmui/packages/vmui/src/utils/uplot/stack.ts
Normal file
33
app/vmui/packages/vmui/src/utils/uplot/stack.ts
Normal 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;
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user