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
pull/3018/head
Danshil Kokil Mungur 2 years ago committed by GitHub
parent 87825a0e05
commit 30141f76e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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>
),

Loading…
Cancel
Save