mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat(logs): add search filter (#2505)
* feat(logs): add search filter * refactor(logs): move loading spinner inside log viewer Inputting text in the search bar on the logs page would refresh the page losing focus on the search bar. This moves the loading spinner inside the log viewer, so that it is not as disruptive as it would * fix(logs): escape string for search filter * chore: rebase * fix(logs): suggested changes
This commit is contained in:

committed by
GitHub

parent
87825a0e05
commit
30141f76e0
@@ -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
|
||||
|
@@ -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, unknown>): 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<string, unknown>));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
@@ -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<Filter>('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<LogsResultsResponse>(
|
||||
`/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 <LoadingSpinner />;
|
||||
}
|
||||
|
||||
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 <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
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',
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-grow flex-row sm:flex-grow-0 sm:justify-end">
|
||||
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||
<SearchIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="rounded-r-only"
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value as string)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-1 flex-row justify-between sm:mb-0 sm:flex-none">
|
||||
<Button
|
||||
className="mr-2 w-full flex-grow"
|
||||
className="mr-2 flex flex-grow"
|
||||
buttonType={refreshInterval ? 'default' : 'primary'}
|
||||
onClick={() => toggleLogs()}
|
||||
>
|
||||
@@ -259,34 +274,34 @@ const SettingsLogs = () => {
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-1 sm:mb-0 sm:flex-none">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||
<FilterIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<select
|
||||
id="filter"
|
||||
name="filter"
|
||||
onChange={(e) => {
|
||||
setCurrentFilter(e.target.value as Filter);
|
||||
router.push(router.pathname);
|
||||
}}
|
||||
value={currentFilter}
|
||||
className="rounded-r-only"
|
||||
>
|
||||
<option value="debug">
|
||||
{intl.formatMessage(messages.filterDebug)}
|
||||
</option>
|
||||
<option value="info">
|
||||
{intl.formatMessage(messages.filterInfo)}
|
||||
</option>
|
||||
<option value="warn">
|
||||
{intl.formatMessage(messages.filterWarn)}
|
||||
</option>
|
||||
<option value="error">
|
||||
{intl.formatMessage(messages.filterError)}
|
||||
</option>
|
||||
</select>
|
||||
<div className="flex flex-grow">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||
<FilterIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<select
|
||||
id="filter"
|
||||
name="filter"
|
||||
onChange={(e) => {
|
||||
setCurrentFilter(e.target.value as Filter);
|
||||
router.push(router.pathname);
|
||||
}}
|
||||
value={currentFilter}
|
||||
className="rounded-r-only"
|
||||
>
|
||||
<option value="debug">
|
||||
{intl.formatMessage(messages.filterDebug)}
|
||||
</option>
|
||||
<option value="info">
|
||||
{intl.formatMessage(messages.filterInfo)}
|
||||
</option>
|
||||
<option value="warn">
|
||||
{intl.formatMessage(messages.filterWarn)}
|
||||
</option>
|
||||
<option value="error">
|
||||
{intl.formatMessage(messages.filterError)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
@@ -300,73 +315,81 @@ const SettingsLogs = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<Table.TBody>
|
||||
{data.results.map((row: LogMessage, index: number) => {
|
||||
return (
|
||||
<tr key={`log-list-${index}`}>
|
||||
<Table.TD className="text-gray-300">
|
||||
{intl.formatDate(row.timestamp, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
})}
|
||||
</Table.TD>
|
||||
<Table.TD className="text-gray-300">
|
||||
<Badge
|
||||
badgeType={
|
||||
row.level === 'error'
|
||||
? 'danger'
|
||||
: row.level === 'warn'
|
||||
? 'warning'
|
||||
: row.level === 'info'
|
||||
? 'success'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{row.level.toUpperCase()}
|
||||
</Badge>
|
||||
</Table.TD>
|
||||
<Table.TD className="text-gray-300">
|
||||
{row.label ?? ''}
|
||||
</Table.TD>
|
||||
<Table.TD className="text-gray-300">{row.message}</Table.TD>
|
||||
<Table.TD className="-m-1 flex flex-wrap items-center justify-end">
|
||||
{row.data && (
|
||||
{!data ? (
|
||||
<tr>
|
||||
<Table.TD colSpan={5} noPadding>
|
||||
<LoadingSpinner />
|
||||
</Table.TD>
|
||||
</tr>
|
||||
) : (
|
||||
data.results.map((row: LogMessage, index: number) => {
|
||||
return (
|
||||
<tr key={`log-list-${index}`}>
|
||||
<Table.TD className="text-gray-300">
|
||||
{intl.formatDate(row.timestamp, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
})}
|
||||
</Table.TD>
|
||||
<Table.TD className="text-gray-300">
|
||||
<Badge
|
||||
badgeType={
|
||||
row.level === 'error'
|
||||
? 'danger'
|
||||
: row.level === 'warn'
|
||||
? 'warning'
|
||||
: row.level === 'info'
|
||||
? 'success'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{row.level.toUpperCase()}
|
||||
</Badge>
|
||||
</Table.TD>
|
||||
<Table.TD className="text-gray-300">
|
||||
{row.label ?? ''}
|
||||
</Table.TD>
|
||||
<Table.TD className="text-gray-300">{row.message}</Table.TD>
|
||||
<Table.TD className="-m-1 flex flex-wrap items-center justify-end">
|
||||
{row.data && (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.viewdetails)}
|
||||
>
|
||||
<Button
|
||||
buttonSize="sm"
|
||||
buttonType="primary"
|
||||
onClick={() =>
|
||||
setActiveLog({ log: row, isOpen: true })
|
||||
}
|
||||
className="m-1"
|
||||
>
|
||||
<DocumentSearchIcon className="icon-md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.viewdetails)}
|
||||
content={intl.formatMessage(messages.copyToClipboard)}
|
||||
>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() =>
|
||||
setActiveLog({ log: row, isOpen: true })
|
||||
}
|
||||
onClick={() => copyLogString(row)}
|
||||
className="m-1"
|
||||
>
|
||||
<DocumentSearchIcon className="icon-md" />
|
||||
<ClipboardCopyIcon className="icon-md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.copyToClipboard)}
|
||||
>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => copyLogString(row)}
|
||||
className="m-1"
|
||||
>
|
||||
<ClipboardCopyIcon className="icon-md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Table.TD>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</Table.TD>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{data.results.length === 0 && (
|
||||
{data?.results.length === 0 && (
|
||||
<tr className="relative h-24 p-2 text-white">
|
||||
<Table.TD colSpan={5} noPadding>
|
||||
<div className="flex w-screen flex-col items-center justify-center p-6 md:w-full">
|
||||
@@ -396,15 +419,15 @@ const SettingsLogs = () => {
|
||||
>
|
||||
<div className="hidden lg:flex lg:flex-1">
|
||||
<p className="text-sm">
|
||||
{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) => (
|
||||
<span className="font-medium">{msg}</span>
|
||||
),
|
||||
|
Reference in New Issue
Block a user