mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-21 14:44:00 +00: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/).
This commit is contained in:
parent
2e16732fdb
commit
04c2232e45
25 changed files with 734 additions and 172 deletions
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,21 +88,31 @@ const BarHitsChart: FC<Props> = ({ data, period, setPeriod }) => {
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="vm-bar-hits-chart__wrapper">
|
||||||
className={classNames({
|
|
||||||
"vm-bar-hits-chart": true,
|
|
||||||
"vm-bar-hits-chart_panning": isPanning
|
|
||||||
})}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="vm-line-chart__u-plot"
|
className={classNames({
|
||||||
ref={uPlotRef}
|
"vm-bar-hits-chart": true,
|
||||||
/>
|
"vm-bar-hits-chart_panning": isPanning
|
||||||
<TooltipBarHitsChart
|
})}
|
||||||
uPlotInst={uPlotInst}
|
ref={containerRef}
|
||||||
focusDataIdx={focusDataIdx}
|
>
|
||||||
/>
|
<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>
|
</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 { 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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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
|
## 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.
|
||||||
|
|
Loading…
Reference in a new issue