diff --git a/src/core/react-query/logging/queries.ts b/src/core/react-query/logging/queries.ts new file mode 100644 index 000000000..a66ebf056 --- /dev/null +++ b/src/core/react-query/logging/queries.ts @@ -0,0 +1,97 @@ +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 type { LogEventType, LogReadResultType, LogsSearchParamsType } from '@/core/react-query/logging/types'; + +const logsQueryKey = ['logs']; + +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) => (oldData ? [...oldData, ...lines] : lines), + ); + }, + ); + + connectionLog.on( + 'Log', + (line: LogEventType) => { + queryClient.setQueryData( + logsQueryKey, + (oldData: LogEventType[] | undefined) => (oldData ? [...oldData, line] : [line]), + ); + }, + ); + + 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) => { + // 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: { + offset: pageParam, + limit: 100, + 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 ?? undefined, + 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/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/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..ae72ecf62 --- /dev/null +++ b/src/pages/logs/LogLiveView.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef } from 'react'; +import { mdiLoading } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import { useVirtualizer } from '@tanstack/react-virtual'; + +import useVirtualizerScrollRectWorkaround from '@/hooks/useVirtualizerScrollRectWorkaround'; +import LogRow from '@/pages/logs/LogRow'; + +import type { LogEventType } from '@/core/react-query/logging/types'; + +type Props = { + logLines: LogEventType[]; + scrollToBottom: boolean; + setScrollToBottom: (value: boolean) => void; +}; + +const LogLiveView = ({ logLines, scrollToBottom, setScrollToBottom }: Props) => { + const parentRef = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: logLines.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 36, + useFlushSync: false, + }); + const virtualItems = rowVirtualizer.getVirtualItems(); + useVirtualizerScrollRectWorkaround(rowVirtualizer, parentRef); + + useEffect(() => { + if (!scrollToBottom || logLines.length === 0) return; + rowVirtualizer.scrollToIndex(logLines.length - 1); + }, [logLines, scrollToBottom, rowVirtualizer]); + + // 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 = () => { + const container = parentRef.current; + if (!scrollToBottom || !container) return; + if (container.scrollHeight - container.scrollTop - container.clientHeight > 1) setScrollToBottom(false); + }; + + return ( +
+ {logLines.length === 0 && ( +
+ +
+ )} + + {logLines.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..aabbea413 --- /dev/null +++ b/src/pages/logs/LogRow.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { mdiChevronDown, mdiChevronRight } from '@mdi/js'; +import { Icon } from '@mdi/react'; + +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; + 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..d5f3d6139 --- /dev/null +++ b/src/pages/logs/LogSearchView.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef } from 'react'; +import { mdiLoading, mdiTextSearch } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import { useVirtualizer } from '@tanstack/react-virtual'; + +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 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) => (!value || 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, hasNextPage, isFetching, isFetchingNextPage } = useLogsSearchQuery({ + search: serverSearch, + levels: activeLevels, + }); + + const logEntries = data?.pages.flatMap(page => page.Entries) ?? []; + + const searching = isFetching && logEntries.length === 0; + + const parentRef = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: logEntries.length + (hasNextPage ? 1 : 0), + getScrollElement: () => parentRef.current, + estimateSize: () => 36, + useFlushSync: false, + }); + const virtualItems = rowVirtualizer.getVirtualItems(); + + // 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 && hasNextPage && !isFetchingNextPage) { + fetchNextPage().catch(console.error); + } + }, [lastVirtualIndex, hasNextPage, isFetchingNextPage, fetchNextPage, logEntries.length]); + + useVirtualizerScrollRectWorkaround(rowVirtualizer, parentRef); + + 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 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..276603a78 100644 --- a/src/pages/logs/LogsPage.tsx +++ b/src/pages/logs/LogsPage.tsx @@ -1,79 +1,103 @@ -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 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; - 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 +
+
+ {filtersActive + ? '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 +105,21 @@ const LogsPage = () => {
-
- {logLines.length === 0 - ? ( -
- -
- ) - : ( -
-
- {virtualItems.map((virtualRow) => { - const row = logLines[virtualRow.index]; - return ( -
-
{row.TimeStamp}
-
{row.Level}
-
{row.Message}
-
- ); - })} -
-
- )} -
+ {filtersActive + ? ( + + ) + : ( + + )}