From ac9b784c54a8c43c3254736cb8446ccb196ee156 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <26010946+harshithmohan@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:43:43 +0530 Subject: [PATCH 1/4] feat(logs): rewrite logs page with server-side search --- src/core/react-query/logging/queries.ts | 98 ++++++++++++ src/core/react-query/logging/types.ts | 23 +++ src/core/react-query/logs/queries.ts | 72 --------- src/core/types/api/common.ts | 6 - src/pages/logs/LogLevelChip.tsx | 46 ++++++ src/pages/logs/LogLiveView.tsx | 110 ++++++++++++++ src/pages/logs/LogRow.tsx | 62 ++++++++ src/pages/logs/LogSearchView.tsx | 133 +++++++++++++++++ src/pages/logs/LogsPage.tsx | 191 ++++++++++++------------ 9 files changed, 568 insertions(+), 173 deletions(-) create mode 100644 src/core/react-query/logging/queries.ts create mode 100644 src/core/react-query/logging/types.ts delete mode 100644 src/core/react-query/logs/queries.ts create mode 100644 src/pages/logs/LogLevelChip.tsx create mode 100644 src/pages/logs/LogLiveView.tsx create mode 100644 src/pages/logs/LogRow.tsx create mode 100644 src/pages/logs/LogSearchView.tsx diff --git a/src/core/react-query/logging/queries.ts b/src/core/react-query/logging/queries.ts new file mode 100644 index 000000000..de903c18f --- /dev/null +++ b/src/core/react-query/logging/queries.ts @@ -0,0 +1,98 @@ +import { useEffect } from 'react'; +import { HttpTransportType, HubConnectionBuilder, JsonHubProtocol, LogLevel } from '@microsoft/signalr'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; + +import { axios } from '@/core/axios'; +import queryClient from '@/core/react-query/queryClient'; +import { useSelector } from '@/core/store'; +import { dayjs } from '@/core/util'; + +import type { LogEventType, LogReadResultType, LogsSearchParamsType } from '@/core/react-query/logging/types'; + +const logsQueryKey = ['logs']; + +export const formatStamp = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'); +const formatTimestamps = (lines: LogEventType[]): LogEventType[] => + lines.map(item => ({ ...item, TimeStamp: formatStamp(item.TimeStamp) })); + +const useLogsSubscription = () => { + const apikey = useSelector(state => state.apiSession.apikey); + + useEffect(() => { + const connectionLogHub = '/signalr/logging'; + const protocol = new JsonHubProtocol(); + // oxlint-disable-next-line no-bitwise + const transport = HttpTransportType.WebSockets | HttpTransportType.LongPolling; + const options = { + transport, + logMessageContent: true, + logger: LogLevel.Warning, + accessTokenFactory: () => apikey, + }; + + const connectionLog = new HubConnectionBuilder().withUrl(connectionLogHub, options).withHubProtocol(protocol) + .withAutomaticReconnect([5000, 15000, 30000, 60000, 90000]) + .build(); + + connectionLog.onreconnected(() => { + // The server re-sends GetBacklog on every connect; reset the tail so the + // fresh backlog replaces the stale one instead of duplicating entries. + queryClient.setQueryData(logsQueryKey, []); + }); + + connectionLog.on( + 'GetBacklog', + (lines: LogEventType[]) => { + queryClient.setQueryData(logsQueryKey, (oldData: LogEventType[] | undefined) => { + const newData = formatTimestamps(lines); + return oldData ? [...oldData, ...newData] : newData; + }); + }, + ); + + connectionLog.on( + 'Log', + (line: LogEventType) => { + queryClient.setQueryData(logsQueryKey, (oldData: LogEventType[] | undefined) => { + const newData = { ...line, TimeStamp: formatStamp(line.TimeStamp) }; + return oldData ? [...oldData, newData] : [newData]; + }); + }, + ); + + connectionLog.start().catch(console.error); + + return () => { + connectionLog.stop().catch(console.error); + }; + }, [apikey]); +}; + +export const useLogsQuery = () => { + useLogsSubscription(); + return useQuery({ + queryKey: logsQueryKey, + queryFn: () => [], + initialData: [], + staleTime: Infinity, + gcTime: Infinity, + }); +}; + +export const useLogsSearchQuery = ({ levels, search }: LogsSearchParamsType) => + useInfiniteQuery({ + queryKey: ['logs', 'search', { search, levels }], + queryFn: ({ pageParam }) => + axios.get('Logging/Range/Read', { + params: { + offset: pageParam, + limit: 100, + descending: true, + // Server expects a comma-separated list of LogLevel names; omitted params are inactive filters. + level: levels.size > 0 ? [...levels].join(',') : undefined, + message: search, + }, + }), + getNextPageParam: lastPage => lastPage.NextOffset, + initialPageParam: 0, + }); diff --git a/src/core/react-query/logging/types.ts b/src/core/react-query/logging/types.ts new file mode 100644 index 000000000..93b1878f9 --- /dev/null +++ b/src/core/react-query/logging/types.ts @@ -0,0 +1,23 @@ +// Mirrors Microsoft.Extensions.Logging.LogLevel as serialized by the server (StringEnumConverter). +export type LogLevelType = 'Trace' | 'Debug' | 'Information' | 'Warning' | 'Error' | 'Critical' | 'None'; + +export type LogEventType = { + TimeStamp: string; + Level: LogLevelType; + ThreadID?: number; + ProcessID?: number; + Logger?: string; + Caller?: string; + Message: string; + Exception?: string; +}; + +export type LogReadResultType = { + NextOffset: number | null; + Entries: LogEventType[]; +}; + +export type LogsSearchParamsType = { + search: string; + levels: Set; +}; diff --git a/src/core/react-query/logs/queries.ts b/src/core/react-query/logs/queries.ts deleted file mode 100644 index 41e246a79..000000000 --- a/src/core/react-query/logs/queries.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect } from 'react'; -import { HttpTransportType, HubConnectionBuilder, JsonHubProtocol, LogLevel } from '@microsoft/signalr'; -import { useQuery } from '@tanstack/react-query'; - -import queryClient from '@/core/react-query/queryClient'; -import { useSelector } from '@/core/store'; -import { dayjs } from '@/core/util'; - -import type { LogLineType } from '@/core/types/api/common'; - -const logsQueryKey = ['logs']; - -const formatStamp = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'); -const formatTimestamps = (lines: LogLineType[]): LogLineType[] => - lines.map(item => ({ ...item, TimeStamp: formatStamp(item.TimeStamp) })); - -const useLogsSubscription = () => { - const apikey = useSelector(state => state.apiSession.apikey); - - useEffect(() => { - const connectionLogHub = '/signalr/logging'; - const protocol = new JsonHubProtocol(); - // oxlint-disable-next-line no-bitwise - const transport = HttpTransportType.WebSockets | HttpTransportType.LongPolling; - const options = { - transport, - logMessageContent: true, - logger: LogLevel.Warning, - accessTokenFactory: () => apikey, - }; - - const connectionLog = new HubConnectionBuilder().withUrl(connectionLogHub, options).withHubProtocol(protocol) - .build(); - - connectionLog.on( - 'GetBacklog', - (lines: LogLineType[]) => { - queryClient.setQueryData(logsQueryKey, (oldData: LogLineType[] | undefined) => { - const newData = formatTimestamps(lines); - return oldData ? [...oldData, ...newData] : newData; - }); - }, - ); - - connectionLog.on( - 'Log', - (line: LogLineType) => { - queryClient.setQueryData(logsQueryKey, (oldData: LogLineType[] | undefined) => { - const newData = { ...line, TimeStamp: formatStamp(line.TimeStamp) }; - return oldData ? [...oldData, newData] : [newData]; - }); - }, - ); - - connectionLog.start().catch(console.error); - - return () => { - connectionLog.stop().catch(console.error); - }; - }, [apikey]); -}; - -export const useLogsQuery = () => { - useLogsSubscription(); - return useQuery({ - queryKey: logsQueryKey, - queryFn: () => [], - initialData: [], - staleTime: Infinity, - gcTime: Infinity, - }); -}; diff --git a/src/core/types/api/common.ts b/src/core/types/api/common.ts index e8448363b..c0858d55f 100644 --- a/src/core/types/api/common.ts +++ b/src/core/types/api/common.ts @@ -37,12 +37,6 @@ export type RatingType = { Type: 'Permanent' | 'Temporary'; }; -export type LogLineType = { - TimeStamp: string; - Message: string; - Level: string; -}; - export type DataSourceValues = | 'Plugin' | 'LocallyGenerated' diff --git a/src/pages/logs/LogLevelChip.tsx b/src/pages/logs/LogLevelChip.tsx new file mode 100644 index 000000000..a95bc5640 --- /dev/null +++ b/src/pages/logs/LogLevelChip.tsx @@ -0,0 +1,46 @@ +import cx from 'classnames'; + +import type { LogLevelType } from '@/core/react-query/logging/types'; + +const logLevelStyles: Record = { + Trace: 'bg-panel-text/25 text-panel-text/90', + Debug: 'bg-panel-text-other/15 text-panel-text-other', + Information: 'bg-panel-text-important/15 text-panel-text-important', + Warning: 'bg-panel-text-warning/15 text-panel-text-warning', + Error: 'bg-panel-text-danger/20 text-panel-text-danger', + Critical: 'bg-panel-text-danger text-button-danger-text', + None: 'bg-panel-text/25 text-panel-text/90', +}; + +const logLevelHoverStyles: Record = { + Trace: 'hover:bg-panel-text/25 hover:text-panel-text/90', + Debug: 'hover:bg-panel-text-other/15 hover:text-panel-text-other', + Information: 'hover:bg-panel-text-important/15 hover:text-panel-text-important', + Warning: 'hover:bg-panel-text-warning/15 hover:text-panel-text-warning', + Error: 'hover:bg-panel-text-danger/20 hover:text-panel-text-danger', + Critical: 'hover:bg-panel-text-danger hover:text-button-danger-text', + None: 'hover:bg-panel-text/25 hover:text-panel-text/90', +}; + +type Props = { + level: LogLevelType; + active?: boolean; +}; + +const LogLevelChip = ({ active = true, level }: Props) => ( +
+ {level} +
+); + +export default LogLevelChip; diff --git a/src/pages/logs/LogLiveView.tsx b/src/pages/logs/LogLiveView.tsx new file mode 100644 index 000000000..78b1b7533 --- /dev/null +++ b/src/pages/logs/LogLiveView.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef } from 'react'; +import { mdiLoading, mdiTextSearch } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { throttle } from 'lodash'; + +import Button from '@/components/Input/Button'; +import LogRow from '@/pages/logs/LogRow'; + +import type { LogEventType, LogLevelType } from '@/core/react-query/logging/types'; + +type Props = { + activeLevels: Set; + logLines: LogEventType[]; + onClearFilters: () => void; + scrollToBottom: boolean; + setScrollToBottom: (value: boolean) => void; +}; + +const LogLiveView = ({ activeLevels, logLines, onClearFilters, scrollToBottom, setScrollToBottom }: Props) => { + const visibleLines = activeLevels.size === 0 + ? logLines + : logLines.filter(line => activeLevels.has(line.Level)); + + const parentRef = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: visibleLines.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 36, + useFlushSync: false, + }); + const virtualItems = rowVirtualizer.getVirtualItems(); + // Magic code stolen from https://github.com/TanStack/virtual/issues/634 + // Fixes autoscroll issue in firefox + // and now apparently chrome too + if (parentRef.current) { + rowVirtualizer.scrollRect = { height: parentRef.current.clientHeight, width: parentRef.current.clientWidth }; + } + + useEffect(() => { + if (!scrollToBottom || visibleLines.length === 0) return; + rowVirtualizer.scrollToIndex(visibleLines.length - 1); + }, [visibleLines, scrollToBottom, rowVirtualizer]); + + // Taken from ChatGPT... + // Disables auto scroll when user scrolls up + const checkScrollDirection = useRef( + throttle(() => { + if (!parentRef.current) return; + const currentScroll = parentRef.current.scrollTop; + + setTimeout(() => { + if (parentRef.current && parentRef.current.scrollTop < currentScroll) setScrollToBottom(false); + }, 50); + }, 1000), + ).current; + + // This exists because the value of scrollToBottom won't change inside checkScrollDirection + const handleScroll = () => { + if (scrollToBottom) checkScrollDirection(); + }; + + return ( +
+ {logLines.length === 0 && ( +
+ +
+ )} + + {logLines.length > 0 && visibleLines.length === 0 && ( +
+ +
No log messages match
+
Adjust the level filters or clear them.
+ +
+ )} + + {visibleLines.length > 0 && ( +
+
+ {virtualItems.map(virtualRow => ( + + ))} +
+
+ )} +
+ ); +}; + +export default LogLiveView; diff --git a/src/pages/logs/LogRow.tsx b/src/pages/logs/LogRow.tsx new file mode 100644 index 000000000..5da0c320b --- /dev/null +++ b/src/pages/logs/LogRow.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { mdiChevronDown, mdiChevronRight } from '@mdi/js'; +import { Icon } from '@mdi/react'; + +import { formatStamp } from '@/core/react-query/logging/queries'; +import LogLevelChip from '@/pages/logs/LogLevelChip'; + +import type { LogEventType } from '@/core/react-query/logging/types'; + +type Props = { + dataIndex: number; + event: LogEventType; + measureRef: (node: HTMLDivElement | null) => void; +}; + +const LogRow = ({ dataIndex, event, measureRef }: Props) => { + const [exceptionExpanded, setExceptionExpanded] = useState(false); + + // Logger and Caller are optional (older server versions may omit them) + const sourceParts: string[] = []; + if (event.Logger) sourceParts.push(event.Logger); + if (event.Caller) sourceParts.push(event.Caller); + const sourceText = sourceParts.join(' › '); + + return ( +
+
+ {formatStamp(event.TimeStamp)} +
+
+ +
+
+ {sourceText} +
+
+
+
{event.Message}
+ {event.Exception && ( + + )} +
+ {exceptionExpanded && event.Exception && ( +
+            {event.Exception}
+          
+ )} +
+
+ ); +}; + +export default LogRow; diff --git a/src/pages/logs/LogSearchView.tsx b/src/pages/logs/LogSearchView.tsx new file mode 100644 index 000000000..28fe476a3 --- /dev/null +++ b/src/pages/logs/LogSearchView.tsx @@ -0,0 +1,133 @@ +import { useMemo, useRef } from 'react'; +import { mdiLoading, mdiTextSearch } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { debounce } from 'lodash'; + +import Button from '@/components/Input/Button'; +import { useLogsSearchQuery } from '@/core/react-query/logging/queries'; +import LogRow from '@/pages/logs/LogRow'; + +import type { LogLevelType } from '@/core/react-query/logging/types'; + +// DSL prefixes the server understands (c: contains, =: equals, ^: starts, $: ends, ~: fuzzy, *: regex, +// with ! negate and # case-insensitive modifiers). A bare value is shorthand for "c:" (case-sensitive +// contains), so we make it case-insensitive by default unless the user typed DSL themselves. +const hasDslPrefix = (value: string) => /^[c=^$~*!#]+:/.test(value); + +const toServerSearch = (value: string) => (hasDslPrefix(value) ? value : `c#:${value}`); + +type Props = { + activeLevels: Set; + onClearFilters: () => void; + search: string; +}; + +const LogSearchView = ({ activeLevels, onClearFilters, search }: Props) => { + const serverSearch = toServerSearch(search); + + const { data, fetchNextPage, isFetching, isFetchingNextPage, isPending } = useLogsSearchQuery({ + search: serverSearch, + levels: activeLevels, + }); + + const logEntries = data?.pages.flatMap(page => page.Entries) ?? []; + + const hasMore = data?.pages[data.pages.length - 1]?.NextOffset !== null; + + const searching = isPending || isFetchingNextPage || (isFetching && !isFetchingNextPage && logEntries.length === 0); + + const fetchNextPageDebounced = useMemo( + () => + debounce(() => { + if (!hasMore || isFetchingNextPage) return; + fetchNextPage().catch(console.error); + }, 50), + [hasMore, isFetchingNextPage, fetchNextPage], + ); + + const parentRef = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: logEntries.length + (hasMore ? 1 : 0), + getScrollElement: () => parentRef.current, + estimateSize: () => 36, + useFlushSync: false, + }); + const virtualItems = rowVirtualizer.getVirtualItems(); + // Magic code stolen from https://github.com/TanStack/virtual/issues/634 + // Fixes autoscroll issue in firefox + // and now apparently chrome too + if (parentRef.current) { + rowVirtualizer.scrollRect = { height: parentRef.current.clientHeight, width: parentRef.current.clientWidth }; + } + + return ( +
+ {searching && ( +
+ +
Searching server logs...
+
This covers the full log history, not just the live tail.
+
+ )} + + {!searching && !isFetching && logEntries.length === 0 && ( +
+ +
No server log entries match your search.
+
Try different keywords or clear the filters.
+ +
+ )} + + {logEntries.length > 0 && ( +
+
+
+ {virtualItems.map((virtualRow) => { + const isLoadMoreTrigger = virtualRow.index === logEntries.length; + if (isLoadMoreTrigger && !isFetchingNextPage) fetchNextPageDebounced(); + + const event = logEntries[virtualRow.index]; + + if (!event) { + return ( +
+ +
+ ); + } + + return ( + + ); + })} +
+
+
+ )} +
+ ); +}; + +export default LogSearchView; diff --git a/src/pages/logs/LogsPage.tsx b/src/pages/logs/LogsPage.tsx index 4d007d239..d239f8f93 100644 --- a/src/pages/logs/LogsPage.tsx +++ b/src/pages/logs/LogsPage.tsx @@ -1,79 +1,102 @@ -import { useEffect, useRef, useState } from 'react'; -import { mdiArrowVerticalLock, mdiLoading } from '@mdi/js'; -import { Icon } from '@mdi/react'; -import { useVirtualizer } from '@tanstack/react-virtual'; +import { useState } from 'react'; +import { mdiArrowVerticalLock, mdiFilterRemoveOutline, mdiMagnify } from '@mdi/js'; import cx from 'classnames'; -import { throttle } from 'lodash'; +import { useImmer } from 'use-immer'; +import { useDebounceValue } from 'usehooks-ts'; +import Button from '@/components/Input/Button'; import IconButton from '@/components/Input/IconButton'; -import { useLogsQuery } from '@/core/react-query/logs/queries'; +import Input from '@/components/Input/Input'; +import { useLogsQuery } from '@/core/react-query/logging/queries'; +import { formatThousand } from '@/core/util'; +import LogLevelChip from '@/pages/logs/LogLevelChip'; +import LogLiveView from '@/pages/logs/LogLiveView'; +import LogSearchView from '@/pages/logs/LogSearchView'; + +import type { LogLevelType } from '@/core/react-query/logging/types'; + +// `None` is MEL enum completeness only — the server never emits it, so it's not +// offered as a filter here. +const logLevels: LogLevelType[] = ['Trace', 'Debug', 'Information', 'Warning', 'Error', 'Critical']; const LogsPage = () => { const logLines = useLogsQuery().data; const [scrollToBottom, setScrollToBottom] = useState(true); + const [search, setSearch] = useState(''); + const [debouncedSearch] = useDebounceValue(search.trim(), 250); + const [activeLevels, setActiveLevels] = useImmer>(new Set()); - const parentRef = useRef(null); - const rowVirtualizer = useVirtualizer({ - count: logLines.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 34, - }); - const virtualItems = rowVirtualizer.getVirtualItems(); - // Magic code stolen from https://github.com/TanStack/virtual/issues/634 - // Fixes autoscroll issue in firefox - // and now apparently chrome too - if (parentRef.current) { - rowVirtualizer.scrollRect = { height: parentRef.current.clientHeight, width: parentRef.current.clientWidth }; - } - - useEffect(() => { - if (!rowVirtualizer || !scrollToBottom || logLines.length === 0) return; - rowVirtualizer.scrollToIndex(logLines.length - 1); - }, [logLines, scrollToBottom, rowVirtualizer]); - - // Taken from ChatGPT... - // Disables auto scroll when user scrolls up - const checkScrollDirection = useRef( - throttle(() => { - if (!parentRef.current) return; - const currentScroll = parentRef.current.scrollTop; + // Live mode shows the SignalR tail; any debounced search text switches to server-side search + const searchMode = debouncedSearch !== ''; + const filtersActive = search !== '' || activeLevels.size > 0; - setTimeout(() => { - if (parentRef.current && parentRef.current.scrollTop < currentScroll) setScrollToBottom(false); - }, 50); - }, 1000), - ).current; + const toggleLevel = (level: LogLevelType) => { + setActiveLevels((draft) => { + if (draft.has(level)) { + draft.delete(level); + } else { + draft.add(level); + } + }); + }; - // This exists because the value of scrollToBottom won't change inside checkScrollDirection - const handleScroll = () => { - if (scrollToBottom) checkScrollDirection(); + const clearFilters = () => { + setSearch(''); + setActiveLevels(new Set()); }; return ( <> Logs | Shoko
-
-
Logs
-
- {/* TODO: Disabled until functionality is implemented */} - {/* setSearch(event.target.value)} */} - {/* type="text" */} - {/* value={search} */} - {/* placeholder="Search Logs..." */} - {/* startIcon={mdiMagnify} */} - {/* className="w-80" */} - {/* disabled */} - {/* /> */} - {/* */} - {/* */} +
+
+
+ Logs +
+
+ {searchMode + ? 'Searching the full log history on the server' + : `${formatThousand(logLines.length)} lines in the live tail`} +
+
+ +
+
+ {logLevels.map(level => ( + + ))} +
+ + setSearch(event.target.value)} + inputClassName="py-2!" + /> + setScrollToBottom(prev => !prev)} tooltip={`${scrollToBottom ? 'Disable' : 'Enable'} scroll to bottom`} /> @@ -81,45 +104,23 @@ const LogsPage = () => {
-
- {logLines.length === 0 - ? ( -
- -
- ) - : ( -
-
- {virtualItems.map((virtualRow) => { - const row = logLines[virtualRow.index]; - return ( -
-
{row.TimeStamp}
-
{row.Level}
-
{row.Message}
-
- ); - })} -
-
- )} -
+ {searchMode + ? ( + + ) + : ( + + )}
From 6ce172fa720866b1298b0f131bda0835dd39fb5b Mon Sep 17 00:00:00 2001 From: Harshith Mohan <26010946+harshithmohan@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:28:49 +0530 Subject: [PATCH 2/4] fix(logs): address review findings for server-side search rewrite - Derive stable, content-sensitive query key from level Set (Set serializes to {} in the hash, so level filters never refetched during search) - Omit empty message/level params instead of sending empty DSL operands - Skip DSL wrapping for empty search strings - Fix DSL prefix regex to match server grammar (mode char first, at most one ! and one # modifier in either order) - Gate full-screen spinner to initial fetch only; drop redundant isPending - Replace render-phase side effect + useMemo debounce with an effect keyed on the trailing virtual index (React Compiler rule compliance) - Format timestamps once in LogRow; remove formatStamp/formatTimestamps from the data layer - Route level-chip filtering to the server search view; drop unused activeLevels/onClearFilters props from LogLiveView - Extract virtualizer scrollRect workaround into useVirtualizerScrollRectWorkaround hook - Fix hasMore off-by-one before first load (!= null) - Sync filtersActive with debounced search value --- src/core/react-query/logging/queries.ts | 35 ++++++------ .../useVirtualizerScrollRectWorkaround.ts | 18 +++++++ src/pages/logs/LogLiveView.tsx | 44 ++++----------- src/pages/logs/LogRow.tsx | 4 +- src/pages/logs/LogSearchView.tsx | 53 +++++++++---------- src/pages/logs/LogsPage.tsx | 15 +++--- 6 files changed, 81 insertions(+), 88 deletions(-) create mode 100644 src/hooks/useVirtualizerScrollRectWorkaround.ts diff --git a/src/core/react-query/logging/queries.ts b/src/core/react-query/logging/queries.ts index de903c18f..28e763ac3 100644 --- a/src/core/react-query/logging/queries.ts +++ b/src/core/react-query/logging/queries.ts @@ -5,16 +5,11 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { axios } from '@/core/axios'; import queryClient from '@/core/react-query/queryClient'; import { useSelector } from '@/core/store'; -import { dayjs } from '@/core/util'; import type { LogEventType, LogReadResultType, LogsSearchParamsType } from '@/core/react-query/logging/types'; const logsQueryKey = ['logs']; -export const formatStamp = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'); -const formatTimestamps = (lines: LogEventType[]): LogEventType[] => - lines.map(item => ({ ...item, TimeStamp: formatStamp(item.TimeStamp) })); - const useLogsSubscription = () => { const apikey = useSelector(state => state.apiSession.apikey); @@ -43,20 +38,20 @@ const useLogsSubscription = () => { connectionLog.on( 'GetBacklog', (lines: LogEventType[]) => { - queryClient.setQueryData(logsQueryKey, (oldData: LogEventType[] | undefined) => { - const newData = formatTimestamps(lines); - return oldData ? [...oldData, ...newData] : newData; - }); + queryClient.setQueryData( + logsQueryKey, + (oldData: LogEventType[] | undefined) => (oldData ? [...oldData, ...lines] : lines), + ); }, ); connectionLog.on( 'Log', (line: LogEventType) => { - queryClient.setQueryData(logsQueryKey, (oldData: LogEventType[] | undefined) => { - const newData = { ...line, TimeStamp: formatStamp(line.TimeStamp) }; - return oldData ? [...oldData, newData] : [newData]; - }); + queryClient.setQueryData( + logsQueryKey, + (oldData: LogEventType[] | undefined) => (oldData ? [...oldData, line] : [line]), + ); }, ); @@ -79,9 +74,12 @@ export const useLogsQuery = () => { }); }; -export const useLogsSearchQuery = ({ levels, search }: LogsSearchParamsType) => - useInfiniteQuery({ - queryKey: ['logs', 'search', { search, levels }], +export const useLogsSearchQuery = ({ levels, search }: LogsSearchParamsType) => { + // Sets serialize to {} in the query key hash, so derive a stable, content-sensitive string instead. + // Sorted so any chip-toggle order produces the same key. + const levelKey = [...levels].sort().join(','); + return useInfiniteQuery({ + queryKey: ['logs', 'search', { search, levels: levelKey }], queryFn: ({ pageParam }) => axios.get('Logging/Range/Read', { params: { @@ -89,10 +87,11 @@ export const useLogsSearchQuery = ({ levels, search }: LogsSearchParamsType) => limit: 100, descending: true, // Server expects a comma-separated list of LogLevel names; omitted params are inactive filters. - level: levels.size > 0 ? [...levels].join(',') : undefined, - message: search, + level: levelKey || undefined, + message: search || undefined, }, }), getNextPageParam: lastPage => lastPage.NextOffset, initialPageParam: 0, }); +}; diff --git a/src/hooks/useVirtualizerScrollRectWorkaround.ts b/src/hooks/useVirtualizerScrollRectWorkaround.ts new file mode 100644 index 000000000..dec79d05a --- /dev/null +++ b/src/hooks/useVirtualizerScrollRectWorkaround.ts @@ -0,0 +1,18 @@ +import type { RefObject } from 'react'; + +import type { Virtualizer } from '@tanstack/react-virtual'; + +// Workaround for https://github.com/TanStack/virtual/issues/634: the virtualizer can cache a +// stale/zero-sized scrollRect when the scroll container mounts, breaking item measurement. +// Patching it from the live element on every render fixes virtualization in Firefox and Chrome. +const useVirtualizerScrollRectWorkaround = ( + rowVirtualizer: Virtualizer, + parentRef: RefObject, +) => { + if (parentRef.current) { + // oxlint-disable-next-line no-param-reassign -- mutating the virtualizer instance is the workaround itself + rowVirtualizer.scrollRect = { height: parentRef.current.clientHeight, width: parentRef.current.clientWidth }; + } +}; + +export default useVirtualizerScrollRectWorkaround; diff --git a/src/pages/logs/LogLiveView.tsx b/src/pages/logs/LogLiveView.tsx index 78b1b7533..24b38b49e 100644 --- a/src/pages/logs/LogLiveView.tsx +++ b/src/pages/logs/LogLiveView.tsx @@ -1,46 +1,35 @@ import { useEffect, useRef } from 'react'; -import { mdiLoading, mdiTextSearch } from '@mdi/js'; +import { mdiLoading } from '@mdi/js'; import { Icon } from '@mdi/react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { throttle } from 'lodash'; -import Button from '@/components/Input/Button'; +import useVirtualizerScrollRectWorkaround from '@/hooks/useVirtualizerScrollRectWorkaround'; import LogRow from '@/pages/logs/LogRow'; -import type { LogEventType, LogLevelType } from '@/core/react-query/logging/types'; +import type { LogEventType } from '@/core/react-query/logging/types'; type Props = { - activeLevels: Set; logLines: LogEventType[]; - onClearFilters: () => void; scrollToBottom: boolean; setScrollToBottom: (value: boolean) => void; }; -const LogLiveView = ({ activeLevels, logLines, onClearFilters, scrollToBottom, setScrollToBottom }: Props) => { - const visibleLines = activeLevels.size === 0 - ? logLines - : logLines.filter(line => activeLevels.has(line.Level)); - +const LogLiveView = ({ logLines, scrollToBottom, setScrollToBottom }: Props) => { const parentRef = useRef(null); const rowVirtualizer = useVirtualizer({ - count: visibleLines.length, + count: logLines.length, getScrollElement: () => parentRef.current, estimateSize: () => 36, useFlushSync: false, }); const virtualItems = rowVirtualizer.getVirtualItems(); - // Magic code stolen from https://github.com/TanStack/virtual/issues/634 - // Fixes autoscroll issue in firefox - // and now apparently chrome too - if (parentRef.current) { - rowVirtualizer.scrollRect = { height: parentRef.current.clientHeight, width: parentRef.current.clientWidth }; - } + useVirtualizerScrollRectWorkaround(rowVirtualizer, parentRef); useEffect(() => { - if (!scrollToBottom || visibleLines.length === 0) return; - rowVirtualizer.scrollToIndex(visibleLines.length - 1); - }, [visibleLines, scrollToBottom, rowVirtualizer]); + if (!scrollToBottom || logLines.length === 0) return; + rowVirtualizer.scrollToIndex(logLines.length - 1); + }, [logLines, scrollToBottom, rowVirtualizer]); // Taken from ChatGPT... // Disables auto scroll when user scrolls up @@ -72,18 +61,7 @@ const LogLiveView = ({ activeLevels, logLines, onClearFilters, scrollToBottom, s
)} - {logLines.length > 0 && visibleLines.length === 0 && ( -
- -
No log messages match
-
Adjust the level filters or clear them.
- -
- )} - - {visibleLines.length > 0 && ( + {logLines.length > 0 && (
))} diff --git a/src/pages/logs/LogRow.tsx b/src/pages/logs/LogRow.tsx index 5da0c320b..aabbea413 100644 --- a/src/pages/logs/LogRow.tsx +++ b/src/pages/logs/LogRow.tsx @@ -2,11 +2,13 @@ import { useState } from 'react'; import { mdiChevronDown, mdiChevronRight } from '@mdi/js'; import { Icon } from '@mdi/react'; -import { formatStamp } from '@/core/react-query/logging/queries'; +import { dayjs } from '@/core/util'; import LogLevelChip from '@/pages/logs/LogLevelChip'; import type { LogEventType } from '@/core/react-query/logging/types'; +const formatStamp = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'); + type Props = { dataIndex: number; event: LogEventType; diff --git a/src/pages/logs/LogSearchView.tsx b/src/pages/logs/LogSearchView.tsx index 28fe476a3..73ecf36de 100644 --- a/src/pages/logs/LogSearchView.tsx +++ b/src/pages/logs/LogSearchView.tsx @@ -1,21 +1,24 @@ -import { useMemo, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { mdiLoading, mdiTextSearch } from '@mdi/js'; import { Icon } from '@mdi/react'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { debounce } from 'lodash'; import Button from '@/components/Input/Button'; import { useLogsSearchQuery } from '@/core/react-query/logging/queries'; +import useVirtualizerScrollRectWorkaround from '@/hooks/useVirtualizerScrollRectWorkaround'; import LogRow from '@/pages/logs/LogRow'; import type { LogLevelType } from '@/core/react-query/logging/types'; -// DSL prefixes the server understands (c: contains, =: equals, ^: starts, $: ends, ~: fuzzy, *: regex, -// with ! negate and # case-insensitive modifiers). A bare value is shorthand for "c:" (case-sensitive -// contains), so we make it case-insensitive by default unless the user typed DSL themselves. -const hasDslPrefix = (value: string) => /^[c=^$~*!#]+:/.test(value); +// DSL grammar the server accepts (LogService.TryParseLogFilterDsl): a mode char first — c: contains, +// =: equals, ^: starts, $: ends, ~: fuzzy, *: regex — optionally followed by at most one ! (negate) +// and one # (case-insensitive) in either order, then ':'. ! and # are modifiers, never prefixes on +// their own (e.g. "!c:foo" or "#:foo" are server-side 400s). A bare value is shorthand for "c:" +// (case-sensitive contains), so we make it case-insensitive by default unless the user typed valid +// DSL themselves; anything else gets wrapped as a literal. +const hasDslPrefix = (value: string) => /^[c=^$~*](?:!#|#!|!|#)?:/.test(value); -const toServerSearch = (value: string) => (hasDslPrefix(value) ? value : `c#:${value}`); +const toServerSearch = (value: string) => (!value || hasDslPrefix(value) ? value : `c#:${value}`); type Props = { activeLevels: Set; @@ -26,25 +29,16 @@ type Props = { const LogSearchView = ({ activeLevels, onClearFilters, search }: Props) => { const serverSearch = toServerSearch(search); - const { data, fetchNextPage, isFetching, isFetchingNextPage, isPending } = useLogsSearchQuery({ + const { data, fetchNextPage, isFetching, isFetchingNextPage } = useLogsSearchQuery({ search: serverSearch, levels: activeLevels, }); const logEntries = data?.pages.flatMap(page => page.Entries) ?? []; - const hasMore = data?.pages[data.pages.length - 1]?.NextOffset !== null; + const hasMore = data?.pages[data.pages.length - 1]?.NextOffset != null; - const searching = isPending || isFetchingNextPage || (isFetching && !isFetchingNextPage && logEntries.length === 0); - - const fetchNextPageDebounced = useMemo( - () => - debounce(() => { - if (!hasMore || isFetchingNextPage) return; - fetchNextPage().catch(console.error); - }, 50), - [hasMore, isFetchingNextPage, fetchNextPage], - ); + const searching = isFetching && logEntries.length === 0; const parentRef = useRef(null); const rowVirtualizer = useVirtualizer({ @@ -54,12 +48,18 @@ const LogSearchView = ({ activeLevels, onClearFilters, search }: Props) => { useFlushSync: false, }); const virtualItems = rowVirtualizer.getVirtualItems(); - // Magic code stolen from https://github.com/TanStack/virtual/issues/634 - // Fixes autoscroll issue in firefox - // and now apparently chrome too - if (parentRef.current) { - rowVirtualizer.scrollRect = { height: parentRef.current.clientHeight, width: parentRef.current.clientWidth }; - } + + // Trigger the next page fetch from an effect on the trailing virtual row instead of a + // render-phase side effect; fetchNextPage has stable identity so no debounce is needed. + const lastVirtualItem = virtualItems[virtualItems.length - 1]; + const lastVirtualIndex = lastVirtualItem?.index; + useEffect(() => { + if (lastVirtualIndex === logEntries.length && hasMore && !isFetchingNextPage) { + fetchNextPage().catch(console.error); + } + }, [lastVirtualIndex, hasMore, isFetchingNextPage, fetchNextPage, logEntries.length]); + + useVirtualizerScrollRectWorkaround(rowVirtualizer, parentRef); return (
{ style={{ transform: `translateY(${virtualItems[0]?.start ?? 0}px)` }} > {virtualItems.map((virtualRow) => { - const isLoadMoreTrigger = virtualRow.index === logEntries.length; - if (isLoadMoreTrigger && !isFetchingNextPage) fetchNextPageDebounced(); - const event = logEntries[virtualRow.index]; if (!event) { diff --git a/src/pages/logs/LogsPage.tsx b/src/pages/logs/LogsPage.tsx index d239f8f93..276603a78 100644 --- a/src/pages/logs/LogsPage.tsx +++ b/src/pages/logs/LogsPage.tsx @@ -26,9 +26,10 @@ const LogsPage = () => { const [debouncedSearch] = useDebounceValue(search.trim(), 250); const [activeLevels, setActiveLevels] = useImmer>(new Set()); - // Live mode shows the SignalR tail; any debounced search text switches to server-side search - const searchMode = debouncedSearch !== ''; - const filtersActive = search !== '' || activeLevels.size > 0; + // Live mode shows the SignalR tail; any active filter (debounced search text or level chips) + // switches to server-side search over the full history. A future mode toggle could let the + // level chips and/or search text filter the live tail client-side instead, if needed. + const filtersActive = debouncedSearch !== '' || activeLevels.size > 0; const toggleLevel = (level: LogLevelType) => { setActiveLevels((draft) => { @@ -55,7 +56,7 @@ const LogsPage = () => { Logs
- {searchMode + {filtersActive ? 'Searching the full log history on the server' : `${formatThousand(logLines.length)} lines in the live tail`}
@@ -95,7 +96,7 @@ const LogsPage = () => { icon={mdiArrowVerticalLock} buttonType="secondary" buttonSize="normal" - disabled={searchMode} + disabled={filtersActive} className={cx(scrollToBottom ? 'text-panel-icon-action' : 'text-panel-text!')} onClick={() => setScrollToBottom(prev => !prev)} tooltip={`${scrollToBottom ? 'Disable' : 'Enable'} scroll to bottom`} @@ -104,7 +105,7 @@ const LogsPage = () => {
- {searchMode + {filtersActive ? ( { : ( )}
From af7eab9f9fe862a4504cbdc4df4812ddf9ccee60 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <26010946+harshithmohan@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:55:35 +0530 Subject: [PATCH 3/4] fix(logs): stop scroll-to-bottom lock disabling itself on load The old detection compared scrollTop snapshots 50ms apart, so the virtualizer's measurement corrections (rows measured != 36px estimate) and programmatic scrollToIndex events looked like a user scroll-up and disabled the lock as soon as logs loaded. Replace it with a bottom check: while locked, the container is only not at the bottom if the user scrolled up. Also catches scrollbar drags and drops the throttle machinery. --- src/pages/logs/LogLiveView.tsx | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/pages/logs/LogLiveView.tsx b/src/pages/logs/LogLiveView.tsx index 24b38b49e..ae72ecf62 100644 --- a/src/pages/logs/LogLiveView.tsx +++ b/src/pages/logs/LogLiveView.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'; import { mdiLoading } from '@mdi/js'; import { Icon } from '@mdi/react'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { throttle } from 'lodash'; import useVirtualizerScrollRectWorkaround from '@/hooks/useVirtualizerScrollRectWorkaround'; import LogRow from '@/pages/logs/LogRow'; @@ -31,22 +30,14 @@ const LogLiveView = ({ logLines, scrollToBottom, setScrollToBottom }: Props) => rowVirtualizer.scrollToIndex(logLines.length - 1); }, [logLines, scrollToBottom, rowVirtualizer]); - // Taken from ChatGPT... - // Disables auto scroll when user scrolls up - const checkScrollDirection = useRef( - throttle(() => { - if (!parentRef.current) return; - const currentScroll = parentRef.current.scrollTop; - - setTimeout(() => { - if (parentRef.current && parentRef.current.scrollTop < currentScroll) setScrollToBottom(false); - }, 50); - }, 1000), - ).current; - - // This exists because the value of scrollToBottom won't change inside checkScrollDirection + // Disables auto scroll when the user scrolls up. While locked, the only way the + // container stops being at the bottom is a user scroll — the programmatic + // scrollToIndex always lands at the bottom, and the virtualizer's measurement + // corrections keep it there. const handleScroll = () => { - if (scrollToBottom) checkScrollDirection(); + const container = parentRef.current; + if (!scrollToBottom || !container) return; + if (container.scrollHeight - container.scrollTop - container.clientHeight > 1) setScrollToBottom(false); }; return ( From 7f3ff85b6131752c94c7b07feebead8d4dffd232 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <26010946+harshithmohan@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:18:43 +0530 Subject: [PATCH 4/4] fix(logs): align search pagination with hasNextPage and use ascending order --- src/core/react-query/logging/queries.ts | 4 ++-- src/pages/logs/LogSearchView.tsx | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/core/react-query/logging/queries.ts b/src/core/react-query/logging/queries.ts index 28e763ac3..a66ebf056 100644 --- a/src/core/react-query/logging/queries.ts +++ b/src/core/react-query/logging/queries.ts @@ -85,13 +85,13 @@ export const useLogsSearchQuery = ({ levels, search }: LogsSearchParamsType) => params: { offset: pageParam, limit: 100, - descending: true, + descending: false, // Server expects a comma-separated list of LogLevel names; omitted params are inactive filters. level: levelKey || undefined, message: search || undefined, }, }), - getNextPageParam: lastPage => lastPage.NextOffset, + getNextPageParam: lastPage => lastPage.NextOffset ?? undefined, initialPageParam: 0, }); }; diff --git a/src/pages/logs/LogSearchView.tsx b/src/pages/logs/LogSearchView.tsx index 73ecf36de..d5f3d6139 100644 --- a/src/pages/logs/LogSearchView.tsx +++ b/src/pages/logs/LogSearchView.tsx @@ -29,20 +29,18 @@ type Props = { const LogSearchView = ({ activeLevels, onClearFilters, search }: Props) => { const serverSearch = toServerSearch(search); - const { data, fetchNextPage, isFetching, isFetchingNextPage } = useLogsSearchQuery({ + const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = useLogsSearchQuery({ search: serverSearch, levels: activeLevels, }); const logEntries = data?.pages.flatMap(page => page.Entries) ?? []; - const hasMore = data?.pages[data.pages.length - 1]?.NextOffset != null; - const searching = isFetching && logEntries.length === 0; const parentRef = useRef(null); const rowVirtualizer = useVirtualizer({ - count: logEntries.length + (hasMore ? 1 : 0), + count: logEntries.length + (hasNextPage ? 1 : 0), getScrollElement: () => parentRef.current, estimateSize: () => 36, useFlushSync: false, @@ -54,10 +52,10 @@ const LogSearchView = ({ activeLevels, onClearFilters, search }: Props) => { const lastVirtualItem = virtualItems[virtualItems.length - 1]; const lastVirtualIndex = lastVirtualItem?.index; useEffect(() => { - if (lastVirtualIndex === logEntries.length && hasMore && !isFetchingNextPage) { + if (lastVirtualIndex === logEntries.length && hasNextPage && !isFetchingNextPage) { fetchNextPage().catch(console.error); } - }, [lastVirtualIndex, hasMore, isFetchingNextPage, fetchNextPage, logEntries.length]); + }, [lastVirtualIndex, hasNextPage, isFetchingNextPage, fetchNextPage, logEntries.length]); useVirtualizerScrollRectWorkaround(rowVirtualizer, parentRef);