From 950e0223de0d16526ae0decc4394ea4d98c8f827 Mon Sep 17 00:00:00 2001 From: Yury Molodov Date: Wed, 20 May 2026 17:55:27 +0200 Subject: [PATCH 1/2] app/vmui: improve stream context modal - warn and highlight logs with identical timestamps - show older logs above and newer logs below the target log - replace load control buttons with infinite scroll - expand stream_context time_window from 1m to 7d while loading context --- .../src/components/Views/GroupView/style.scss | 6 + .../pages/QueryPage/hooks/useTimePeriod.ts | 11 +- .../StreamContext/StreamContextButton.tsx | 3 +- .../pages/StreamContext/StreamContextList.tsx | 269 ++++++++++++------ .../src/pages/StreamContext/helpers.test.ts | 120 ++++++++ .../vmui/src/pages/StreamContext/helpers.ts | 86 ++++++ .../hooks/useFetchStreamContext.ts | 106 ++++--- .../hooks/useStreamContextScroll.ts | 25 ++ .../vmui/src/pages/StreamContext/style.scss | 52 +++- .../packages/vmui/src/utils/dom-geometry.ts | 12 + app/vmui/packages/vmui/src/utils/logs.test.ts | 6 +- app/vmui/packages/vmui/src/utils/logs.ts | 2 +- docs/victorialogs/CHANGELOG.md | 4 + 13 files changed, 552 insertions(+), 150 deletions(-) create mode 100644 app/vmui/packages/vmui/src/pages/StreamContext/helpers.test.ts create mode 100644 app/vmui/packages/vmui/src/pages/StreamContext/helpers.ts create mode 100644 app/vmui/packages/vmui/src/pages/StreamContext/hooks/useStreamContextScroll.ts diff --git a/app/vmui/packages/vmui/src/components/Views/GroupView/style.scss b/app/vmui/packages/vmui/src/components/Views/GroupView/style.scss index ba879d7ba2..569f07232d 100644 --- a/app/vmui/packages/vmui/src/components/Views/GroupView/style.scss +++ b/app/vmui/packages/vmui/src/components/Views/GroupView/style.scss @@ -138,6 +138,12 @@ $actions-width: calc(($actions-buttons * 30px) + 10px); font-size: $font-size-logs; font-variant-numeric: tabular-nums; line-height: 1.2; + + &__time-duplicate { + .vm-group-logs-row-content__time { + color: $color-warning; + } + } } } diff --git a/app/vmui/packages/vmui/src/pages/QueryPage/hooks/useTimePeriod.ts b/app/vmui/packages/vmui/src/pages/QueryPage/hooks/useTimePeriod.ts index d48118c8a0..a09be7f9ec 100644 --- a/app/vmui/packages/vmui/src/pages/QueryPage/hooks/useTimePeriod.ts +++ b/app/vmui/packages/vmui/src/pages/QueryPage/hooks/useTimePeriod.ts @@ -1,4 +1,4 @@ -import { useMemo, useCallback } from "preact/compat"; +import { useMemo, useCallback, useEffect, useRef } from "preact/compat"; import { useSearchParams } from "react-router-dom"; import { getDurationFromPeriod, @@ -28,6 +28,7 @@ const NO_RELATIVE_TIME = "none"; export const useTimePeriod = (groupN: number = 0) => { const [searchParams, setSearchParams] = useSearchParams(); + const setSearchParamsRef = useRef(setSearchParams); const keys = useMemo(() => ({ relative: getGroupKey(TIME_QUERY_PARAMS.RELATIVE, groupN), @@ -65,12 +66,12 @@ export const useTimePeriod = (groupN: number = 0) => { const setPeriod = useCallback((payload: SetPeriodOptions, navigateOpts?: NavigateOptions) => { const timeParams = getUrlParams(payload); - setSearchParams(prev => { + setSearchParamsRef.current(prev => { const nextParams = new URLSearchParams(prev); timeParams.forEach((value, key) => nextParams.set(key, value)); return nextParams; }, navigateOpts); - }, [setSearchParams, getUrlParams]); + }, [getUrlParams]); const period: TimeParams = useMemo(() => { if (relativeTime) { @@ -105,6 +106,10 @@ export const useTimePeriod = (groupN: number = 0) => { return true; }, [relativeTime, setPeriod, endTimeStr]); + useEffect(() => { + setSearchParamsRef.current = setSearchParams; + }, [setSearchParams]); + return { period, setPeriod, diff --git a/app/vmui/packages/vmui/src/pages/StreamContext/StreamContextButton.tsx b/app/vmui/packages/vmui/src/pages/StreamContext/StreamContextButton.tsx index c1718dc24a..ea3c03f689 100644 --- a/app/vmui/packages/vmui/src/pages/StreamContext/StreamContextButton.tsx +++ b/app/vmui/packages/vmui/src/pages/StreamContext/StreamContextButton.tsx @@ -48,9 +48,10 @@ const StreamContextButton: FC = ({ log, displayFields }) => { {isOpenContext && ( = ({ log, displayFields }) => { + const { isMobile } = useDeviceDetect(); + const [searchParams, setSearchParams] = useSearchParams(); const noWrapLines = searchParams.get(LOGS_URL_PARAMS.NO_WRAP_LINES) === "true"; - const [loadSize, setLoadSize] = useState(10); + const scrollContainerRef = useRef(null); + const targetRowRef = useRef(null); const { logsBefore, logsAfter, hasMore, - isLoading, + isLoading: { after: isLoadingAfter, before: isLoadingBefore }, error, fetchContextLogs, resetContextLogs, abort } = useFetchStreamContext(); + const logsWithTimeDuplicates = useMemo(() => { + const logs = logsBefore.concat(log).concat(logsAfter); + const groupedByTime = groupByMultipleKeys(logs, ["_time"]); + const groupWithDuplicates = groupedByTime.filter(group => group.values.length > 1); + + // Keep duplicated logs so we can highlight them in the list. + return groupWithDuplicates.flatMap(group => group.values); + }, [log, logsBefore, logsAfter]); + + const hasTimeDuplicates = logsWithTimeDuplicates.length > 0; + const streamFields = useMemo(() => { const stream = logsBefore[0]?._stream || logsAfter[0]?._stream || log._stream || ""; return getStreamPairs(stream); }, [logsBefore, logsAfter, log._stream]); - const handleLoadMoreAfter = () => { - const target = logsAfter[0]; - if (!target) return; - void fetchContextLogs({ log: target, linesAfter: loadSize }); - }; + const handleLoadMore = useCallback((dir: Direction) => { + const isAfter = dir === "after"; + + if (isAfter && (isLoadingAfter || !hasMore.after)) return; + if (!isAfter && (isLoadingBefore || !hasMore.before)) return; + + const target = isAfter ? logsAfter[logsAfter.length - 1] : logsBefore[0]; - const handleLoadMoreBefore = () => { - const target = logsBefore[logsBefore.length - 1]; if (!target) return; - void fetchContextLogs({ log: target, linesBefore: loadSize }); - }; - const handleChangeLoadSize = (limit: number) => { - setLoadSize(limit); - }; + const scrollContainer = scrollContainerRef.current; + const previousScrollHeight = scrollContainer?.scrollHeight ?? 0; + const previousScrollTop = scrollContainer?.scrollTop ?? 0; + + void fetchContextLogs({ + log: target, + linesAfter: isAfter ? STREAM_CONTEXT_LOAD_SIZE : 0, + linesBefore: isAfter ? 0 : STREAM_CONTEXT_LOAD_SIZE, + }).then(() => { + if (isAfter || !scrollContainer) return; + + requestAnimationFrame(() => { + const isStillAtTop = scrollContainer.scrollTop <= 16; + + if (!isStillAtTop) { + // User has already scrolled away while older logs were loading, so don't force the viewport back. + return; + } + + const offset = 54; + scrollContainer.scrollTop = scrollContainer.scrollHeight - (previousScrollHeight + previousScrollTop) - offset; + }); + }); + }, [fetchContextLogs, hasMore.after, hasMore.before, isLoadingAfter, isLoadingBefore, logsAfter, logsBefore]); const toggleWrapLines = () => { searchParams.set(LOGS_URL_PARAMS.NO_WRAP_LINES, String(!noWrapLines)); setSearchParams(searchParams); }; - useEffect(() => { - void fetchContextLogs({ log, linesBefore: 10, linesAfter: 10 }); + const scrollToTargetLog = () => { + const scrollContainer = scrollContainerRef.current; + const targetRow = targetRowRef.current; + + if (!scrollContainer || !targetRow) return; + + scrollElementToCenter(scrollContainer, targetRow); + }; + + useLayoutEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer || !log) return; + + const initialLogsPerSide = getInitialLogsPerSide(scrollContainer.clientHeight); + + void fetchContextLogs({ + log, + linesBefore: initialLogsPerSide, + linesAfter: initialLogsPerSide + }).then(() => { + // Center the target log after initial context load. + requestAnimationFrame(scrollToTargetLog); + }); return () => { resetContextLogs(); abort(); // Abort the fetch request when closing the modal }; - }, []); + }, [log]); + + const { handleScroll } = useStreamContextScroll({ handleLoadMore }); const streamPairs = (
@@ -85,98 +149,117 @@ const StreamContextList: FC = ({ log, displayFields }) => { ); return ( -
- {isLoading && } - +
+ {(isLoadingAfter || isLoadingBefore) && } + {streamPairs} + - - -
- Time window - 1h + + {hasTimeDuplicates && ( +
+ + Stream context cannot reliably determine the order of log entries + because some entries share identical timestamps. +
- + )} + {error && ( +
+ + {error} + +
+ )}
+
+ {!error && !hasMore.before && ( +
+ {`No older logs within ${STREAM_CONTEXT_TIME_WINDOW_MAX} window`} +
+ )} - {error && ( -
- - {error} - -
- )} - - {!error && ( -
- -
- )} + {isLoadingBefore && ( +
+ +
+ )} -
- {logsAfter.map((log, rowN) => ( - + {logsBefore.map((log, rowN) => ( +
+ +
))} - - - {logsBefore.map((log, rowN) => ( +
+
+ + {logsAfter.map((log, rowN) => ( +
+ +
))} -
- {!error && ( -
- -
- )} + {isLoadingAfter && ( +
+ +
+ )} + + {!error && !hasMore.after && ( +
+ {`No newer logs within ${STREAM_CONTEXT_TIME_WINDOW_MAX} window`} +
+ )} +
); }; diff --git a/app/vmui/packages/vmui/src/pages/StreamContext/helpers.test.ts b/app/vmui/packages/vmui/src/pages/StreamContext/helpers.test.ts new file mode 100644 index 0000000000..662ac9eb00 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/StreamContext/helpers.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { Logs } from "../../api/types"; +import { getSecondsFromDuration } from "../../utils/time"; +import { + buildContextQuery, + getNextTimeWindow, + isMaxTimeWindow, + mergeContextLogs, + STREAM_CONTEXT_TIME_WINDOW_INITIAL, + STREAM_CONTEXT_TIME_WINDOW_MAX, +} from "./helpers"; + +describe("StreamContext helpers", () => { + describe("isMaxTimeWindow", () => { + it("checks max time window by seconds", () => { + expect(isMaxTimeWindow(STREAM_CONTEXT_TIME_WINDOW_INITIAL)).toBe(false); + expect(isMaxTimeWindow(STREAM_CONTEXT_TIME_WINDOW_MAX)).toBe(true); + + const maxSeconds = getSecondsFromDuration(STREAM_CONTEXT_TIME_WINDOW_MAX); + expect(isMaxTimeWindow(`${maxSeconds + 1}s`)).toBe(true); + }); + }); + + describe("getNextTimeWindow", () => { + it("returns a larger time window", () => { + const nextTimeWindow = getNextTimeWindow(STREAM_CONTEXT_TIME_WINDOW_INITIAL); + + expect(getSecondsFromDuration(nextTimeWindow)).toBeGreaterThan( + getSecondsFromDuration(STREAM_CONTEXT_TIME_WINDOW_INITIAL) + ); + }); + + it("normalizes time window on unit boundaries", () => { + expect(getNextTimeWindow("32m")).toBe("1h"); + expect(getNextTimeWindow("16h")).toBe("1d"); + }); + + it("does not exceed max time window", () => { + const nextTimeWindow = getNextTimeWindow(STREAM_CONTEXT_TIME_WINDOW_MAX); + + expect(getSecondsFromDuration(nextTimeWindow)).toBe( + getSecondsFromDuration(STREAM_CONTEXT_TIME_WINDOW_MAX) + ); + }); + }); + + describe("buildContextQuery", () => { + const log = { + _stream_id: "stream-id", + _time: "2025-01-01T10:00:00.123Z", + _msg: "", + _stream: "", + } as Logs; + + it("builds a stream_context query with time_window", () => { + const query = buildContextQuery(log, "before", 10, STREAM_CONTEXT_TIME_WINDOW_INITIAL); + + expect(query).toContain("_stream_id:stream-id"); + expect(query).toContain("_time:2025-01-01T10:00:00.123000000Z"); + expect(query).toContain(`stream_context before 10 time_window ${STREAM_CONTEXT_TIME_WINDOW_INITIAL}`); + }); + + it("throws if _stream_id or _time is missing", () => { + expect(() => buildContextQuery({ ...log, _stream_id: "" }, "after", 10, STREAM_CONTEXT_TIME_WINDOW_INITIAL)) + .toThrow("Log must contain _stream_id and _time fields."); + + expect(() => buildContextQuery({ ...log, _time: "" }, "after", 10, STREAM_CONTEXT_TIME_WINDOW_INITIAL)) + .toThrow("Log must contain _stream_id and _time fields."); + }); + }); + + describe("mergeContextLogs", () => { + const target = { + _stream_id: "stream-id", + _time: "2025-01-01T10:00:00.123Z", + _msg: "target", + _stream: "", + } as Logs; + + const olderLog = { + _stream_id: "stream-id", + _time: "2025-01-01T09:59:00.000Z", + _msg: "older", + _stream: "", + } as Logs; + + const newerLog = { + _stream_id: "stream-id", + _time: "2025-01-01T10:01:00.000Z", + _msg: "newer", + _stream: "", + } as Logs; + + it("prepends before logs and removes the target log", () => { + const setter = vi.fn(); + const prev = [{ ...olderLog, _msg: "existing older" }] as Logs[]; + + mergeContextLogs("before", setter)([olderLog, target], target); + + const updater = setter.mock.calls[0][0]; + const result = updater(prev); + + expect(result).toEqual([olderLog, prev[0]]); + expect(result).not.toContain(target); + }); + + it("appends after logs and removes the target log", () => { + const setter = vi.fn(); + const prev = [{ ...newerLog, _msg: "existing newer" }] as Logs[]; + + mergeContextLogs("after", setter)([target, newerLog], target); + + const updater = setter.mock.calls[0][0]; + const result = updater(prev); + + expect(result).toEqual([prev[0], newerLog]); + expect(result).not.toContain(target); + }); + }); +}); diff --git a/app/vmui/packages/vmui/src/pages/StreamContext/helpers.ts b/app/vmui/packages/vmui/src/pages/StreamContext/helpers.ts new file mode 100644 index 0000000000..d8fe547013 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/StreamContext/helpers.ts @@ -0,0 +1,86 @@ +import { Logs } from "../../api/types"; +import { getDurationFromMilliseconds, getSecondsFromDuration, toNanoPrecision } from "../../utils/time"; +import { Direction } from "./hooks/useFetchStreamContext"; +import { Dispatch, SetStateAction } from "preact/compat"; +import { removeLogsByKeys } from "../../utils/logs"; + +export const STREAM_CONTEXT_LOAD_SIZE = 30; + +export const STREAM_CONTEXT_TIME_WINDOW_INITIAL = "1m"; +export const STREAM_CONTEXT_TIME_WINDOW_MAX = "7d"; +export const STREAM_CONTEXT_TIME_WINDOW_MULTIPLIER = 2; + +/** Checks max time_window by seconds. */ +export const isMaxTimeWindow = (timeWindow: string): boolean => { + return getSecondsFromDuration(timeWindow) >= getSecondsFromDuration(STREAM_CONTEXT_TIME_WINDOW_MAX); +}; + +/** Normalizes time_window to whole minutes, hours, or days. */ +const normalizeTimeWindowSeconds = (seconds: number): number => { + const minute = 60; + const hour = 60 * minute; + const day = 24 * hour; + const maxSeconds = getSecondsFromDuration(STREAM_CONTEXT_TIME_WINDOW_MAX); + + if (seconds >= maxSeconds) return maxSeconds; + + if (seconds >= day) { + return Math.floor(seconds / day) * day; + } + + if (seconds >= hour) { + return Math.floor(seconds / hour) * hour; + } + + return Math.floor(seconds / minute) * minute; +}; + +/** Returns the next time_window for the stream_context pipe. */ +export const getNextTimeWindow = (currentWindow: string): string => { + const currentSeconds = getSecondsFromDuration(currentWindow); + const maxSeconds = getSecondsFromDuration(STREAM_CONTEXT_TIME_WINDOW_MAX); + + const nextSeconds = Math.min( + currentSeconds * STREAM_CONTEXT_TIME_WINDOW_MULTIPLIER, + maxSeconds + ); + + const normalizedSeconds = normalizeTimeWindowSeconds(nextSeconds); + + return getDurationFromMilliseconds(normalizedSeconds * 1000); +}; + +/** Builds a LogsQL query with the stream_context pipe. */ +export const buildContextQuery = ( + log: Logs, + dir: Direction, + lines: number, + timeWindow: string, +): string => { + const { _stream_id, _time } = log; + + if (!_stream_id || !_time) { + throw new Error("Log must contain _stream_id and _time fields."); + } + + return `_stream_id:${_stream_id} +_time:${toNanoPrecision(_time)} +| stream_context ${dir} ${lines} time_window ${timeWindow}`; +}; + +/** Merges fetched logs and removes the target log. */ +export const mergeContextLogs = (dir: Direction, setter: Dispatch>) => + (fetched: Logs[], target: Logs) => { + const filtered = removeLogsByKeys(fetched, target, ["_stream_id", "_time"]); + setter(prev => dir === "after" ? prev.concat(filtered) : filtered.concat(prev)); + }; + +const MIN_LOG_ROW_HEIGHT = 20; +const INITIAL_LOAD_OVERSCAN = 1.25; // Extra viewport space to ensure initial scroll. + +export const getInitialLogsPerSide = (containerHeight: number) => { + return Math.max( + STREAM_CONTEXT_LOAD_SIZE, + Math.ceil((containerHeight * INITIAL_LOAD_OVERSCAN) / MIN_LOG_ROW_HEIGHT), + ); +}; diff --git a/app/vmui/packages/vmui/src/pages/StreamContext/hooks/useFetchStreamContext.ts b/app/vmui/packages/vmui/src/pages/StreamContext/hooks/useFetchStreamContext.ts index 57d7877b7b..6679b06546 100644 --- a/app/vmui/packages/vmui/src/pages/StreamContext/hooks/useFetchStreamContext.ts +++ b/app/vmui/packages/vmui/src/pages/StreamContext/hooks/useFetchStreamContext.ts @@ -5,11 +5,12 @@ import { } from "preact/compat"; import { Logs } from "../../../api/types"; import { useFetchLogs } from "../../QueryPage/hooks/useFetchLogs"; -import { toNanoPrecision } from "../../../utils/time"; -import { removeExactLog } from "../../../utils/logs"; -import { LOGS_STREAM_CONTEXT_KEYS } from "../../../constants/logs"; +import { + buildContextQuery, getNextTimeWindow, isMaxTimeWindow, mergeContextLogs, + STREAM_CONTEXT_TIME_WINDOW_INITIAL +} from "../helpers"; -type Direction = "before" | "after"; +export type Direction = "before" | "after"; interface FetchParams { log: Logs; @@ -17,31 +18,15 @@ interface FetchParams { linesAfter?: number; } -const buildContextQuery = ( - log: Logs, - dir: Direction, - lines: number -): string => { - const { _stream_id, _time } = log; - - if (!_stream_id || !_time) { - throw new Error("Log must contain _stream_id and _time fields."); - } - - return `_stream_id:${_stream_id} -_time:${toNanoPrecision(_time)} -| stream_context ${dir} ${lines} -| sort by (_time) desc`; -}; - -const mergeLogs = (dir: Direction, setter: Dispatch>) => - (fetched: Logs[], target: Logs) => { - const filtered = removeExactLog(fetched, target, LOGS_STREAM_CONTEXT_KEYS); - setter(prev => dir === "after" ? filtered.concat(prev) : prev.concat(filtered)); - }; +interface FetchSideParams { + dir: Direction; + lines: number; + setter: Dispatch>; + log: Logs; +} export const useFetchStreamContext = () => { - const { fetchLogs, isLoading, error, abort } = useFetchLogs(); + const { fetchLogs, error, abort } = useFetchLogs(); const [logsBefore, setLogsBefore] = useState([]); const [logsAfter, setLogsAfter] = useState([]); @@ -50,38 +35,66 @@ export const useFetchStreamContext = () => { after: true, }); - const fetchSide = async ( - dir: Direction, - lines: number, - setter: Dispatch>, - log: Logs - ) => { - if (lines <= 0) return; + const [isLoading, setIsLoading] = useState<{ before: boolean; after: boolean }>({ + before: false, + after: false, + }); - try { + const fetchWithExpandedTimeWindow = async ({ log, dir, lines }: FetchSideParams) => { + let timeWindow = STREAM_CONTEXT_TIME_WINDOW_INITIAL; + + while (true) { const data = await fetchLogs({ - query: buildContextQuery(log, dir, lines), + query: buildContextQuery(log, dir, lines, timeWindow), }); - if (Array.isArray(data)) { - if (data.length) { - mergeLogs(dir, setter)(data, log); - } + if (!Array.isArray(data)) { + return { data: [], timeWindow }; + } - setHasMore(prev => ({ - ...prev, - [dir]: data.length >= lines, - })); + if (data.length >= lines || isMaxTimeWindow(timeWindow)) { + return { data, timeWindow }; } + + timeWindow = getNextTimeWindow(timeWindow); + } + }; + + const fetchSide = async (params: FetchSideParams) => { + const { log, lines, dir, setter } = params; + + if (lines <= 0) return; + + setIsLoading(prev => ({ + ...prev, + [dir]: true, + })); + + try { + const { data } = await fetchWithExpandedTimeWindow(params); + + if (data.length) { + mergeContextLogs(dir, setter)(data, log); + } + + setHasMore(prev => ({ + ...prev, + [dir]: data.length >= lines, + })); } catch (err) { console.error(`Error fetching ${dir} logs:`, err); + } finally { + setIsLoading(prev => ({ + ...prev, + [dir]: false, + })); } }; const fetchContextLogs = async ({ log, linesBefore = 0, linesAfter = 0 }: FetchParams) => { await Promise.allSettled([ - fetchSide("before", linesBefore, setLogsBefore, log), - fetchSide("after", linesAfter, setLogsAfter, log), + fetchSide({ dir: "before", lines: linesBefore, setter: setLogsBefore, log }), + fetchSide({ dir: "after", lines: linesAfter, setter: setLogsAfter, log }), ]); }; @@ -89,6 +102,7 @@ export const useFetchStreamContext = () => { setLogsBefore([]); setLogsAfter([]); setHasMore({ before: true, after: true }); + setIsLoading({ before: false, after: false }); }; return { diff --git a/app/vmui/packages/vmui/src/pages/StreamContext/hooks/useStreamContextScroll.ts b/app/vmui/packages/vmui/src/pages/StreamContext/hooks/useStreamContextScroll.ts new file mode 100644 index 0000000000..870de37e58 --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/StreamContext/hooks/useStreamContextScroll.ts @@ -0,0 +1,25 @@ +import { Direction } from "./useFetchStreamContext"; + +interface UseStreamContextScroll { + handleLoadMore: (dir: Direction) => void; +} + +const SCROLL_THRESHOLD = 24; + +export const useStreamContextScroll = ({ handleLoadMore }: UseStreamContextScroll) => { + const handleScroll = (e: Event) => { + const scrollContainer = e.currentTarget as HTMLDivElement | null; + + if (!scrollContainer) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + + const isTop = scrollTop <= SCROLL_THRESHOLD; + const isBottom = scrollTop + clientHeight >= scrollHeight - SCROLL_THRESHOLD; + + if (isTop) handleLoadMore("before"); + if (isBottom) handleLoadMore("after"); + }; + + return { handleScroll }; +}; diff --git a/app/vmui/packages/vmui/src/pages/StreamContext/style.scss b/app/vmui/packages/vmui/src/pages/StreamContext/style.scss index af5d0b5956..a2fa290033 100644 --- a/app/vmui/packages/vmui/src/pages/StreamContext/style.scss +++ b/app/vmui/packages/vmui/src/pages/StreamContext/style.scss @@ -1,10 +1,29 @@ @use "src/styles/variables" as *; @use "sass:color"; +$modal-header-height: 41px; + .vm-steam-context { + position: relative; + display: flex; + flex-direction: column; max-width: 90vw; min-width: 90vw; - min-height: 220px; + height: calc($vh * 90 - ($padding-global * 2) - $modal-header-height); + overflow: auto; + + &_mobile { + max-width: 100%; + height: calc($vh * 100 - ($padding-global * 2) - $modal-header-height); + } + + &__modal > .vm-modal-content { + overflow: visible; + + .vm-modal-content-body { + padding: 0; + } + } &__target-row { .vm-group-logs-row-content { @@ -33,18 +52,31 @@ padding-top: $padding-global; } - &__load-more { + &__no-load-more { display: flex; align-items: center; justify-content: center; padding: $padding-global; + margin-block: $padding-global; + border-top: $border-divider; + text-align: center; + color: $color-text-secondary; + } + + &__loader { + display: flex; + align-items: center; + justify-content: center; + padding: $padding-global; + pointer-events: none; } &-header { position: sticky; top: 0; + flex: 0 0 auto; + padding-inline: 1px; padding: $padding-global; - margin: (-$padding-global) (-$padding-global) 0; display: flex; align-items: center; justify-content: flex-end; @@ -74,5 +106,19 @@ text-transform: none; } } + + &__warning { + width: 100%; + flex-grow: 1; + padding-top: $padding-small; + background-color: $color-background-block; + } + } + + &-rows { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + padding: $padding-global; } } diff --git a/app/vmui/packages/vmui/src/utils/dom-geometry.ts b/app/vmui/packages/vmui/src/utils/dom-geometry.ts index eac871c0ca..619872bf18 100644 --- a/app/vmui/packages/vmui/src/utils/dom-geometry.ts +++ b/app/vmui/packages/vmui/src/utils/dom-geometry.ts @@ -20,3 +20,15 @@ export const borderBoxToContentSize = ( return Math.max(0, borderBoxSize - sub); }; + +export const scrollElementToCenter = ( + scrollContainer: HTMLElement, + targetElement: HTMLElement, +) => { + const containerRect = scrollContainer.getBoundingClientRect(); + const targetRect = targetElement.getBoundingClientRect(); + + const targetOffset = targetRect.top - containerRect.top + scrollContainer.scrollTop; + + scrollContainer.scrollTop = targetOffset - scrollContainer.clientHeight / 2 + targetElement.clientHeight / 2; +}; diff --git a/app/vmui/packages/vmui/src/utils/logs.test.ts b/app/vmui/packages/vmui/src/utils/logs.test.ts index 33d67785f3..7ef9903134 100644 --- a/app/vmui/packages/vmui/src/utils/logs.test.ts +++ b/app/vmui/packages/vmui/src/utils/logs.test.ts @@ -7,7 +7,7 @@ import { convertToFieldFilter, calculateTotalHits, isEqualLogByKeys, - removeExactLog, + removeLogsByKeys, } from "./logs"; import { LOGS_GROUP_BY } from "../constants/logs"; @@ -158,7 +158,7 @@ describe("utils/logs", () => { { _time: 1, foo: "bar", x: 1 } as unknown as Logs, // same content ]; - expect(removeExactLog(logs, target, ["_time", "foo", "x"])).toEqual([ + expect(removeLogsByKeys(logs, target, ["_time", "foo", "x"])).toEqual([ { _time: 1, foo: "bar", x: 2 }, { _time: 2, foo: "bar", x: 1 }, ]); @@ -168,7 +168,7 @@ describe("utils/logs", () => { const target = { _time: 9, foo: "x" } as unknown as Logs; const logs = [{ _time: 1, foo: "bar" } as unknown as Logs]; - expect(removeExactLog(logs, target, ["_time", "foo"])).toEqual([{ _time: 1, foo: "bar" }]); + expect(removeLogsByKeys(logs, target, ["_time", "foo"])).toEqual([{ _time: 1, foo: "bar" }]); }); }); }); diff --git a/app/vmui/packages/vmui/src/utils/logs.ts b/app/vmui/packages/vmui/src/utils/logs.ts index b46d17de5f..ad18fee366 100644 --- a/app/vmui/packages/vmui/src/utils/logs.ts +++ b/app/vmui/packages/vmui/src/utils/logs.ts @@ -116,6 +116,6 @@ export const isEqualLogByKeys = (a: Logs, b: Logs, keys: Array): boo return keys.every(key => a[key] === b[key]); }; -export const removeExactLog = (logs: Logs[], target: Logs, keys: Array): Logs[] => { +export const removeLogsByKeys = (logs: Logs[], target: Logs, keys: Array): Logs[] => { return logs.filter(log => !isEqualLogByKeys(log, target, keys)); }; diff --git a/docs/victorialogs/CHANGELOG.md b/docs/victorialogs/CHANGELOG.md index b3727cd118..6e20de4b4b 100644 --- a/docs/victorialogs/CHANGELOG.md +++ b/docs/victorialogs/CHANGELOG.md @@ -30,6 +30,10 @@ according to the following docs: * FEATURE: [alerts](https://github.com/VictoriaMetrics/VictoriaLogs/blob/master/deployment/docker/rules): add new alerting rules `PersistentQueueRunsOutOfSpaceIn12Hours` and `PersistentQueueRunsOutOfSpaceIn4Hours` for `vlagent` persistent queue capacity. These alerts help users to take proactive actions before `vlagent` starts dropping logs due to insufficient persistent queue space. See [#10193](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10193) * FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): remove the `Date format` setting and always display timestamps with nanosecond precision. See [#1161](https://github.com/VictoriaMetrics/VictoriaLogs/issues/1161). * FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): rename field `Query time` to `Hits query` on the Hits chart panel. The change makes it clear duration of which query is displayed. +* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): show older logs above and newer logs below the target log in the `Log context` modal. +* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): replace manual load controls with infinite scroll in the `Log context` modal. +* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): expand the `stream_context` `time_window` from `1m` to `7d` while loading context logs in the `Log context` modal. See [#1397](https://github.com/VictoriaMetrics/VictoriaLogs/issues/1397). +* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): warn and highlight logs with identical timestamps in the `Log context` modal. * FEATURE: [dashboards/kubernetes-explorer](https://github.com/VictoriaMetrics/VictoriaLogs/blob/master/dashboards/victorialogs-kubernetes-explorer.json): add new dashboard for exploring Kubernetes logs via [VictoriaLogs datasource](https://docs.victoriametrics.com/victorialogs/integrations/grafana/) in Grafana. Thanks to @sias32 for [the contribution](https://github.com/VictoriaMetrics/VictoriaLogs/pull/1254)! * FEATURE: [File Collector](https://docs.victoriametrics.com/victorialogs/vlagent/#collect-logs-from-files): enhance glob pattern support with additional syntax: double-star `/**/` for matching nested directories (e.g. `/var/log/**/*.log`), alternatives `{a,b}` for matching multiple specific names (e.g. `{access,error}.log`), character classes `[a-z]` and `?` wildcard for single-character matching. See [#1393](https://github.com/VictoriaMetrics/VictoriaLogs/issues/1393) and [these docs](https://docs.victoriametrics.com/victorialogs/vlagent/#glob-pattern-requirements) for the full pattern syntax reference. From 4ba80a29ba40cd80e951467fe6733559f9ec8f43 Mon Sep 17 00:00:00 2001 From: Yury Moladau Date: Wed, 20 May 2026 18:19:27 +0200 Subject: [PATCH 2/2] app/vmui: fix stream context scroll restoration Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Signed-off-by: Yury Moladau --- .../packages/vmui/src/pages/StreamContext/StreamContextList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/vmui/packages/vmui/src/pages/StreamContext/StreamContextList.tsx b/app/vmui/packages/vmui/src/pages/StreamContext/StreamContextList.tsx index 9e8f7fa34a..ddfd3185ee 100644 --- a/app/vmui/packages/vmui/src/pages/StreamContext/StreamContextList.tsx +++ b/app/vmui/packages/vmui/src/pages/StreamContext/StreamContextList.tsx @@ -94,7 +94,7 @@ const StreamContextList: FC = ({ log, displayFields }) => { } const offset = 54; - scrollContainer.scrollTop = scrollContainer.scrollHeight - (previousScrollHeight + previousScrollTop) - offset; + scrollContainer.scrollTop = scrollContainer.scrollHeight - previousScrollHeight + previousScrollTop - offset; }); }); }, [fetchContextLogs, hasMore.after, hasMore.before, isLoadingAfter, isLoadingBefore, logsAfter, logsBefore]);