mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-12-12 12:46:23 +01:00
vmui/logs: add bar chart (#6461)
- Added a bar chart displaying the number of log entries over a time
range.
#6404
- When `_msg` is empty, all fields are displayed in a single line.
- Added double quotes when copying pairs: `key: "value"`.
- Minor style adjustments.
(cherry picked from commit 32fbffedd9
)
This commit is contained in:
parent
5be2f2c4e4
commit
88650abf97
@ -1,2 +1,5 @@
|
|||||||
export const getLogsUrl = (server: string): string =>
|
export const getLogsUrl = (server: string): string =>
|
||||||
`${server}/select/logsql/query`;
|
`${server}/select/logsql/query`;
|
||||||
|
|
||||||
|
export const getLogHitsUrl = (server: string): string =>
|
||||||
|
`${server}/select/logsql/hits`;
|
||||||
|
@ -34,3 +34,11 @@ export interface Logs {
|
|||||||
_time: string;
|
_time: string;
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LogHits {
|
||||||
|
timestamps: string[];
|
||||||
|
values: number[];
|
||||||
|
fields: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -0,0 +1,76 @@
|
|||||||
|
import React, { FC, 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 { 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";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: AlignedData;
|
||||||
|
period: TimeParams;
|
||||||
|
setPeriod: ({ from, to }: { from: Date, to: Date }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BarHitsChart: FC<Props> = ({ data, period, setPeriod }) => {
|
||||||
|
const [containerRef, containerSize] = useElementSize();
|
||||||
|
const uPlotRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [uPlotInst, setUPlotInst] = useState<uPlot>();
|
||||||
|
|
||||||
|
const { xRange, setPlotScale } = usePlotScale({ period, setPeriod });
|
||||||
|
const { onReadyChart, isPanning } = useReadyChart(setPlotScale);
|
||||||
|
useZoomChart({ uPlotInst, xRange, setPlotScale });
|
||||||
|
const { options, focusDataIdx } = useBarHitsOptions({ xRange, containerSize, onReadyChart, setPlotScale });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uPlotRef.current) return;
|
||||||
|
const uplot = new uPlot(options, data, uPlotRef.current);
|
||||||
|
setUPlotInst(uplot);
|
||||||
|
return () => uplot.destroy();
|
||||||
|
}, [uPlotRef.current, options]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uPlotInst) return;
|
||||||
|
uPlotInst.scales.x.range = () => [xRange.min, xRange.max];
|
||||||
|
uPlotInst.redraw();
|
||||||
|
}, [xRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uPlotInst) return;
|
||||||
|
uPlotInst.setSize(containerSize);
|
||||||
|
uPlotInst.redraw();
|
||||||
|
}, [containerSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uPlotInst) return;
|
||||||
|
uPlotInst.setData(data);
|
||||||
|
uPlotInst.redraw();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames({
|
||||||
|
"vm-bar-hits-chart": true,
|
||||||
|
"vm-bar-hits-chart_panning": isPanning
|
||||||
|
})}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="vm-line-chart__u-plot"
|
||||||
|
ref={uPlotRef}
|
||||||
|
/>
|
||||||
|
<TooltipBarHitsChart
|
||||||
|
uPlotInst={uPlotInst}
|
||||||
|
focusDataIdx={focusDataIdx}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BarHitsChart;
|
@ -0,0 +1,89 @@
|
|||||||
|
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;
|
@ -0,0 +1,74 @@
|
|||||||
|
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 { getCssVariable } from "../../../../utils/theme";
|
||||||
|
import { barPaths } from "../../../../utils/uplot/bars";
|
||||||
|
import { useAppState } from "../../../../state/common/StateContext";
|
||||||
|
import { MinMax, SetMinMax } from "../../../../types";
|
||||||
|
|
||||||
|
interface UseGetBarHitsOptionsArgs {
|
||||||
|
xRange: MinMax;
|
||||||
|
containerSize: { width: number, height: number };
|
||||||
|
setPlotScale: SetMinMax;
|
||||||
|
onReadyChart: (u: uPlot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useBarHitsOptions = ({ xRange, containerSize, onReadyChart, setPlotScale }: 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 options: Options = useMemo(() => ({
|
||||||
|
series,
|
||||||
|
width: containerSize.width || (window.innerWidth / 2),
|
||||||
|
height: containerSize.height || 200,
|
||||||
|
cursor: {
|
||||||
|
points: {
|
||||||
|
width: (u, seriesIdx, size) => size / 4,
|
||||||
|
size: (u, seriesIdx) => (u.series?.[seriesIdx]?.points?.size || 1) * 2.5,
|
||||||
|
stroke: (u, seriesIdx) => `${series?.[seriesIdx]?.stroke || "#ffffff"}`,
|
||||||
|
fill: () => "#ffffff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
time: true,
|
||||||
|
range: () => [xRange.min, xRange.max]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
ready: [onReadyChart],
|
||||||
|
setCursor: [setCursor],
|
||||||
|
setSelect: [setSelect(setPlotScale)],
|
||||||
|
destroy: [handleDestroy],
|
||||||
|
},
|
||||||
|
legend: { show: false },
|
||||||
|
axes: getAxes([{}, { scale: "y" }]),
|
||||||
|
tzDate: ts => dayjs(formatDateForNativeInput(dateFromSeconds(ts))).local().toDate(),
|
||||||
|
}), [isDarkTheme]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
focusDataIdx,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useBarHitsOptions;
|
@ -0,0 +1,22 @@
|
|||||||
|
@use "src/styles/variables" as *;
|
||||||
|
|
||||||
|
.vm-bar-hits-chart {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&_panning {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-tooltip {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 240px;
|
||||||
|
gap: $padding-small;
|
||||||
|
|
||||||
|
&_visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2
app/vmui/packages/vmui/src/constants/logs.ts
Normal file
2
app/vmui/packages/vmui/src/constants/logs.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const LOGS_ENTRIES_LIMIT = 50;
|
||||||
|
export const LOGS_BARS_VIEW = 20;
|
@ -14,7 +14,8 @@ export const darkPalette = {
|
|||||||
"box-shadow": "rgba(0, 0, 0, 0.16) 1px 2px 6px",
|
"box-shadow": "rgba(0, 0, 0, 0.16) 1px 2px 6px",
|
||||||
"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)"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const lightPalette = {
|
export const lightPalette = {
|
||||||
@ -33,5 +34,6 @@ export const lightPalette = {
|
|||||||
"box-shadow": "rgba(0, 0, 0, 0.08) 1px 2px 6px",
|
"box-shadow": "rgba(0, 0, 0, 0.08) 1px 2px 6px",
|
||||||
"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)"
|
||||||
};
|
};
|
||||||
|
@ -12,9 +12,12 @@ import { ErrorTypes } from "../../types";
|
|||||||
import { useState } from "react";
|
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 { useFetchLogHits } from "./hooks/useFetchLogHits";
|
||||||
|
import { LOGS_ENTRIES_LIMIT } from "../../constants/logs";
|
||||||
|
|
||||||
const storageLimit = Number(getFromStorage("LOGS_LIMIT"));
|
const storageLimit = Number(getFromStorage("LOGS_LIMIT"));
|
||||||
const defaultLimit = isNaN(storageLimit) ? 50 : storageLimit;
|
const defaultLimit = isNaN(storageLimit) ? LOGS_ENTRIES_LIMIT : storageLimit;
|
||||||
|
|
||||||
const ExploreLogs: FC = () => {
|
const ExploreLogs: FC = () => {
|
||||||
const { serverUrl } = useAppState();
|
const { serverUrl } = useAppState();
|
||||||
@ -24,6 +27,7 @@ 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 { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query, limit);
|
const { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query, limit);
|
||||||
|
const { fetchLogHits, ...dataLogHits } = useFetchLogHits(serverUrl, query);
|
||||||
const [queryError, setQueryError] = useState<ErrorTypes | string>("");
|
const [queryError, setQueryError] = useState<ErrorTypes | string>("");
|
||||||
const [loaded, isLoaded] = useState(false);
|
const [loaded, isLoaded] = useState(false);
|
||||||
const [markdownParsing, setMarkdownParsing] = useState(getFromStorage("LOGS_MARKDOWN") === "true");
|
const [markdownParsing, setMarkdownParsing] = useState(getFromStorage("LOGS_MARKDOWN") === "true");
|
||||||
@ -37,6 +41,7 @@ const ExploreLogs: FC = () => {
|
|||||||
fetchLogs().then(() => {
|
fetchLogs().then(() => {
|
||||||
isLoaded(true);
|
isLoaded(true);
|
||||||
});
|
});
|
||||||
|
fetchLogHits();
|
||||||
|
|
||||||
setSearchParamsFromKeys( {
|
setSearchParamsFromKeys( {
|
||||||
query,
|
query,
|
||||||
@ -79,6 +84,11 @@ const ExploreLogs: FC = () => {
|
|||||||
/>
|
/>
|
||||||
{isLoading && <Spinner />}
|
{isLoading && <Spinner />}
|
||||||
{error && <Alert variant="error">{error}</Alert>}
|
{error && <Alert variant="error">{error}</Alert>}
|
||||||
|
<ExploreLogsBarChart
|
||||||
|
query={query}
|
||||||
|
loaded={loaded}
|
||||||
|
{...dataLogHits}
|
||||||
|
/>
|
||||||
<ExploreLogsBody
|
<ExploreLogsBody
|
||||||
data={logs}
|
data={logs}
|
||||||
loaded={loaded}
|
loaded={loaded}
|
||||||
|
@ -0,0 +1,82 @@
|
|||||||
|
import React, { FC, useMemo } from "preact/compat";
|
||||||
|
import "./style.scss";
|
||||||
|
import useDeviceDetect from "../../../hooks/useDeviceDetect";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { LogHits } from "../../../api/types";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { useTimeDispatch, useTimeState } from "../../../state/time/TimeStateContext";
|
||||||
|
import { AlignedData } from "uplot";
|
||||||
|
import BarHitsChart from "../../../components/Chart/BarHitsChart/BarHitsChart";
|
||||||
|
import Alert from "../../../components/Main/Alert/Alert";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
query: string;
|
||||||
|
logHits: LogHits[];
|
||||||
|
error?: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExploreLogsBarChart: FC<Props> = ({ logHits, error, loaded }) => {
|
||||||
|
const { isMobile } = useDeviceDetect();
|
||||||
|
const { period } = useTimeState();
|
||||||
|
const timeDispatch = useTimeDispatch();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}, [logHits]);
|
||||||
|
|
||||||
|
const noDataMessage: string = useMemo(() => {
|
||||||
|
const noData = data.every(d => d.length === 0);
|
||||||
|
const noTimestamps = data[0].length === 0;
|
||||||
|
const noValues = data[1].length === 0;
|
||||||
|
if (noData) {
|
||||||
|
return "No logs volume available\nNo volume information available for the current queries and time range.";
|
||||||
|
} else if (noTimestamps) {
|
||||||
|
return "No timestamp information available for the current queries and time range.";
|
||||||
|
} else if (noValues) {
|
||||||
|
return "No value information available for the current queries and time range.";
|
||||||
|
} return "";
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const setPeriod = ({ from, to }: {from: Date, to: Date}) => {
|
||||||
|
timeDispatch({ type: "SET_PERIOD", payload: { from, to } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={classNames({
|
||||||
|
"vm-explore-logs-chart": true,
|
||||||
|
"vm-block": true,
|
||||||
|
"vm-block_mobile": isMobile,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{!error && loaded && noDataMessage && (
|
||||||
|
<div className="vm-explore-logs-chart__empty">
|
||||||
|
<Alert variant="info">{noDataMessage}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && loaded && noDataMessage && (
|
||||||
|
<div className="vm-explore-logs-chart__empty">
|
||||||
|
<Alert variant="error">{error}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<BarHitsChart
|
||||||
|
data={data}
|
||||||
|
period={period}
|
||||||
|
setPeriod={setPeriod}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExploreLogsBarChart;
|
@ -0,0 +1,21 @@
|
|||||||
|
@use "src/styles/variables" as *;
|
||||||
|
|
||||||
|
.vm-explore-logs-chart {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
padding: 0 0 0 $padding-small !important;
|
||||||
|
width: calc(100vw - ($padding-medium * 2));
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: calc(100vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
position: absolute;
|
||||||
|
transform: translateY(-25px);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
@ -48,7 +48,7 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, loaded, markdownParsin
|
|||||||
...item,
|
...item,
|
||||||
_vmui_time: item._time ? dayjs(item._time).tz().format(`${DATE_TIME_FORMAT}.SSS`) : "",
|
_vmui_time: item._time ? dayjs(item._time).tz().format(`${DATE_TIME_FORMAT}.SSS`) : "",
|
||||||
_vmui_data: JSON.stringify(item, null, 2),
|
_vmui_data: JSON.stringify(item, null, 2),
|
||||||
_vmui_markdown: marked(item._msg.replace(/```/g, "\n```\n")) as string,
|
_vmui_markdown: item._msg ? marked(item._msg.replace(/```/g, "\n```\n")) as string : ""
|
||||||
})) as Logs[], [data, timezone]);
|
})) as Logs[], [data, timezone]);
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
|
@ -7,6 +7,8 @@ import { groupByMultipleKeys } from "../../../utils/array";
|
|||||||
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
|
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
|
||||||
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
|
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
|
||||||
import GroupLogsItem from "./GroupLogsItem";
|
import GroupLogsItem from "./GroupLogsItem";
|
||||||
|
import { useAppState } from "../../../state/common/StateContext";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
interface TableLogsProps {
|
interface TableLogsProps {
|
||||||
logs: Logs[];
|
logs: Logs[];
|
||||||
@ -15,6 +17,7 @@ interface TableLogsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GroupLogs: FC<TableLogsProps> = ({ logs, markdownParsing }) => {
|
const GroupLogs: FC<TableLogsProps> = ({ logs, markdownParsing }) => {
|
||||||
|
const { isDarkTheme } = useAppState();
|
||||||
const copyToClipboard = useCopyToClipboard();
|
const copyToClipboard = useCopyToClipboard();
|
||||||
|
|
||||||
const [copied, setCopied] = useState<string | null>(null);
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
@ -32,7 +35,7 @@ const GroupLogs: FC<TableLogsProps> = ({ logs, markdownParsing }) => {
|
|||||||
|
|
||||||
const handleClickByPair = (pair: string) => async (e: MouseEvent<HTMLDivElement>) => {
|
const handleClickByPair = (pair: string) => async (e: MouseEvent<HTMLDivElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const isCopied = await copyToClipboard(`${pair}`);
|
const isCopied = await copyToClipboard(`${pair.replace(/=/, ": ")}`);
|
||||||
if (isCopied) {
|
if (isCopied) {
|
||||||
setCopied(pair);
|
setCopied(pair);
|
||||||
}
|
}
|
||||||
@ -63,7 +66,10 @@ const GroupLogs: FC<TableLogsProps> = ({ logs, markdownParsing }) => {
|
|||||||
placement={"top-center"}
|
placement={"top-center"}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="vm-group-logs-section-keys__pair"
|
className={classNames({
|
||||||
|
"vm-group-logs-section-keys__pair": true,
|
||||||
|
"vm-group-logs-section-keys__pair_dark": isDarkTheme
|
||||||
|
})}
|
||||||
onClick={handleClickByPair(pair)}
|
onClick={handleClickByPair(pair)}
|
||||||
>
|
>
|
||||||
{pair}
|
{pair}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React, { FC, useEffect, useState } from "preact/compat";
|
import React, { FC, useEffect, useMemo, useState } from "preact/compat";
|
||||||
import { Logs } from "../../../api/types";
|
import { Logs } from "../../../api/types";
|
||||||
import "./style.scss";
|
import "./style.scss";
|
||||||
import useBoolean from "../../../hooks/useBoolean";
|
import useBoolean from "../../../hooks/useBoolean";
|
||||||
import Button from "../../../components/Main/Button/Button";
|
import Button from "../../../components/Main/Button/Button";
|
||||||
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
|
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
|
||||||
import { ArrowDropDownIcon, CopyIcon } from "../../../components/Main/Icons";
|
import { ArrowDownIcon, CopyIcon } from "../../../components/Main/Icons";
|
||||||
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
|
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
@ -23,6 +23,16 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
|
|||||||
const fields = Object.entries(log).filter(([key]) => !excludeKeys.includes(key));
|
const fields = Object.entries(log).filter(([key]) => !excludeKeys.includes(key));
|
||||||
const hasFields = fields.length > 0;
|
const hasFields = fields.length > 0;
|
||||||
|
|
||||||
|
const displayMessage = useMemo(() => {
|
||||||
|
if (log._msg) return log._msg;
|
||||||
|
if (!hasFields) return;
|
||||||
|
const dataObject = fields.reduce<{[key: string]: string}>((obj, [key, value]) => {
|
||||||
|
obj[key] = value;
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
return JSON.stringify(dataObject);
|
||||||
|
}, [log, fields, hasFields]);
|
||||||
|
|
||||||
const copyToClipboard = useCopyToClipboard();
|
const copyToClipboard = useCopyToClipboard();
|
||||||
const [copied, setCopied] = useState<number | null>(null);
|
const [copied, setCopied] = useState<number | null>(null);
|
||||||
|
|
||||||
@ -56,7 +66,7 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
|
|||||||
"vm-group-logs-row-content__arrow_open": isOpenFields,
|
"vm-group-logs-row-content__arrow_open": isOpenFields,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ArrowDropDownIcon/>
|
<ArrowDownIcon/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@ -70,11 +80,12 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
|
|||||||
<div
|
<div
|
||||||
className={classNames({
|
className={classNames({
|
||||||
"vm-group-logs-row-content__msg": true,
|
"vm-group-logs-row-content__msg": true,
|
||||||
"vm-group-logs-row-content__msg_missing": !log._msg
|
"vm-group-logs-row-content__msg_empty-msg": !log._msg,
|
||||||
|
"vm-group-logs-row-content__msg_missing": !displayMessage
|
||||||
})}
|
})}
|
||||||
dangerouslySetInnerHTML={markdownParsing && log._vmui_markdown ? { __html: log._vmui_markdown } : undefined}
|
dangerouslySetInnerHTML={markdownParsing && log._vmui_markdown ? { __html: log._vmui_markdown } : undefined}
|
||||||
>
|
>
|
||||||
{log._msg || "message missing"}
|
{displayMessage || "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasFields && isOpenFields && (
|
{hasFields && isOpenFields && (
|
||||||
@ -94,7 +105,7 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
|
|||||||
color="gray"
|
color="gray"
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={<CopyIcon/>}
|
startIcon={<CopyIcon/>}
|
||||||
onClick={createCopyHandler(`${key}: ${value}`, i)}
|
onClick={createCopyHandler(`${key}: "${value}"`, i)}
|
||||||
ariaLabel="copy to clipboard"
|
ariaLabel="copy to clipboard"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
background-color: lighten($color-tropical-blue, 6%);
|
background-color: lighten($color-tropical-blue, 6%);
|
||||||
color: darken($color-dodger-blue, 20%);
|
color: darken($color-dodger-blue, 20%);
|
||||||
border-radius: $border-radius-medium;
|
border-radius: $border-radius-medium;
|
||||||
transition: background-color 0.3s ease-in, transform 0.1s ease-in;;
|
transition: background-color 0.3s ease-in, transform 0.1s ease-in, opacity 0.3s ease-in;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $color-tropical-blue;
|
background-color: $color-tropical-blue;
|
||||||
@ -30,6 +30,17 @@
|
|||||||
&:active {
|
&:active {
|
||||||
transform: translate(0, 3px);
|
transform: translate(0, 3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&_dark {
|
||||||
|
color: lighten($color-dodger-blue, 20%);
|
||||||
|
background-color: $color-background-body;
|
||||||
|
opacity: 0.8;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $color-background-body;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,9 +56,8 @@
|
|||||||
&-content {
|
&-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(180px, max-content) 1fr;
|
grid-template-columns: auto minmax(180px, max-content) 1fr;
|
||||||
gap: $padding-small;
|
padding: $padding-global 0;
|
||||||
padding: $padding-global;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease-in;
|
transition: background-color 0.2s ease-in;
|
||||||
|
|
||||||
@ -56,14 +66,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__arrow {
|
&__arrow {
|
||||||
position: absolute;
|
|
||||||
top: $padding-global;
|
|
||||||
left: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 14px;
|
width: 16px;
|
||||||
height: 14px;
|
height: $font-size;
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
transition: transform 0.2s ease-out;
|
transition: transform 0.2s ease-out;
|
||||||
|
|
||||||
@ -76,6 +83,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
margin-right: $padding-small;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
@ -91,6 +99,12 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
|
|
||||||
|
&_empty-msg {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
&_missing {
|
&_missing {
|
||||||
color: $color-text-disabled;
|
color: $color-text-disabled;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
@ -0,0 +1,76 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from "preact/compat";
|
||||||
|
import { getLogHitsUrl } from "../../../api/logs";
|
||||||
|
import { ErrorTypes } from "../../../types";
|
||||||
|
import { LogHits } from "../../../api/types";
|
||||||
|
import { useTimeState } from "../../../state/time/TimeStateContext";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { LOGS_BARS_VIEW } from "../../../constants/logs";
|
||||||
|
|
||||||
|
export const useFetchLogHits = (server: string, query: string) => {
|
||||||
|
const { period } = useTimeState();
|
||||||
|
const [logHits, setLogHits] = useState<LogHits[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<ErrorTypes | string>();
|
||||||
|
const abortControllerRef = useRef(new AbortController());
|
||||||
|
|
||||||
|
const url = useMemo(() => getLogHitsUrl(server), [server]);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
const start = dayjs(period.start * 1000);
|
||||||
|
const end = dayjs(period.end * 1000);
|
||||||
|
const totalSeconds = end.diff(start, "milliseconds");
|
||||||
|
const step = Math.ceil(totalSeconds / LOGS_BARS_VIEW) || 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({
|
||||||
|
query: query.trim(),
|
||||||
|
step: `${step}ms`,
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}, [query, period]);
|
||||||
|
|
||||||
|
const fetchLogHits = useCallback(async () => {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
const { signal } = abortControllerRef.current;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { ...options, signal });
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
const text = await response.text();
|
||||||
|
setError(text);
|
||||||
|
setLogHits([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const hits = data?.hits as LogHits[];
|
||||||
|
if (!hits) {
|
||||||
|
setError("Error: No 'hits' field in response");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogHits(hits);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name !== "AbortError") {
|
||||||
|
setError(String(e));
|
||||||
|
console.error(e);
|
||||||
|
setLogHits([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// setIsLoading(false);
|
||||||
|
}, [url, options]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logHits,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchLogHits,
|
||||||
|
};
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from "preact/compat";
|
import { useCallback, useMemo, useRef, useState } from "preact/compat";
|
||||||
import { getLogsUrl } from "../../../api/logs";
|
import { getLogsUrl } from "../../../api/logs";
|
||||||
import { ErrorTypes } from "../../../types";
|
import { ErrorTypes } from "../../../types";
|
||||||
import { Logs } from "../../../api/types";
|
import { Logs } from "../../../api/types";
|
||||||
@ -10,6 +10,7 @@ export const useFetchLogs = (server: string, query: string, limit: number) => {
|
|||||||
const [logs, setLogs] = useState<Logs[]>([]);
|
const [logs, setLogs] = useState<Logs[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<ErrorTypes | string>();
|
const [error, setError] = useState<ErrorTypes | string>();
|
||||||
|
const abortControllerRef = useRef(new AbortController());
|
||||||
|
|
||||||
const url = useMemo(() => getLogsUrl(server), [server]);
|
const url = useMemo(() => getLogsUrl(server), [server]);
|
||||||
|
|
||||||
@ -35,11 +36,14 @@ export const useFetchLogs = (server: string, query: string, limit: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
const { signal } = abortControllerRef.current;
|
||||||
const limit = Number(options.body.get("limit"));
|
const limit = Number(options.body.get("limit"));
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, options);
|
const response = await fetch(url, { ...options, signal });
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
@ -53,10 +57,10 @@ export const useFetchLogs = (server: string, query: string, limit: number) => {
|
|||||||
const data = lines.map(parseLineToJSON).filter(line => line) as Logs[];
|
const data = lines.map(parseLineToJSON).filter(line => line) as Logs[];
|
||||||
setLogs(data);
|
setLogs(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name !== "AbortError") {
|
||||||
|
setError(String(e));
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
if (e instanceof Error) {
|
|
||||||
setError(`${e.name}: ${e.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -31,7 +31,7 @@ export const getAxes = (series: Series[], unit?: string): Axis[] => Array.from(n
|
|||||||
values: (u: uPlot, ticks: number[]) => formatTicks(u, ticks, unit)
|
values: (u: uPlot, ticks: number[]) => formatTicks(u, ticks, unit)
|
||||||
};
|
};
|
||||||
if (!a) return { space: 80, values: timeValues, stroke, font };
|
if (!a) return { space: 80, values: timeValues, stroke, font };
|
||||||
if (!(Number(a) % 2)) return { ...axis, side: 1 };
|
if (!(Number(a) % 2) && a !== "y") return { ...axis, side: 1 };
|
||||||
return axis;
|
return axis;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
14
app/vmui/packages/vmui/src/utils/uplot/bars.ts
Normal file
14
app/vmui/packages/vmui/src/utils/uplot/bars.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
@ -19,6 +19,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 a bar chart displaying the number of log entries over a time range. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6404).
|
||||||
|
|
||||||
## [v0.20.2](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v0.20.2-victorialogs)
|
## [v0.20.2](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v0.20.2-victorialogs)
|
||||||
|
|
||||||
Released at 2024-06-18
|
Released at 2024-06-18
|
||||||
|
Loading…
Reference in New Issue
Block a user