diff --git a/overseerr-api.yml b/overseerr-api.yml index e436aa628..5d5bc035d 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -2252,6 +2252,54 @@ paths: responses: '204': description: 'Flushed cache' + /settings/logs: + get: + summary: Returns logs + description: Returns list of all log items and details + tags: + - settings + parameters: + - in: query + name: take + schema: + type: number + nullable: true + example: 25 + - in: query + name: skip + schema: + type: number + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: [debug, info, warn, error] + default: debug + responses: + '200': + description: Server log returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + label: + type: string + example: server + level: + type: string + example: info + message: + type: string + example: Server ready on port 5055 + timestamp: + type: string + example: 2020-12-15T16:20:00.069Z /settings/notifications: get: summary: Return notification settings diff --git a/package.json b/package.json index 0adf6116b..b840aa0bf 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "email-templates": "^8.0.3", "express": "^4.17.1", "express-openapi-validator": "^4.12.5", + "express-rate-limit": "^5.2.6", "express-session": "^1.17.1", "formik": "^2.2.6", "gravatar-url": "^3.1.0", @@ -89,6 +90,7 @@ "@types/csurf": "^1.11.0", "@types/email-templates": "^8.0.2", "@types/express": "^4.17.11", + "@types/express-rate-limit": "^5.1.1", "@types/express-session": "^1.17.3", "@types/lodash": "^4.14.168", "@types/node": "^14.14.35", diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index b687d5977..67925bb32 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -1,3 +1,16 @@ +import type { PaginatedResponse } from './common'; + +export type LogMessage = { + timestamp: string; + level: string; + label: string; + message: string; +}; + +export interface LogsResultsResponse extends PaginatedResponse { + results: LogMessage[]; +} + export interface SettingsAboutResponse { version: string; totalRequests: number; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index a7dbd3c13..993329edb 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,22 +1,30 @@ import { Router } from 'express'; -import { getSettings, Library, MainSettings } from '../../lib/settings'; +import rateLimit from 'express-rate-limit'; +import fs from 'fs'; +import { merge, omit } from 'lodash'; +import path from 'path'; import { getRepository } from 'typeorm'; -import { User } from '../../entity/User'; import PlexAPI from '../../api/plexapi'; import PlexTvAPI from '../../api/plextv'; +import Media from '../../entity/Media'; +import { MediaRequest } from '../../entity/MediaRequest'; +import { User } from '../../entity/User'; +import { + LogMessage, + LogsResultsResponse, + SettingsAboutResponse, +} from '../../interfaces/api/settingsInterfaces'; import { scheduledJobs } from '../../job/schedule'; +import cacheManager, { AvailableCacheIds } from '../../lib/cache'; import { Permission } from '../../lib/permissions'; +import { plexFullScanner } from '../../lib/scanners/plex'; +import { getSettings, Library, MainSettings } from '../../lib/settings'; +import logger from '../../logger'; import { isAuthenticated } from '../../middleware/auth'; -import { merge, omit } from 'lodash'; -import Media from '../../entity/Media'; -import { MediaRequest } from '../../entity/MediaRequest'; import { getAppVersion } from '../../utils/appVersion'; -import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces'; import notificationRoutes from './notifications'; -import sonarrRoutes from './sonarr'; import radarrRoutes from './radarr'; -import cacheManager, { AvailableCacheIds } from '../../lib/cache'; -import { plexFullScanner } from '../../lib/scanners/plex'; +import sonarrRoutes from './sonarr'; const settingsRoutes = Router(); @@ -223,6 +231,82 @@ settingsRoutes.post('/plex/sync', (req, res) => { return res.status(200).json(plexFullScanner.status()); }); +settingsRoutes.get( + '/logs', + rateLimit({ windowMs: 60 * 1000, max: 50 }), + (req, res, next) => { + const pageSize = req.query.take ? Number(req.query.take) : 25; + const skip = req.query.skip ? Number(req.query.skip) : 0; + + let filter: string[] = []; + switch (req.query.filter) { + case 'debug': + filter.push('debug'); + // falls through + case 'info': + filter.push('info'); + // falls through + case 'warn': + filter.push('warn'); + // falls through + case 'error': + filter.push('error'); + break; + default: + filter = ['debug', 'info', 'warn', 'error']; + } + + const logFile = process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/logs/overseerr.log` + : path.join(__dirname, '../../../config/logs/overseerr.log'); + const logs: LogMessage[] = []; + + try { + fs.readFileSync(logFile) + .toString() + .split('\n') + .forEach((line) => { + if (!line.length) return; + + const timestamp = line.match(new RegExp(/^.{24}/)) || []; + const level = line.match(new RegExp(/\s\[\w+\]/)) || []; + const label = line.match(new RegExp(/[^\s]\[\w+\s*\w*\]/)) || []; + const message = line.match(new RegExp(/:\s.*/)) || []; + + if (level.length && filter.includes(level[0].slice(2, -1))) { + logs.push({ + timestamp: timestamp[0], + level: level.length ? level[0].slice(2, -1) : '', + label: label.length ? label[0].slice(2, -1) : '', + message: message.length ? message[0].slice(2) : '', + }); + } + }); + + const displayedLogs = logs.reverse().slice(skip, skip + pageSize); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(logs.length / pageSize), + pageSize, + results: logs.length, + page: Math.ceil(skip / pageSize) + 1, + }, + results: displayedLogs, + } as LogsResultsResponse); + } catch (error) { + logger.error('Something went wrong while fetching the logs', { + label: 'Logs', + errorMessage: error.message, + }); + return next({ + status: 500, + message: 'Something went wrong while fetching the logs', + }); + } + } +); + settingsRoutes.get('/jobs', (_req, res) => { return res.status(200).json( scheduledJobs.map((job) => ({ diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index 7d1aceff4..bba70847d 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -59,7 +59,7 @@ const RequestList: React.FC = () => { } }, []); - // Set fitler values to local storage any time they are changed + // Set filter values to local storage any time they are changed useEffect(() => { window.localStorage.setItem( 'rl-filter-settings', diff --git a/src/components/Settings/SettingsLogs/index.tsx b/src/components/Settings/SettingsLogs/index.tsx index bac8cd3bb..8e0212f7d 100644 --- a/src/components/Settings/SettingsLogs/index.tsx +++ b/src/components/Settings/SettingsLogs/index.tsx @@ -1,14 +1,292 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import useSWR from 'swr'; +import { + LogMessage, + LogsResultsResponse, +} from '../../../../server/interfaces/api/settingsInterfaces'; +import Error from '../../../pages/_error'; +import Badge from '../../Common/Badge'; +import Button from '../../Common/Button'; +import LoadingSpinner from '../../Common/LoadingSpinner'; +import Table from '../../Common/Table'; -// We will localize this file when the complete version is released. +const messages = defineMessages({ + logs: 'Logs', + logsDescription: + 'You can also view these logs directly via stdout, or in {configDir}/logs/overseerr.log.', + time: 'Timestamp', + level: 'Severity', + label: 'Label', + message: 'Message', + filterDebug: 'Debug', + filterInfo: 'Info', + filterWarn: 'Warning', + filterError: 'Error', + noresults: 'No results.', + showall: 'Show All Logs', + showingresults: + 'Showing {from} to {to} of {total} results', + resultsperpage: 'Display {pageSize} results per page', + next: 'Next', + previous: 'Previous', + pauseLogs: 'Pause', + resumeLogs: 'Resume', +}); + +type Filter = 'debug' | 'info' | 'warn' | 'error'; const SettingsLogs: React.FC = () => { + const intl = useIntl(); + const [pageIndex, setPageIndex] = useState(0); + const [currentFilter, setCurrentFilter] = useState('debug'); + const [currentPageSize, setCurrentPageSize] = useState(25); + const [refreshInterval, setRefreshInterval] = useState(5000); + + const toggleLogs = () => { + setRefreshInterval(refreshInterval === 5000 ? 0 : 5000); + }; + + const { data, error } = useSWR( + `/api/v1/settings/logs?take=${currentPageSize}&skip=${ + pageIndex * currentPageSize + }&filter=${currentFilter}`, + { + refreshInterval: refreshInterval, + revalidateOnFocus: false, + } + ); + + const { data: appData } = useSWR('/api/v1/status/appdata'); + + useEffect(() => { + const displayString = window.localStorage.getItem('logs-display-settings'); + + if (displayString) { + const displaySettings = JSON.parse(displayString); + + setCurrentFilter(displaySettings.currentFilter); + setCurrentPageSize(displaySettings.currentPageSize); + } + }, []); + + useEffect(() => { + window.localStorage.setItem( + 'logs-display-settings', + JSON.stringify({ + currentFilter, + currentPageSize, + }) + ); + }, [currentFilter, currentPageSize]); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + const hasNextPage = data.pageInfo.pages > pageIndex + 1; + const hasPrevPage = pageIndex > 0; + return ( <> -
- This page is still being built. For now, you can access your logs - directly in stdout (container logs) or looking in{' '} - /app/config/logs/overseerr.log. +
+

{intl.formatMessage(messages.logs)}

+

+ {intl.formatMessage(messages.logsDescription, { + code: function code(msg) { + return {msg}; + }, + configDir: appData ? appData.appDataPath : '/app/config', + })} +

+
+
+ +
+
+ + + + + + +
+
+ + + + {intl.formatMessage(messages.time)} + {intl.formatMessage(messages.level)} + {intl.formatMessage(messages.label)} + {intl.formatMessage(messages.message)} + + + + {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', + hour12: false, + })} + + + + {row.level.toUpperCase()} + + + {row.label} + {row.message} + + ); + })} + + {data.results.length === 0 && ( + + +
+ + {intl.formatMessage(messages.noresults)} + + {currentFilter !== 'debug' && ( +
+ +
+ )} +
+
+ + )} + + + + + + +
); diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 4a51a2040..335c5b77c 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -453,6 +453,24 @@ "components.Settings.SettingsJobsCache.runnow": "Run Now", "components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan", "components.Settings.SettingsJobsCache.unknownJob": "Unknown Job", + "components.Settings.SettingsLogs.filterDebug": "Debug", + "components.Settings.SettingsLogs.filterError": "Error", + "components.Settings.SettingsLogs.filterInfo": "Info", + "components.Settings.SettingsLogs.filterWarn": "Warning", + "components.Settings.SettingsLogs.label": "Label", + "components.Settings.SettingsLogs.level": "Severity", + "components.Settings.SettingsLogs.logs": "Logs", + "components.Settings.SettingsLogs.logsDescription": "You can also view these logs directly via stdout, or in {configDir}/logs/overseerr.log.", + "components.Settings.SettingsLogs.message": "Message", + "components.Settings.SettingsLogs.next": "Next", + "components.Settings.SettingsLogs.noresults": "No results.", + "components.Settings.SettingsLogs.pauseLogs": "Pause", + "components.Settings.SettingsLogs.previous": "Previous", + "components.Settings.SettingsLogs.resultsperpage": "Display {pageSize} results per page", + "components.Settings.SettingsLogs.resumeLogs": "Resume", + "components.Settings.SettingsLogs.showall": "Show All Logs", + "components.Settings.SettingsLogs.showingresults": "Showing {from} to {to} of {total} results", + "components.Settings.SettingsLogs.time": "Timestamp", "components.Settings.SettingsUsers.defaultPermissions": "Default User Permissions", "components.Settings.SettingsUsers.localLogin": "Enable Local User Sign-In", "components.Settings.SettingsUsers.save": "Save Changes", diff --git a/yarn.lock b/yarn.lock index 8e40f1987..4a08e6718 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2097,6 +2097,13 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.45.tgz#e9387572998e5ecdac221950dab3e8c3b16af884" integrity sha512-jnqIUKDUqJbDIUxm0Uj7bnlMnRm1T/eZ9N+AVMqhPgzrba2GhGG5o/jCTwmdPK709nEZsGoMzXEDUjcXHa3W0g== +"@types/express-rate-limit@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@types/express-rate-limit/-/express-rate-limit-5.1.1.tgz#e5b0239d18c1580e52ae56dce4248333302a1dc8" + integrity sha512-6oMYZBLlhxC5sdcRXXz528QyfGz3zTy9YdHwqlxLfgx5Cd3zwYaUjjPpJcaTtHmRefLi9P8kLBPz2wB7yz4JtQ== + dependencies: + "@types/express" "*" + "@types/express-serve-static-core@*": version "4.17.9" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.9.tgz#2d7b34dcfd25ec663c25c85d76608f8b249667f1" @@ -5714,6 +5721,11 @@ express-openapi-validator@^4.12.5: ono "^7.1.3" path-to-regexp "^6.2.0" +express-rate-limit@^5.2.6: + version "5.2.6" + resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-5.2.6.tgz#b454e1be8a252081bda58460e0a25bf43ee0f7b0" + integrity sha512-nE96xaxGfxiS5jP3tD3kIW1Jg9yQgX0rXCs3rCkZtmbWHEGyotwaezkLj7bnB41Z0uaOLM8W4AX6qHao4IZ2YA== + express-session@^1.15.6, express-session@^1.17.1: version "1.17.1" resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.1.tgz#36ecbc7034566d38c8509885c044d461c11bf357"