diff --git a/app/vmui/packages/vmui/src/api/logs.ts b/app/vmui/packages/vmui/src/api/logs.ts index a5d62126f..35b268b77 100644 --- a/app/vmui/packages/vmui/src/api/logs.ts +++ b/app/vmui/packages/vmui/src/api/logs.ts @@ -1,2 +1,5 @@ export const getLogsUrl = (server: string): string => `${server}/select/logsql/query`; + +export const getLogHitsUrl = (server: string): string => + `${server}/select/logsql/hits`; diff --git a/app/vmui/packages/vmui/src/api/types.ts b/app/vmui/packages/vmui/src/api/types.ts index eee701f93..e9765ee10 100644 --- a/app/vmui/packages/vmui/src/api/types.ts +++ b/app/vmui/packages/vmui/src/api/types.ts @@ -34,3 +34,11 @@ export interface Logs { _time: string; [key: string]: string; } + +export interface LogHits { + timestamps: string[]; + values: number[]; + fields: { + [key: string]: string; + }; +} diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsChart.tsx b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsChart.tsx new file mode 100644 index 000000000..748bf46f2 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsChart.tsx @@ -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 = ({ data, period, setPeriod }) => { + const [containerRef, containerSize] = useElementSize(); + const uPlotRef = useRef(null); + const [uPlotInst, setUPlotInst] = useState(); + + 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 ( +
+
+ +
+ ); +}; + +export default BarHitsChart; diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/TooltipBarHitsChart.tsx b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/TooltipBarHitsChart.tsx new file mode 100644 index 000000000..2d96c32d0 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/TooltipBarHitsChart.tsx @@ -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 = ({ focusDataIdx, uPlotInst }) => { + const tooltipRef = useRef(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 ( +
+
+ Count of records: +

+ {tooltipData.value} +

+
+
+
+ {tooltipData.timestamp} +
+
+
+ ); +}; + +export default TooltipBarHitsChart; diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/hooks/useBarHitsOptions.ts b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/hooks/useBarHitsOptions.ts new file mode 100644 index 000000000..f72b6cacd --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/hooks/useBarHitsOptions.ts @@ -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; diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/style.scss b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/style.scss new file mode 100644 index 000000000..509a5517e --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/style.scss @@ -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; + } + } +} diff --git a/app/vmui/packages/vmui/src/constants/logs.ts b/app/vmui/packages/vmui/src/constants/logs.ts new file mode 100644 index 000000000..4ad19c3e1 --- /dev/null +++ b/app/vmui/packages/vmui/src/constants/logs.ts @@ -0,0 +1,2 @@ +export const LOGS_ENTRIES_LIMIT = 50; +export const LOGS_BARS_VIEW = 20; diff --git a/app/vmui/packages/vmui/src/constants/palette.ts b/app/vmui/packages/vmui/src/constants/palette.ts index abb11bfed..11f78d637 100644 --- a/app/vmui/packages/vmui/src/constants/palette.ts +++ b/app/vmui/packages/vmui/src/constants/palette.ts @@ -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)" }; diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx index 9dbebedb6..8b289f4f5 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx @@ -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(""); 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 && } {error && {error}} + = ({ 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 ( +
+ {!error && loaded && noDataMessage && ( +
+ {noDataMessage} +
+ )} + + {error && loaded && noDataMessage && ( +
+ {error} +
+ )} + + {data && ( + + )} +
+ ); +}; + +export default ExploreLogsBarChart; diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/style.scss b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/style.scss new file mode 100644 index 000000000..d1e839bea --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/style.scss @@ -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; + } +} diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx index 3a66a3503..f213b9a02 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx @@ -48,7 +48,7 @@ const ExploreLogsBody: FC = ({ 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(() => { diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogs.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogs.tsx index 82983a14f..ec0d21da2 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogs.tsx +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogs.tsx @@ -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 = ({ logs, markdownParsing }) => { + const { isDarkTheme } = useAppState(); const copyToClipboard = useCopyToClipboard(); const [copied, setCopied] = useState(null); @@ -32,7 +35,7 @@ const GroupLogs: FC = ({ logs, markdownParsing }) => { const handleClickByPair = (pair: string) => async (e: MouseEvent) => { e.stopPropagation(); - const isCopied = await copyToClipboard(`${pair}`); + const isCopied = await copyToClipboard(`${pair.replace(/=/, ": ")}`); if (isCopied) { setCopied(pair); } @@ -63,7 +66,10 @@ const GroupLogs: FC = ({ logs, markdownParsing }) => { placement={"top-center"} >
{pair} diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogsItem.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogsItem.tsx index 64539db0e..0cfa42bbc 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogsItem.tsx +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogsItem.tsx @@ -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 = ({ 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(null); @@ -56,7 +66,7 @@ const GroupLogsItem: FC = ({ log, markdownParsing }) => { "vm-group-logs-row-content__arrow_open": isOpenFields, })} > - +
)}
= ({ log, markdownParsing }) => {
- {log._msg || "message missing"} + {displayMessage || "-"}
{hasFields && isOpenFields && ( @@ -94,7 +105,7 @@ const GroupLogsItem: FC = ({ log, markdownParsing }) => { color="gray" size="small" startIcon={} - onClick={createCopyHandler(`${key}: ${value}`, i)} + onClick={createCopyHandler(`${key}: "${value}"`, i)} ariaLabel="copy to clipboard" /> diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/style.scss b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/style.scss index 71ee06179..7868be35b 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/style.scss +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/style.scss @@ -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; diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogHits.ts b/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogHits.ts new file mode 100644 index 000000000..b6a709c7e --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogHits.ts @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(); + 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, + }; +}; diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogs.ts b/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogs.ts index 21f63449c..00801cc2f 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogs.ts +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogs.ts @@ -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([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); + 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) { - console.error(e); - setLogs([]); - if (e instanceof Error) { - setError(`${e.name}: ${e.message}`); + if (e instanceof Error && e.name !== "AbortError") { + setError(String(e)); + console.error(e); + setLogs([]); } } setIsLoading(false); diff --git a/app/vmui/packages/vmui/src/utils/uplot/axes.ts b/app/vmui/packages/vmui/src/utils/uplot/axes.ts index c4c8f9b82..631ba133f 100644 --- a/app/vmui/packages/vmui/src/utils/uplot/axes.ts +++ b/app/vmui/packages/vmui/src/utils/uplot/axes.ts @@ -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; }); diff --git a/app/vmui/packages/vmui/src/utils/uplot/bars.ts b/app/vmui/packages/vmui/src/utils/uplot/bars.ts new file mode 100644 index 000000000..be7fc1444 --- /dev/null +++ b/app/vmui/packages/vmui/src/utils/uplot/bars.ts @@ -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; +}; + diff --git a/docs/VictoriaLogs/CHANGELOG.md b/docs/VictoriaLogs/CHANGELOG.md index 7bef94515..d197c44f5 100644 --- a/docs/VictoriaLogs/CHANGELOG.md +++ b/docs/VictoriaLogs/CHANGELOG.md @@ -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