You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
493 lines
19 KiB
493 lines
19 KiB
import Badge from '@app/components/Common/Badge';
|
|
import Button from '@app/components/Common/Button';
|
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|
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';
|
|
import { Transition } from '@headlessui/react';
|
|
import {
|
|
ChevronLeftIcon,
|
|
ChevronRightIcon,
|
|
ClipboardCopyIcon,
|
|
DocumentSearchIcon,
|
|
FilterIcon,
|
|
PauseIcon,
|
|
PlayIcon,
|
|
SearchIcon,
|
|
} from '@heroicons/react/solid';
|
|
import type {
|
|
LogMessage,
|
|
LogsResultsResponse,
|
|
} from '@server/interfaces/api/settingsInterfaces';
|
|
import copy from 'copy-to-clipboard';
|
|
import { useRouter } from 'next/router';
|
|
import { Fragment, useEffect, useState } from 'react';
|
|
import { defineMessages, useIntl } from 'react-intl';
|
|
import { useToasts } from 'react-toast-notifications';
|
|
import useSWR from 'swr';
|
|
|
|
const messages = defineMessages({
|
|
logs: 'Logs',
|
|
logsDescription:
|
|
'You can also view these logs directly via <code>stdout</code>, or in <code>{appDataPath}/logs/overseerr.log</code>.',
|
|
time: 'Timestamp',
|
|
level: 'Severity',
|
|
label: 'Label',
|
|
message: 'Message',
|
|
filterDebug: 'Debug',
|
|
filterInfo: 'Info',
|
|
filterWarn: 'Warning',
|
|
filterError: 'Error',
|
|
showall: 'Show All Logs',
|
|
pauseLogs: 'Pause',
|
|
resumeLogs: 'Resume',
|
|
copyToClipboard: 'Copy to Clipboard',
|
|
logDetails: 'Log Details',
|
|
extraData: 'Additional Data',
|
|
copiedLogMessage: 'Copied log message to clipboard.',
|
|
viewdetails: 'View Details',
|
|
});
|
|
|
|
type Filter = 'debug' | 'info' | 'warn' | 'error';
|
|
|
|
const SettingsLogs = () => {
|
|
const router = useRouter();
|
|
const intl = useIntl();
|
|
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;
|
|
log?: LogMessage;
|
|
}>({ isOpen: false });
|
|
|
|
const page = router.query.page ? Number(router.query.page) : 1;
|
|
const pageIndex = page - 1;
|
|
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
|
|
|
|
const toggleLogs = () => {
|
|
setRefreshInterval(refreshInterval === 5000 ? 0 : 5000);
|
|
};
|
|
|
|
const { data, error } = useSWR<LogsResultsResponse>(
|
|
`/api/v1/settings/logs?take=${currentPageSize}&skip=${
|
|
pageIndex * currentPageSize
|
|
}&filter=${currentFilter}${
|
|
debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''
|
|
}`,
|
|
{
|
|
refreshInterval: refreshInterval,
|
|
revalidateOnFocus: false,
|
|
}
|
|
);
|
|
|
|
const { data: appData } = useSWR('/api/v1/status/appdata');
|
|
|
|
useEffect(() => {
|
|
const filterString = window.localStorage.getItem('logs-display-settings');
|
|
|
|
if (filterString) {
|
|
const filterSettings = JSON.parse(filterString);
|
|
|
|
setCurrentFilter(filterSettings.currentFilter);
|
|
setCurrentPageSize(filterSettings.currentPageSize);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
window.localStorage.setItem(
|
|
'logs-display-settings',
|
|
JSON.stringify({
|
|
currentFilter,
|
|
currentPageSize,
|
|
})
|
|
);
|
|
}, [currentFilter, currentPageSize]);
|
|
|
|
const copyLogString = (log: LogMessage): void => {
|
|
copy(
|
|
`${log.timestamp} [${log.level}]${log.label ? `[${log.label}]` : ''}: ${
|
|
log.message
|
|
}${log.data ? `${JSON.stringify(log.data)}` : ''}`
|
|
);
|
|
addToast(intl.formatMessage(messages.copiedLogMessage), {
|
|
appearance: 'success',
|
|
autoDismiss: true,
|
|
});
|
|
};
|
|
|
|
// 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 ?? 0 > pageIndex + 1;
|
|
const hasPrevPage = pageIndex > 0;
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
title={[
|
|
intl.formatMessage(messages.logs),
|
|
intl.formatMessage(globalMessages.settings),
|
|
]}
|
|
/>
|
|
<Transition
|
|
as={Fragment}
|
|
enter="opacity-0 transition duration-300"
|
|
enterFrom="opacity-0"
|
|
enterTo="opacity-100"
|
|
leave="opacity-100 transition duration-300"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
appear
|
|
show={activeLog.isOpen}
|
|
>
|
|
<Modal
|
|
title={intl.formatMessage(messages.logDetails)}
|
|
onCancel={() => setActiveLog({ log: activeLog.log, isOpen: false })}
|
|
cancelText={intl.formatMessage(globalMessages.close)}
|
|
onOk={() =>
|
|
activeLog.log ? copyLogString(activeLog.log) : undefined
|
|
}
|
|
okText={intl.formatMessage(messages.copyToClipboard)}
|
|
okButtonType="primary"
|
|
>
|
|
{activeLog && (
|
|
<>
|
|
<div className="form-row">
|
|
<div className="text-label">
|
|
{intl.formatMessage(messages.time)}
|
|
</div>
|
|
<div className="mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
|
|
<div className="flex max-w-lg items-center">
|
|
{intl.formatDate(activeLog.log?.timestamp, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: '2-digit',
|
|
hour: 'numeric',
|
|
minute: 'numeric',
|
|
second: 'numeric',
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="form-row">
|
|
<div className="text-label">
|
|
{intl.formatMessage(messages.level)}
|
|
</div>
|
|
<div className="mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
|
|
<div className="flex max-w-lg items-center">
|
|
<Badge
|
|
badgeType={
|
|
activeLog.log?.level === 'error'
|
|
? 'danger'
|
|
: activeLog.log?.level === 'warn'
|
|
? 'warning'
|
|
: activeLog.log?.level === 'info'
|
|
? 'success'
|
|
: 'default'
|
|
}
|
|
>
|
|
{activeLog.log?.level.toUpperCase()}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="form-row">
|
|
<div className="text-label">
|
|
{intl.formatMessage(messages.label)}
|
|
</div>
|
|
<div className="mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
|
|
<div className="flex max-w-lg items-center">
|
|
{activeLog.log?.label}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="form-row">
|
|
<div className="text-label">
|
|
{intl.formatMessage(messages.message)}
|
|
</div>
|
|
<div className="col-span-2 mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
|
|
<div className="flex max-w-lg items-center">
|
|
{activeLog.log?.message}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{activeLog.log?.data && (
|
|
<div className="form-row">
|
|
<div className="text-label">
|
|
{intl.formatMessage(messages.extraData)}
|
|
</div>
|
|
<div className="col-span-2 mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
|
|
<code className="block max-h-64 w-full overflow-auto whitespace-pre bg-gray-800 px-6 py-4 ring-1 ring-gray-700">
|
|
{JSON.stringify(activeLog.log?.data, null, ' ')}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</Modal>
|
|
</Transition>
|
|
<div className="mb-2">
|
|
<h3 className="heading">{intl.formatMessage(messages.logs)}</h3>
|
|
<p className="description">
|
|
{intl.formatMessage(messages.logsDescription, {
|
|
code: (msg: React.ReactNode) => (
|
|
<code className="bg-opacity-50">{msg}</code>
|
|
),
|
|
appDataPath: appData ? appData.appDataPath : '/app/config',
|
|
})}
|
|
</p>
|
|
<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 flex flex-grow"
|
|
buttonType={refreshInterval ? 'default' : 'primary'}
|
|
onClick={() => toggleLogs()}
|
|
>
|
|
{refreshInterval ? <PauseIcon /> : <PlayIcon />}
|
|
<span>
|
|
{intl.formatMessage(
|
|
refreshInterval ? messages.pauseLogs : messages.resumeLogs
|
|
)}
|
|
</span>
|
|
</Button>
|
|
<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>
|
|
<thead>
|
|
<tr>
|
|
<Table.TH>{intl.formatMessage(messages.time)}</Table.TH>
|
|
<Table.TH>{intl.formatMessage(messages.level)}</Table.TH>
|
|
<Table.TH>{intl.formatMessage(messages.label)}</Table.TH>
|
|
<Table.TH>{intl.formatMessage(messages.message)}</Table.TH>
|
|
<Table.TH></Table.TH>
|
|
</tr>
|
|
</thead>
|
|
<Table.TBody>
|
|
{!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.copyToClipboard)}
|
|
>
|
|
<Button
|
|
buttonType="primary"
|
|
buttonSize="sm"
|
|
onClick={() => copyLogString(row)}
|
|
className="m-1"
|
|
>
|
|
<ClipboardCopyIcon className="icon-md" />
|
|
</Button>
|
|
</Tooltip>
|
|
</Table.TD>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
|
|
{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">
|
|
<span className="text-base">
|
|
{intl.formatMessage(globalMessages.noresults)}
|
|
</span>
|
|
{currentFilter !== 'debug' && (
|
|
<div className="mt-4">
|
|
<Button
|
|
buttonSize="sm"
|
|
buttonType="primary"
|
|
onClick={() => setCurrentFilter('debug')}
|
|
>
|
|
{intl.formatMessage(messages.showall)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Table.TD>
|
|
</tr>
|
|
)}
|
|
<tr className="bg-gray-700">
|
|
<Table.TD colSpan={5} noPadding>
|
|
<nav
|
|
className="flex w-screen flex-col items-center space-x-4 space-y-3 px-6 py-3 sm:flex-row sm:space-y-0 md:w-full"
|
|
aria-label="Pagination"
|
|
>
|
|
<div className="hidden lg:flex lg:flex-1">
|
|
<p className="text-sm">
|
|
{(data?.results.length ?? 0) > 0 &&
|
|
intl.formatMessage(globalMessages.showingresults, {
|
|
from: pageIndex * currentPageSize + 1,
|
|
to:
|
|
data?.results.length ?? 0 < currentPageSize
|
|
? pageIndex * currentPageSize +
|
|
(data?.results.length ?? 0)
|
|
: (pageIndex + 1) * currentPageSize,
|
|
total: data?.pageInfo.results ?? 0,
|
|
strong: (msg: React.ReactNode) => (
|
|
<span className="font-medium">{msg}</span>
|
|
),
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-center sm:flex-1 sm:justify-start md:justify-center">
|
|
<span className="-mt-3 items-center text-sm sm:-ml-4 sm:mt-0 md:ml-0">
|
|
{intl.formatMessage(globalMessages.resultsperpage, {
|
|
pageSize: (
|
|
<select
|
|
id="pageSize"
|
|
name="pageSize"
|
|
onChange={(e) => {
|
|
setCurrentPageSize(Number(e.target.value));
|
|
router
|
|
.push(router.pathname)
|
|
.then(() => window.scrollTo(0, 0));
|
|
}}
|
|
value={currentPageSize}
|
|
className="short inline"
|
|
>
|
|
<option value="10">10</option>
|
|
<option value="25">25</option>
|
|
<option value="50">50</option>
|
|
<option value="100">100</option>
|
|
</select>
|
|
),
|
|
})}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-auto justify-center space-x-2 sm:flex-1 sm:justify-end">
|
|
<Button
|
|
disabled={!hasPrevPage}
|
|
onClick={() =>
|
|
updateQueryParams('page', (page - 1).toString())
|
|
}
|
|
>
|
|
<ChevronLeftIcon />
|
|
<span>{intl.formatMessage(globalMessages.previous)}</span>
|
|
</Button>
|
|
<Button
|
|
disabled={!hasNextPage}
|
|
onClick={() =>
|
|
updateQueryParams('page', (page + 1).toString())
|
|
}
|
|
>
|
|
<span>{intl.formatMessage(globalMessages.next)}</span>
|
|
<ChevronRightIcon />
|
|
</Button>
|
|
</div>
|
|
</nav>
|
|
</Table.TD>
|
|
</tr>
|
|
</Table.TBody>
|
|
</Table>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default SettingsLogs;
|