diff --git a/overseerr-api.yml b/overseerr-api.yml index 25a24667..164187de 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -2539,6 +2539,12 @@ paths: nullable: true enum: [debug, info, warn, error] default: debug + - in: query + name: search + schema: + type: string + nullable: true + example: plex responses: '200': description: Server log returned diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index de23d4b8..41d2c745 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -25,7 +25,7 @@ import { getAppVersion } from '@server/utils/appVersion'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; -import { merge, omit, set, sortBy } from 'lodash'; +import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { rescheduleJob } from 'node-schedule'; import path from 'path'; import semver from 'semver'; @@ -344,6 +344,8 @@ settingsRoutes.get( (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 25; const skip = req.query.skip ? Number(req.query.skip) : 0; + const search = (req.query.search as string) ?? ''; + const searchRegexp = new RegExp(escapeRegExp(search), 'i'); let filter: string[] = []; switch (req.query.filter) { @@ -375,6 +377,22 @@ settingsRoutes.get( 'data', ]; + const deepValueStrings = (obj: Record): string[] => { + const values = []; + + for (const val of Object.values(obj)) { + if (typeof val === 'string') { + values.push(val); + } else if (typeof val === 'number') { + values.push(val.toString()); + } else if (val !== null && typeof val === 'object') { + values.push(...deepValueStrings(val as Record)); + } + } + + return values; + }; + try { fs.readFileSync(logFile, 'utf-8') .split('\n') @@ -399,6 +417,19 @@ settingsRoutes.get( }); } + if (req.query.search) { + if ( + // label and data are sometimes undefined + !searchRegexp.test(logMessage.label ?? '') && + !searchRegexp.test(logMessage.message) && + !deepValueStrings(logMessage.data ?? {}).some((val) => + searchRegexp.test(val) + ) + ) { + return; + } + } + logs.push(logMessage); }); diff --git a/src/components/Settings/SettingsLogs/index.tsx b/src/components/Settings/SettingsLogs/index.tsx index 3b370e24..fbf5d5e0 100644 --- a/src/components/Settings/SettingsLogs/index.tsx +++ b/src/components/Settings/SettingsLogs/index.tsx @@ -5,6 +5,7 @@ import Modal from '@app/components/Common/Modal'; import PageTitle from '@app/components/Common/PageTitle'; import Table from '@app/components/Common/Table'; import Tooltip from '@app/components/Common/Tooltip'; +import useDebouncedState from '@app/hooks/useDebouncedState'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; @@ -17,6 +18,7 @@ import { FilterIcon, PauseIcon, PlayIcon, + SearchIcon, } from '@heroicons/react/solid'; import type { LogMessage, @@ -59,6 +61,8 @@ const SettingsLogs = () => { const { addToast } = useToasts(); const [currentFilter, setCurrentFilter] = useState('debug'); const [currentPageSize, setCurrentPageSize] = useState(25); + const [searchFilter, debouncedSearchFilter, setSearchFilter] = + useDebouncedState(''); const [refreshInterval, setRefreshInterval] = useState(5000); const [activeLog, setActiveLog] = useState<{ isOpen: boolean; @@ -76,7 +80,9 @@ const SettingsLogs = () => { const { data, error } = useSWR( `/api/v1/settings/logs?take=${currentPageSize}&skip=${ pageIndex * currentPageSize - }&filter=${currentFilter}`, + }&filter=${currentFilter}${ + debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : '' + }`, { refreshInterval: refreshInterval, revalidateOnFocus: false, @@ -118,15 +124,13 @@ const SettingsLogs = () => { }); }; - if (!data && !error) { - return ; - } - - if (!data) { + // check if there's no data and no errors in the table + // so as to show a spinner inside the table and not refresh the whole component + if (!data && error) { return ; } - const hasNextPage = data.pageInfo.pages > pageIndex + 1; + const hasNextPage = data?.pageInfo.pages ?? 0 > pageIndex + 1; const hasPrevPage = pageIndex > 0; return ( @@ -245,10 +249,21 @@ const SettingsLogs = () => { appDataPath: appData ? appData.appDataPath : '/app/config', })}

-
+
+
+ + + + setSearchFilter(e.target.value as string)} + /> +
-
-
- - - - +
+ + + + +
@@ -300,73 +315,81 @@ const SettingsLogs = () => { - {data.results.map((row: LogMessage, index: number) => { - return ( - - - {intl.formatDate(row.timestamp, { - year: 'numeric', - month: 'short', - day: '2-digit', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - })} - - - - {row.level.toUpperCase()} - - - - {row.label ?? ''} - - {row.message} - - {row.data && ( + {!data ? ( + + + + + + ) : ( + data.results.map((row: LogMessage, index: number) => { + return ( + + + {intl.formatDate(row.timestamp, { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + })} + + + + {row.level.toUpperCase()} + + + + {row.label ?? ''} + + {row.message} + + {row.data && ( + + + + )} - )} - - - - - - ); - })} + + + ); + }) + )} - {data.results.length === 0 && ( + {data?.results.length === 0 && (
@@ -396,15 +419,15 @@ const SettingsLogs = () => { >

- {data.results.length > 0 && + {(data?.results.length ?? 0) > 0 && intl.formatMessage(globalMessages.showingresults, { from: pageIndex * currentPageSize + 1, to: - data.results.length < currentPageSize + data?.results.length ?? 0 < currentPageSize ? pageIndex * currentPageSize + - data.results.length + (data?.results.length ?? 0) : (pageIndex + 1) * currentPageSize, - total: data.pageInfo.results, + total: data?.pageInfo.results ?? 0, strong: (msg: React.ReactNode) => ( {msg} ),