diff --git a/overseerr-api.yml b/overseerr-api.yml index 209b5ac23..56ae19161 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -2024,6 +2024,56 @@ paths: running: type: boolean example: false + /settings/cache: + get: + summary: Get a list of active caches + description: Retrieves a list of all active caches and their current stats. + tags: + - settings + responses: + '200': + description: Caches returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + example: cache-id + name: + type: string + example: cache name + stats: + type: object + properties: + hits: + type: number + misses: + type: number + keys: + type: number + ksize: + type: number + vsize: + type: number + /settings/cache/{cacheId}/flush: + get: + summary: Flush a specific cache + description: Flushes all data from the cache ID provided + tags: + - settings + parameters: + - in: path + name: cacheId + required: true + schema: + type: string + responses: + '204': + description: 'Flushed cache' /settings/notifications: get: summary: Return notification settings diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index bf7163852..be1a629eb 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -148,7 +148,7 @@ class TheMovieDb extends ExternalAPI { append_to_response: 'credits,external_ids,videos', }, }, - 900 + 43200 ); return data; @@ -174,7 +174,7 @@ class TheMovieDb extends ExternalAPI { 'aggregate_credits,credits,external_ids,keywords,videos', }, }, - 900 + 43200 ); return data; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 52be4c4c8..41abaf917 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -11,3 +11,15 @@ export interface PublicSettingsResponse { series4kEnabled: boolean; hideAvailable: boolean; } + +export interface CacheItem { + id: string; + name: string; + stats: { + hits: number; + misses: number; + keys: number; + ksize: number; + vsize: number; + }; +} diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 5624527f4..aaf3bd44b 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -1,45 +1,49 @@ import NodeCache from 'node-cache'; -type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt'; - -interface Cache { - id: AvailableCacheIds; - data: NodeCache; -} +export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; +class Cache { + public id: AvailableCacheIds; + public data: NodeCache; + public name: string; + + constructor( + id: AvailableCacheIds, + name: string, + options: { stdTtl?: number; checkPeriod?: number } = {} + ) { + this.id = id; + this.name = name; + this.data = new NodeCache({ + stdTTL: options.stdTtl ?? DEFAULT_TTL, + checkperiod: options.checkPeriod ?? DEFAULT_CHECK_PERIOD, + }); + } + + public getStats() { + return this.data.getStats(); + } + + public flush(): void { + this.data.flushAll(); + } +} + class CacheManager { private availableCaches: Record = { - tmdb: { - id: 'tmdb', - data: new NodeCache({ - stdTTL: DEFAULT_TTL, - checkperiod: DEFAULT_CHECK_PERIOD, - }), - }, - radarr: { - id: 'radarr', - data: new NodeCache({ - stdTTL: DEFAULT_TTL, - checkperiod: DEFAULT_CHECK_PERIOD, - }), - }, - sonarr: { - id: 'sonarr', - data: new NodeCache({ - stdTTL: DEFAULT_TTL, - checkperiod: DEFAULT_CHECK_PERIOD, - }), - }, - rt: { - id: 'rt', - data: new NodeCache({ - stdTTL: 21600, // 12 hours TTL - checkperiod: 60 * 30, // 30 minutes check period - }), - }, + tmdb: new Cache('tmdb', 'TMDb API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), + radarr: new Cache('radarr', 'Radarr API'), + sonarr: new Cache('sonarr', 'Sonarr API'), + rt: new Cache('rt', 'Rotten Tomatoes API', { + stdTtl: 43200, + checkPeriod: 60 * 30, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 1d87c12e4..61dabe217 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -16,6 +16,7 @@ import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces'; import notificationRoutes from './notifications'; import sonarrRoutes from './sonarr'; import radarrRoutes from './radarr'; +import cacheManager, { AvailableCacheIds } from '../../lib/cache'; const settingsRoutes = Router(); @@ -273,6 +274,32 @@ settingsRoutes.get<{ jobId: string }>( } ); +settingsRoutes.get('/cache', (req, res) => { + const caches = cacheManager.getAllCaches(); + + return res.status(200).json( + Object.values(caches).map((cache) => ({ + id: cache.id, + name: cache.name, + stats: cache.getStats(), + })) + ); +}); + +settingsRoutes.get<{ cacheId: AvailableCacheIds }>( + '/cache/:cacheId/flush', + (req, res, next) => { + const cache = cacheManager.getCache(req.params.cacheId); + + if (cache) { + cache.flush(); + return res.status(204).send(); + } + + next({ status: 404, message: 'Cache does not exist.' }); + } +); + settingsRoutes.get( '/initialize', isAuthenticated(Permission.ADMIN), diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index 70f59eb96..0d5a4deac 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -7,18 +7,7 @@ import type { ServiceCommonServerWithDetails, } from '../../../../server/interfaces/api/serviceInterfaces'; import { defineMessages, useIntl } from 'react-intl'; - -const formatBytes = (bytes: number, decimals = 2) => { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; -}; +import { formatBytes } from '../../../utils/numberHelpers'; const messages = defineMessages({ advancedoptions: 'Advanced Options', diff --git a/src/components/Settings/SettingsJobs.tsx b/src/components/Settings/SettingsJobs.tsx deleted file mode 100644 index bf891fc51..000000000 --- a/src/components/Settings/SettingsJobs.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React from 'react'; -import useSWR from 'swr'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import { FormattedRelativeTime, defineMessages, useIntl } from 'react-intl'; -import Button from '../Common/Button'; -import Table from '../Common/Table'; -import Spinner from '../../assets/spinner.svg'; -import axios from 'axios'; -import { useToasts } from 'react-toast-notifications'; -import Badge from '../Common/Badge'; - -const messages = defineMessages({ - jobname: 'Job Name', - jobtype: 'Type', - nextexecution: 'Next Execution', - runnow: 'Run Now', - canceljob: 'Cancel Job', - jobstarted: '{jobname} started.', - jobcancelled: '{jobname} cancelled.', -}); - -interface Job { - id: string; - name: string; - type: 'process' | 'command'; - nextExecutionTime: string; - running: boolean; -} - -const SettingsJobs: React.FC = () => { - const intl = useIntl(); - const { addToast } = useToasts(); - const { data, error, revalidate } = useSWR('/api/v1/settings/jobs', { - refreshInterval: 5000, - }); - - if (!data && !error) { - return ; - } - - const runJob = async (job: Job) => { - await axios.get(`/api/v1/settings/jobs/${job.id}/run`); - addToast( - intl.formatMessage(messages.jobstarted, { - jobname: job.name, - }), - { - appearance: 'success', - autoDismiss: true, - } - ); - revalidate(); - }; - - const cancelJob = async (job: Job) => { - await axios.get(`/api/v1/settings/jobs/${job.id}/cancel`); - addToast(intl.formatMessage(messages.jobcancelled, { jobname: job.name }), { - appearance: 'error', - autoDismiss: true, - }); - revalidate(); - }; - - return ( - - - {intl.formatMessage(messages.jobname)} - {intl.formatMessage(messages.jobtype)} - {intl.formatMessage(messages.nextexecution)} - - - - {data?.map((job) => ( - - -
- {job.running && } - {job.name} -
-
- - - {job.type} - - - -
- -
-
- - {job.running ? ( - - ) : ( - - )} - - - ))} - -
- ); -}; - -export default SettingsJobs; diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx new file mode 100644 index 000000000..72493b2b5 --- /dev/null +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import useSWR from 'swr'; +import LoadingSpinner from '../../Common/LoadingSpinner'; +import { FormattedRelativeTime, defineMessages, useIntl } from 'react-intl'; +import Button from '../../Common/Button'; +import Table from '../../Common/Table'; +import Spinner from '../../../assets/spinner.svg'; +import axios from 'axios'; +import { useToasts } from 'react-toast-notifications'; +import Badge from '../../Common/Badge'; +import { CacheItem } from '../../../../server/interfaces/api/settingsInterfaces'; +import { formatBytes } from '../../../utils/numberHelpers'; + +const messages = defineMessages({ + jobs: 'Jobs', + jobsDescription: + 'Overseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered below. Manually running a job will not alter its schedule.', + jobname: 'Job Name', + jobtype: 'Type', + nextexecution: 'Next Execution', + runnow: 'Run Now', + canceljob: 'Cancel Job', + jobstarted: '{jobname} started.', + jobcancelled: '{jobname} cancelled.', + cache: 'Cache', + cacheDescription: + 'Overseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.', + cacheflushed: '{cachename} cache flushed.', + cachename: 'Cache Name', + cachehits: 'Hits', + cachemisses: 'Misses', + cachekeys: 'Total Keys', + cacheksize: 'Key Size', + cachevsize: 'Value Size', + flushcache: 'Flush Cache', +}); + +interface Job { + id: string; + name: string; + type: 'process' | 'command'; + nextExecutionTime: string; + running: boolean; +} + +const SettingsJobs: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const { data, error, revalidate } = useSWR('/api/v1/settings/jobs', { + refreshInterval: 5000, + }); + const { data: cacheData, revalidate: cacheRevalidate } = useSWR( + '/api/v1/settings/cache', + { + refreshInterval: 10000, + } + ); + + if (!data && !error) { + return ; + } + + const runJob = async (job: Job) => { + await axios.get(`/api/v1/settings/jobs/${job.id}/run`); + addToast( + intl.formatMessage(messages.jobstarted, { + jobname: job.name, + }), + { + appearance: 'success', + autoDismiss: true, + } + ); + revalidate(); + }; + + const cancelJob = async (job: Job) => { + await axios.get(`/api/v1/settings/jobs/${job.id}/cancel`); + addToast(intl.formatMessage(messages.jobcancelled, { jobname: job.name }), { + appearance: 'error', + autoDismiss: true, + }); + revalidate(); + }; + + const flushCache = async (cache: CacheItem) => { + await axios.get(`/api/v1/settings/cache/${cache.id}/flush`); + addToast( + intl.formatMessage(messages.cacheflushed, { cachename: cache.name }), + { + appearance: 'success', + autoDismiss: true, + } + ); + cacheRevalidate(); + }; + + return ( + <> +
+

+ {intl.formatMessage(messages.jobs)} +

+

+ {intl.formatMessage(messages.jobsDescription)} +

+
+ + + {intl.formatMessage(messages.jobname)} + {intl.formatMessage(messages.jobtype)} + {intl.formatMessage(messages.nextexecution)} + + + + {data?.map((job) => ( + + +
+ {job.running && } + {job.name} +
+
+ + + {job.type} + + + +
+ +
+
+ + {job.running ? ( + + ) : ( + + )} + + + ))} + +
+
+

+ {intl.formatMessage(messages.cache)} +

+

+ {intl.formatMessage(messages.cacheDescription)} +

+
+ + + {intl.formatMessage(messages.cachename)} + {intl.formatMessage(messages.cachehits)} + {intl.formatMessage(messages.cachemisses)} + {intl.formatMessage(messages.cachekeys)} + {intl.formatMessage(messages.cacheksize)} + {intl.formatMessage(messages.cachevsize)} + + + + {cacheData?.map((cache) => ( + + {cache.name} + {cache.stats.hits} + {cache.stats.misses} + {cache.stats.keys} + {formatBytes(cache.stats.ksize)} + {formatBytes(cache.stats.vsize)} + + + + + ))} + +
+ + ); +}; + +export default SettingsJobs; diff --git a/src/components/Settings/SettingsLayout.tsx b/src/components/Settings/SettingsLayout.tsx index 13bc18015..8ce65432c 100644 --- a/src/components/Settings/SettingsLayout.tsx +++ b/src/components/Settings/SettingsLayout.tsx @@ -9,7 +9,7 @@ const messages = defineMessages({ menuServices: 'Services', menuNotifications: 'Notifications', menuLogs: 'Logs', - menuJobs: 'Jobs', + menuJobs: 'Jobs & Cache', menuAbout: 'About', }); @@ -106,7 +106,7 @@ const SettingsLayout: React.FC = ({ children }) => { )?.route } aria-label="Selected tab" - className="bg-gray-800 text-white mt-1 rounded-md form-select block w-full pl-3 pr-10 py-2 text-base leading-6 border-gray-700 focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5 transition ease-in-out duration-150" + className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5" > {settingsRoutes.map((route, index) => ( {
-