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:
Yury Molodov 2024-06-18 15:23:21 +02:00 committed by hagen1778
parent 5be2f2c4e4
commit 88650abf97
No known key found for this signature in database
GPG Key ID: 3BF75F3741CA9640
20 changed files with 544 additions and 28 deletions

View File

@ -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`;

View File

@ -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;
};
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}
}
}

View File

@ -0,0 +1,2 @@
export const LOGS_ENTRIES_LIMIT = 50;
export const LOGS_BARS_VIEW = 20;

View File

@ -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)"
}; };

View File

@ -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}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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(() => {

View File

@ -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}

View File

@ -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>

View File

@ -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;

View File

@ -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,
};
};

View File

@ -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);

View File

@ -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;
}); });

View 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;
};

View File

@ -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