mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-23 12:31:07 +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.
This commit is contained in:
parent
3b8970802e
commit
32fbffedd9
@ -1,2 +1,5 @@
|
||||
export const getLogsUrl = (server: string): string =>
|
||||
`${server}/select/logsql/query`;
|
||||
|
||||
export const getLogHitsUrl = (server: string): string =>
|
||||
`${server}/select/logsql/hits`;
|
||||
|
@ -34,3 +34,11 @@ export interface Logs {
|
||||
_time: 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-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-hover-black": "rgba(0, 0, 0, 0.12)",
|
||||
"color-log-hits-bar": "rgba(255, 255, 255, 0.18)"
|
||||
};
|
||||
|
||||
export const lightPalette = {
|
||||
@ -33,5 +34,6 @@ export const lightPalette = {
|
||||
"box-shadow": "rgba(0, 0, 0, 0.08) 1px 2px 6px",
|
||||
"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-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 { useTimeState } from "../../state/time/TimeStateContext";
|
||||
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 defaultLimit = isNaN(storageLimit) ? 50 : storageLimit;
|
||||
const defaultLimit = isNaN(storageLimit) ? LOGS_ENTRIES_LIMIT : storageLimit;
|
||||
|
||||
const ExploreLogs: FC = () => {
|
||||
const { serverUrl } = useAppState();
|
||||
@ -24,6 +27,7 @@ const ExploreLogs: FC = () => {
|
||||
const [limit, setLimit] = useStateSearchParams(defaultLimit, "limit");
|
||||
const [query, setQuery] = useStateSearchParams("*", "query");
|
||||
const { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query, limit);
|
||||
const { fetchLogHits, ...dataLogHits } = useFetchLogHits(serverUrl, query);
|
||||
const [queryError, setQueryError] = useState<ErrorTypes | string>("");
|
||||
const [loaded, isLoaded] = useState(false);
|
||||
const [markdownParsing, setMarkdownParsing] = useState(getFromStorage("LOGS_MARKDOWN") === "true");
|
||||
@ -37,6 +41,7 @@ const ExploreLogs: FC = () => {
|
||||
fetchLogs().then(() => {
|
||||
isLoaded(true);
|
||||
});
|
||||
fetchLogHits();
|
||||
|
||||
setSearchParamsFromKeys( {
|
||||
query,
|
||||
@ -79,6 +84,11 @@ const ExploreLogs: FC = () => {
|
||||
/>
|
||||
{isLoading && <Spinner />}
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
<ExploreLogsBarChart
|
||||
query={query}
|
||||
loaded={loaded}
|
||||
{...dataLogHits}
|
||||
/>
|
||||
<ExploreLogsBody
|
||||
data={logs}
|
||||
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,
|
||||
_vmui_time: item._time ? dayjs(item._time).tz().format(`${DATE_TIME_FORMAT}.SSS`) : "",
|
||||
_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]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
|
@ -7,6 +7,8 @@ import { groupByMultipleKeys } from "../../../utils/array";
|
||||
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
|
||||
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
|
||||
import GroupLogsItem from "./GroupLogsItem";
|
||||
import { useAppState } from "../../../state/common/StateContext";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface TableLogsProps {
|
||||
logs: Logs[];
|
||||
@ -15,6 +17,7 @@ interface TableLogsProps {
|
||||
}
|
||||
|
||||
const GroupLogs: FC<TableLogsProps> = ({ logs, markdownParsing }) => {
|
||||
const { isDarkTheme } = useAppState();
|
||||
const copyToClipboard = useCopyToClipboard();
|
||||
|
||||
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>) => {
|
||||
e.stopPropagation();
|
||||
const isCopied = await copyToClipboard(`${pair}`);
|
||||
const isCopied = await copyToClipboard(`${pair.replace(/=/, ": ")}`);
|
||||
if (isCopied) {
|
||||
setCopied(pair);
|
||||
}
|
||||
@ -63,7 +66,10 @@ const GroupLogs: FC<TableLogsProps> = ({ logs, markdownParsing }) => {
|
||||
placement={"top-center"}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
{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 "./style.scss";
|
||||
import useBoolean from "../../../hooks/useBoolean";
|
||||
import Button from "../../../components/Main/Button/Button";
|
||||
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 classNames from "classnames";
|
||||
|
||||
@ -23,6 +23,16 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
|
||||
const fields = Object.entries(log).filter(([key]) => !excludeKeys.includes(key));
|
||||
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 [copied, setCopied] = useState<number | null>(null);
|
||||
|
||||
@ -56,7 +66,7 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
|
||||
"vm-group-logs-row-content__arrow_open": isOpenFields,
|
||||
})}
|
||||
>
|
||||
<ArrowDropDownIcon/>
|
||||
<ArrowDownIcon/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@ -70,11 +80,12 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
|
||||
<div
|
||||
className={classNames({
|
||||
"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}
|
||||
>
|
||||
{log._msg || "message missing"}
|
||||
{displayMessage || "-"}
|
||||
</div>
|
||||
</div>
|
||||
{hasFields && isOpenFields && (
|
||||
@ -94,7 +105,7 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
|
||||
color="gray"
|
||||
size="small"
|
||||
startIcon={<CopyIcon/>}
|
||||
onClick={createCopyHandler(`${key}: ${value}`, i)}
|
||||
onClick={createCopyHandler(`${key}: "${value}"`, i)}
|
||||
ariaLabel="copy to clipboard"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
@ -21,7 +21,7 @@
|
||||
background-color: lighten($color-tropical-blue, 6%);
|
||||
color: darken($color-dodger-blue, 20%);
|
||||
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 {
|
||||
background-color: $color-tropical-blue;
|
||||
@ -30,6 +30,17 @@
|
||||
&:active {
|
||||
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 {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, max-content) 1fr;
|
||||
gap: $padding-small;
|
||||
padding: $padding-global;
|
||||
grid-template-columns: auto minmax(180px, max-content) 1fr;
|
||||
padding: $padding-global 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-in;
|
||||
|
||||
@ -56,14 +66,11 @@
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
position: absolute;
|
||||
top: $padding-global;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 16px;
|
||||
height: $font-size;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.2s ease-out;
|
||||
|
||||
@ -76,6 +83,7 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
margin-right: $padding-small;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
@ -91,6 +99,12 @@
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.1;
|
||||
|
||||
&_empty-msg {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&_missing {
|
||||
color: $color-text-disabled;
|
||||
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 { ErrorTypes } from "../../../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 [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<ErrorTypes | string>();
|
||||
const abortControllerRef = useRef(new AbortController());
|
||||
|
||||
const url = useMemo(() => getLogsUrl(server), [server]);
|
||||
|
||||
@ -35,11 +36,14 @@ export const useFetchLogs = (server: string, query: string, limit: number) => {
|
||||
};
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
const { signal } = abortControllerRef.current;
|
||||
const limit = Number(options.body.get("limit"));
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const response = await fetch(url, { ...options, signal });
|
||||
const text = await response.text();
|
||||
|
||||
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[];
|
||||
setLogs(data);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name !== "AbortError") {
|
||||
setError(String(e));
|
||||
console.error(e);
|
||||
setLogs([]);
|
||||
if (e instanceof Error) {
|
||||
setError(`${e.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
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)
|
||||
};
|
||||
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;
|
||||
});
|
||||
|
||||
|
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
|
||||
|
||||
* 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)
|
||||
|
||||
Released at 2024-06-18
|
||||
|
Loading…
Reference in New Issue
Block a user