Compare commits

..

71 Commits

Author SHA1 Message Date
Bogdan
97d1384726 Guard against using invalid sort keys 2024-09-21 21:35:23 +03:00
Bogdan
ba002a7a4a Add packages needed for RemoveDiacritics 2024-09-21 21:30:34 +03:00
momo
349efab7a8 Fix description for API key as query parameter
(cherry picked from commit 30c36fdc3baa686102ff124833c7963fc786f251)
2024-09-21 21:21:26 +03:00
Mark McDowall
af9a6f42db Fixed: Unable to login when instance name contained brackets 2024-09-21 00:27:15 +03:00
Mark McDowall
6b20fa8abd New: Use instance name in forms authentication cookie name
Closes #2224
2024-09-16 16:47:22 +03:00
Bogdan
029ad3903f Bump version to 1.24.2 2024-09-15 15:56:33 +03:00
Weblate
a23d66930b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: FloatStream <1213193613@qq.com>
Co-authored-by: Kuzmich55 <kuzmich55@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: shixiaotongy <shixiaotong2280@sina.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_CN/
Translation: Servarr/Prowlarr
2024-09-14 17:14:52 +03:00
Bogdan
710ab7ae09 New: (Gazelle/OPS/RED) Prevent downloads without FL tokens 2024-09-08 15:19:25 +03:00
jaype87
434b07ae64 New: Sync seeding limits for LazyLibrarian (#2215)
* add support for seeders, seed_ratio and seed_duration for LazyLibrarian
2024-09-08 11:34:26 +03:00
Bogdan
eee8c95ca6 Fix weblate widget 2024-09-08 11:24:36 +03:00
Bogdan
1f5c514011 Bump version to 1.24.1 2024-09-08 11:10:36 +03:00
Weblate
66d722e097 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Dream <seth.gecko.rr@gmail.com>
Co-authored-by: FloatStream <1213193613@qq.com>
Co-authored-by: Gabriel Markowski <gmarkowski62@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Jason54 <jason54700.jg@gmail.com>
Co-authored-by: Kerk en IT <info@kerkenit.nl>
Co-authored-by: MattiaPell <mattiapellegrini16@gmail.com>
Co-authored-by: Nota Inutilis <hugo@notainutilis.fr>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_CN/
Translation: Servarr/Prowlarr
2024-09-07 13:43:28 -05:00
Bogdan
39befe5aa4 Use error message from Nebulance response
Fixes #2212
2024-09-06 10:41:26 +03:00
Bogdan
ab043e87dc Display grabs, failures and queries stats with values 2024-09-04 16:28:06 +03:00
Bogdan
58ae9c0a13 Fixed: (MyAnonamouse) Avoid using FL wedges for freeleech torrents 2024-09-02 10:37:11 +03:00
Bogdan
44c446943c Fixed: (Gazelle) Allow freeleech torrents with Use Freeleech Tokens 2024-09-02 10:31:56 +03:00
Weblate
8301b669fe Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Dream <seth.gecko.rr@gmail.com>
Co-authored-by: Gabriel Markowski <gmarkowski62@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Jason54 <jason54700.jg@gmail.com>
Co-authored-by: Kerk en IT <info@kerkenit.nl>
Co-authored-by: MattiaPell <mattiapellegrini16@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/tr/
Translation: Servarr/Prowlarr
2024-09-01 21:21:40 -05:00
Bogdan
6fa0b79c67 Bump version to 1.24.0 2024-09-01 06:43:21 +03:00
Bogdan
32d23d6636 Simplify cookie clearing for MAM 2024-08-28 03:28:49 +03:00
Bogdan
b31b695887 Fixed: Mapping of Cardigann indexers on bulk edit 2024-08-27 07:15:49 +03:00
Bogdan
33de32b138 Simplify app profile validation on indexers 2024-08-27 06:51:45 +03:00
Bogdan
753b53a529 Use UTC for filtering out AB releases 2024-08-27 06:09:35 +03:00
Bogdan
123535b9a5 Fixed: Use renewed mam_id from response to avoid invalid credentials after original one expires 2024-08-27 06:02:58 +03:00
Bogdan
7a5fa452f0 Don't persist value for SslCertHash when checking for existence 2024-08-27 00:27:34 +03:00
Bogdan
281e712542 Fixed: Hide reboot and shutdown UI buttons on docker
(cherry picked from commit 50d7e8fed4f9a43b501551f84471656f8bb19458)
2024-08-26 03:29:31 +03:00
Bogdan
c2c34ecf53 New: Bypass IP addresses ranges in proxies
(cherry picked from commit 402db9128c214d4c5af6583643cb49d3aa7a28b5)

Closes #2203
2024-08-26 03:26:56 +03:00
bakerboy448
615193617c Fixed: Trim spaces and empty values in Proxy Bypass List
(cherry picked from commit 846333ddf0d9da775c80d004fdb9b41e700ef359)
2024-08-26 03:26:27 +03:00
Bogdan
1b58d50b6d Bump version to 1.23.1 2024-08-25 10:13:49 +03:00
Bogdan
99f9a0b4e6 Improve sorting indexer by status 2024-08-23 02:52:59 +03:00
Bogdan
696001a8bb Remove AroLol
Site has shutdown
2024-08-22 20:09:51 +03:00
Bogdan
31f057c097 Hiding "enable" property in API docs for applications 2024-08-20 17:28:11 +03:00
Bogdan
0391537a60 Don't display validation errors as HTML
Display the link to application only if it's enabled

Thanks to higa on discord for pointing this to us.
2024-08-20 17:10:24 +03:00
Servarr
521c1f760c Automated API Docs update 2024-08-20 05:26:16 +03:00
Bogdan
3bf9b4f90f Dedupe titles to avoid similar release names for AB 2024-08-20 05:16:35 +03:00
martylukyy
af86a6d34e New: Configure log file size limit in UI
(cherry picked from commit 35baebaf7280749d5dfe5440e28b425e45a22d21)
2024-08-19 15:54:55 +03:00
Bogdan
3ecf5c6166 Fixed: (AnimeBytes) Improve filtering of old releases 2024-08-19 15:30:45 +03:00
Bogdan
4da3e7b2b3 Fixed: (MyAnonamouse) Sanitise search query and stop search if term is empty 2024-08-19 01:08:15 +03:00
Bogdan
66f38f1566 Bump version to 1.23.0 2024-08-18 15:52:58 +03:00
Weblate
04b513ad14 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_CN/
Translation: Servarr/Prowlarr
2024-08-18 04:59:35 +03:00
Bogdan
1ce7fda8bb Fixed: Removing invalid statuses on provider deletion 2024-08-17 18:11:43 +03:00
Bogdan
6d09fad675 Bump babel packages 2024-08-16 18:38:54 +03:00
Bogdan
ad061e7ece Fix select background appearance on iOS
And make use of autoprefixer
2024-08-16 18:38:53 +03:00
Bogdan
3155343bcc Fixed: (Uniotaku) Updated categories
Co-authored-by: Garfield69 <garfield69@outlook.com>
2024-08-16 15:03:24 +03:00
Bogdan
f5e91f7bfd Fixed: (RuTracker) Updated categories
Co-authored-by: Garfield69 <garfield69@outlook.com>
2024-08-16 15:01:55 +03:00
Bogdan
1d69f2ed3f Fixed: (GazelleGames) Fixed release titles 2024-08-16 14:41:45 +03:00
Mark McDowall
1d233dbcab New: Configurable log file size limit
(cherry picked from commit 813965e6a20edef2772d68eaa7646af33028425a)
2024-08-15 04:02:33 +03:00
Mark McDowall
1aafb0b201 New: Add Compact Log Event Format option for console logging
(cherry picked from commit 0d914f4c53876540ed2df83ad3d71615c013856f)
2024-08-15 04:00:23 +03:00
Bogdan
d7d5a2dd42 Upgrade nlog to 5.3.3 2024-08-15 03:57:21 +03:00
Bogdan
8060a65ef6 Fixed: Duplicated changelog lines 2024-08-15 03:51:36 +03:00
Bogdan
379071f838 Convert formatBytes to TypeScript 2024-08-15 03:49:37 +03:00
Mark McDowall
5cbbd060a4 Update React Lint rules for TSX
(cherry picked from commit 1299a97579bec52ee3d16ab8d05c9e22edd80330)
2024-08-15 03:31:29 +03:00
Mark McDowall
ef19673a76 Convert System to TypeScript
(cherry picked from commit 72db8099e0f4abc3176e397f8dda3b2b69026daf)
2024-08-15 03:31:29 +03:00
The Dark
c3cf8a6ebb Convert App to TypeScript
(cherry picked from commit d6d90a64a39d3b9d3a95fb6b265517693a70fdd7)
(cherry picked from commit 428569106499b5e3a463f1990ae2996d1ae4ab49)
(cherry picked from commit d0e9504af0d88391a74e04b90638e4b2d99fb476)
(cherry picked from commit ee80564dd427ca1dc14c192955efaa61f386ad44)
(cherry picked from commit 76650af9fdc7ef06d13ce252986d21574903d293)
2024-08-15 03:31:29 +03:00
Bogdan
c22b27525a Include available version in update health check
(cherry picked from commit 15e3c3efb18242caf28b9bfc77a72a78296018bf)
2024-08-15 03:31:29 +03:00
Mark McDowall
eec3b01f5b Don't hash files in development builds
(cherry picked from commit bc7799139e52b92956eb595fb87f44d7dda9a320)
2024-08-15 03:31:29 +03:00
Mark McDowall
e67a127a02 Fixed: Allow leading/trailing spaces on non-Windows
(cherry picked from commit 9127a91dfc460f442498a00faed98737047098cd)
2024-08-15 03:31:29 +03:00
Mark McDowall
a074ebc951 New: Default file log level changed to debug
(cherry picked from commit 9b528eb82914a05cfc3b67d4d6146ce51e86f68d)
2024-08-15 03:31:29 +03:00
Weblate
d1cd814663 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ano10 <arnaudthommeray+github@ik.me>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: iMohmmedSA <i.mohmmed.i+1@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fr/
Translation: Servarr/Prowlarr
2024-08-15 03:30:44 +03:00
Bogdan
ac76646a20 Fixed: Fallback to saved capabilities when syncing failed indexers 2024-08-15 03:29:45 +03:00
Bogdan
6549f799f6 Fix parsing imdb ids for native indexers 2024-08-15 03:29:18 +03:00
Bogdan
cca55fd66c Bump version to 1.22.0 2024-07-27 00:57:57 +03:00
Weblate
2f67d2813a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: seidnerj <jonathan.seidner@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/is/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/sk/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_TW/
Translation: Servarr/Prowlarr
2024-07-27 00:19:19 +03:00
Bogdan
9a7a5fdc38 Remove PropTypes 2024-07-26 06:40:34 +03:00
Bogdan
f1fdec6822 Convert Add Indexer Modal to Typescript 2024-07-26 06:25:37 +03:00
Bogdan
5464b23329 Sort indexer queries stats by a sum of all 3 types 2024-07-26 01:09:04 +03:00
Bogdan
4c99971882 Improve messaging on no results with applied filter 2024-07-25 08:08:48 +03:00
Weblate
cc7769b601 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Dream <seth.gecko.rr@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/is/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_CN/
Translation: Servarr/Prowlarr
2024-07-24 19:42:16 +03:00
Bogdan
cb2ed7daf9 Fixed: Improve elapsed time collecting for grabs 2024-07-22 19:52:12 +03:00
Bogdan
78508094c8 Bump some frontend libraries 2024-07-22 01:02:25 +03:00
Bogdan
b0f755a30c Fixed: (Nebulance) Searching for daily episodes using ids 2024-07-22 00:38:13 +03:00
Bogdan
9d1384792a Bump version to 1.21.2 2024-07-21 18:06:41 +03:00
222 changed files with 4711 additions and 4007 deletions

View File

@@ -1,7 +1,7 @@
# Prowlarr
[![Build Status](https://dev.azure.com/Prowlarr/Prowlarr/_apis/build/status/Prowlarr.Prowlarr?branchName=develop)](https://dev.azure.com/Prowlarr/Prowlarr/_build/latest?definitionId=1&branchName=develop)
[![Translated](https://translate.servarr.com/widgets/servarr/-/prowlarr/svg-badge.svg)](https://translate.servarr.com/engage/prowlarr/?utm_source=widget)
[![Translation status](https://translate.servarr.com/widget/servarr/prowlarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/?utm_source=widget)
[![Docker Pulls](https://img.shields.io/docker/pulls/hotio/prowlarr.svg)](https://wiki.servarr.com/prowlarr/installation/docker)
![Github Downloads](https://img.shields.io/github/downloads/Prowlarr/Prowlarr/total.svg)
[![Backers on Open Collective](https://opencollective.com/Prowlarr/backers/badge.svg)](#backers)

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '1.21.1'
majorVersion: '1.24.2'
minorVersion: $[counter('minorVersion', 1)]
prowlarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(prowlarrVersion)'

View File

@@ -357,11 +357,16 @@ module.exports = {
],
rules: Object.assign(typescriptEslintRecommended.rules, {
'no-shadow': 'off',
// These should be enabled after cleaning things up
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'after-used',
argsIgnorePattern: '^_',
ignoreRestSiblings: true
}
],
'@typescript-eslint/explicit-function-return-type': 'off',
'react/prop-types': 'off',
'no-shadow': 'off',
'prettier/prettier': 'error',
'simple-import-sort/imports': [
'error',
@@ -374,7 +379,41 @@ module.exports = {
['^@?\\w', `^(${dirs})(/.*|$)`, '^\\.', '^\\..*css$']
]
}
]
],
// React Hooks
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// React
'react/function-component-definition': 'error',
'react/hook-use-state': 'error',
'react/jsx-boolean-value': ['error', 'always'],
'react/jsx-curly-brace-presence': [
'error',
{ props: 'never', children: 'never' }
],
'react/jsx-fragments': 'error',
'react/jsx-handler-names': [
'error',
{
eventHandlerPrefix: 'on',
eventHandlerPropPrefix: 'on'
}
],
'react/jsx-no-bind': ['error', { ignoreRefs: true }],
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }],
'react/jsx-pascal-case': ['error', { allowAllCaps: true }],
'react/jsx-sort-props': [
'error',
{
callbacksLast: true,
noSortAlphabetically: true,
reservedFirst: true
}
],
'react/prop-types': 'off',
'react/self-closing-comp': 'error'
})
},
{

View File

@@ -65,7 +65,7 @@ module.exports = (env) => {
output: {
path: distFolder,
publicPath: '/',
filename: '[name]-[contenthash].js',
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
sourceMapFilename: '[file].map'
},
@@ -90,7 +90,7 @@ module.exports = (env) => {
new MiniCssExtractPlugin({
filename: 'Content/styles.css',
chunkFilename: 'Content/[id]-[chunkhash].css'
chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css'
}),
new HtmlWebpackPlugin({
@@ -190,7 +190,7 @@ module.exports = (env) => {
options: {
importLoaders: 1,
modules: {
localIdentName: '[name]/[local]/[hash:base64:5]'
localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]'
}
}
},

View File

@@ -16,6 +16,7 @@ const mixinsFiles = [
module.exports = {
plugins: [
'autoprefixer',
['postcss-mixins', {
mixinsFiles
}],

View File

@@ -1,20 +1,25 @@
import { ConnectedRouter } from 'connected-react-router';
import PropTypes from 'prop-types';
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
import React from 'react';
import DocumentTitle from 'react-document-title';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import PageConnector from 'Components/Page/PageConnector';
import ApplyTheme from './ApplyTheme';
import AppRoutes from './AppRoutes';
function App({ store, history }) {
interface AppProps {
store: Store;
history: ConnectedRouterProps['history'];
}
function App({ store, history }: AppProps) {
return (
<DocumentTitle title={window.Prowlarr.instanceName}>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme />
<PageConnector>
<AppRoutes app={App} />
<AppRoutes />
</PageConnector>
</ConnectedRouter>
</Provider>
@@ -22,9 +27,4 @@ function App({ store, history }) {
);
}
App.propTypes = {
store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired
};
export default App;

View File

@@ -1,184 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import HistoryConnector from 'History/HistoryConnector';
import IndexerIndex from 'Indexer/Index/IndexerIndex';
import IndexerStats from 'Indexer/Stats/IndexerStats';
import SearchIndexConnector from 'Search/SearchIndexConnector';
import ApplicationSettings from 'Settings/Applications/ApplicationSettings';
import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import IndexerSettings from 'Settings/Indexers/IndexerSettings';
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
import Settings from 'Settings/Settings';
import TagSettings from 'Settings/Tags/TagSettings';
import UISettingsConnector from 'Settings/UI/UISettingsConnector';
import BackupsConnector from 'System/Backup/BackupsConnector';
import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import UpdatesConnector from 'System/Updates/UpdatesConnector';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
function AppRoutes(props) {
const {
app
} = props;
return (
<Switch>
{/*
Indexers
*/}
<Route
exact={true}
path="/"
component={IndexerIndex}
/>
{
window.Prowlarr.urlBase &&
<Route
exact={true}
path="/"
addUrlBase={false}
render={() => {
return (
<Redirect
to={getPathWithUrlBase('/')}
component={app}
/>
);
}}
/>
}
<Route
path="/indexers/stats"
component={IndexerStats}
/>
{/*
Search
*/}
<Route
path="/search"
component={SearchIndexConnector}
/>
{/*
Activity
*/}
<Route
path="/history"
component={HistoryConnector}
/>
{/*
Settings
*/}
<Route
exact={true}
path="/settings"
component={Settings}
/>
<Route
path="/settings/indexers"
component={IndexerSettings}
/>
<Route
path="/settings/applications"
component={ApplicationSettings}
/>
<Route
path="/settings/downloadclients"
component={DownloadClientSettingsConnector}
/>
<Route
path="/settings/connect"
component={NotificationSettings}
/>
<Route
path="/settings/tags"
component={TagSettings}
/>
<Route
path="/settings/general"
component={GeneralSettingsConnector}
/>
<Route
path="/settings/ui"
component={UISettingsConnector}
/>
<Route
path="/settings/development"
component={DevelopmentSettingsConnector}
/>
{/*
System
*/}
<Route
path="/system/status"
component={Status}
/>
<Route
path="/system/tasks"
component={Tasks}
/>
<Route
path="/system/backup"
component={BackupsConnector}
/>
<Route
path="/system/updates"
component={UpdatesConnector}
/>
<Route
path="/system/events"
component={LogsTableConnector}
/>
<Route
path="/system/logs/files"
component={Logs}
/>
{/*
Not Found
*/}
<Route
path="*"
component={NotFound}
/>
</Switch>
);
}
AppRoutes.propTypes = {
app: PropTypes.func.isRequired
};
export default AppRoutes;

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import HistoryConnector from 'History/HistoryConnector';
import IndexerIndex from 'Indexer/Index/IndexerIndex';
import IndexerStats from 'Indexer/Stats/IndexerStats';
import SearchIndexConnector from 'Search/SearchIndexConnector';
import ApplicationSettings from 'Settings/Applications/ApplicationSettings';
import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import IndexerSettings from 'Settings/Indexers/IndexerSettings';
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
import Settings from 'Settings/Settings';
import TagSettings from 'Settings/Tags/TagSettings';
import UISettingsConnector from 'Settings/UI/UISettingsConnector';
import BackupsConnector from 'System/Backup/BackupsConnector';
import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import UpdatesConnector from 'System/Updates/UpdatesConnector';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
function RedirectWithUrlBase() {
return <Redirect to={getPathWithUrlBase('/')} />;
}
function AppRoutes() {
return (
<Switch>
{/*
Indexers
*/}
<Route exact={true} path="/" component={IndexerIndex} />
{window.Prowlarr.urlBase && (
<Route
exact={true}
path="/"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
addUrlBase={false}
render={RedirectWithUrlBase}
/>
)}
<Route path="/indexers/stats" component={IndexerStats} />
{/*
Search
*/}
<Route path="/search" component={SearchIndexConnector} />
{/*
Activity
*/}
<Route path="/history" component={HistoryConnector} />
{/*
Settings
*/}
<Route exact={true} path="/settings" component={Settings} />
<Route path="/settings/indexers" component={IndexerSettings} />
<Route path="/settings/applications" component={ApplicationSettings} />
<Route
path="/settings/downloadclients"
component={DownloadClientSettingsConnector}
/>
<Route path="/settings/connect" component={NotificationSettings} />
<Route path="/settings/tags" component={TagSettings} />
<Route path="/settings/general" component={GeneralSettingsConnector} />
<Route path="/settings/ui" component={UISettingsConnector} />
<Route
path="/settings/development"
component={DevelopmentSettingsConnector}
/>
{/*
System
*/}
<Route path="/system/status" component={Status} />
<Route path="/system/tasks" component={Tasks} />
<Route path="/system/backup" component={BackupsConnector} />
<Route path="/system/updates" component={UpdatesConnector} />
<Route path="/system/events" component={LogsTableConnector} />
<Route path="/system/logs/files" component={Logs} />
{/*
Not Found
*/}
<Route path="*" component={NotFound} />
</Switch>
);
}
export default AppRoutes;

View File

@@ -1,30 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector';
function AppUpdatedModal(props) {
const {
isOpen,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<AppUpdatedModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
AppUpdatedModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AppUpdatedModal;

View File

@@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import Modal from 'Components/Modal/Modal';
import AppUpdatedModalContent from './AppUpdatedModalContent';
interface AppUpdatedModalProps {
isOpen: boolean;
onModalClose: (...args: unknown[]) => unknown;
}
function AppUpdatedModal(props: AppUpdatedModalProps) {
const { isOpen, onModalClose } = props;
const handleModalClose = useCallback(() => {
location.reload();
}, []);
return (
<Modal
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<AppUpdatedModalContent onModalClose={handleModalClose} />
</Modal>
);
}
export default AppUpdatedModal;

View File

@@ -1,12 +0,0 @@
import { connect } from 'react-redux';
import AppUpdatedModal from './AppUpdatedModal';
function createMapDispatchToProps(dispatch, props) {
return {
onModalClose() {
location.reload();
}
};
}
export default connect(null, createMapDispatchToProps)(AppUpdatedModal);

View File

@@ -1,139 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import UpdateChanges from 'System/Updates/UpdateChanges';
import translate from 'Utilities/String/translate';
import styles from './AppUpdatedModalContent.css';
function mergeUpdates(items, version, prevVersion) {
let installedIndex = items.findIndex((u) => u.version === version);
let installedPreviouslyIndex = items.findIndex((u) => u.version === prevVersion);
if (installedIndex === -1) {
installedIndex = 0;
}
if (installedPreviouslyIndex === -1) {
installedPreviouslyIndex = items.length;
} else if (installedPreviouslyIndex === installedIndex && items.length) {
installedPreviouslyIndex += 1;
}
const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex);
if (!appliedUpdates.length) {
return null;
}
const appliedChanges = { new: [], fixed: [] };
appliedUpdates.forEach((u) => {
if (u.changes) {
appliedChanges.new.push(... u.changes.new);
appliedChanges.fixed.push(... u.changes.fixed);
}
});
const mergedUpdate = Object.assign({}, appliedUpdates[0], { changes: appliedChanges });
if (!appliedChanges.new.length && !appliedChanges.fixed.length) {
mergedUpdate.changes = null;
}
return mergedUpdate;
}
function AppUpdatedModalContent(props) {
const {
version,
prevVersion,
isPopulated,
error,
items,
onSeeChangesPress,
onModalClose
} = props;
const update = mergeUpdates(items, version, prevVersion);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('AppUpdated')}
</ModalHeader>
<ModalBody>
<div>
<InlineMarkdown data={translate('AppUpdatedVersion', { version })} blockClassName={styles.version} />
</div>
{
isPopulated && !error && !!update &&
<div>
{
!update.changes &&
<div className={styles.maintenance}>{translate('MaintenanceRelease')}</div>
}
{
!!update.changes &&
<div>
<div className={styles.changes}>
{translate('WhatsNew')}
</div>
<UpdateChanges
title={translate('New')}
changes={update.changes.new}
/>
<UpdateChanges
title={translate('Fixed')}
changes={update.changes.fixed}
/>
</div>
}
</div>
}
{
!isPopulated && !error &&
<LoadingIndicator />
}
</ModalBody>
<ModalFooter>
<Button
onPress={onSeeChangesPress}
>
{translate('RecentChanges')}
</Button>
<Button
kind={kinds.PRIMARY}
onPress={onModalClose}
>
{translate('Reload')}
</Button>
</ModalFooter>
</ModalContent>
);
}
AppUpdatedModalContent.propTypes = {
version: PropTypes.string.isRequired,
prevVersion: PropTypes.string,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onSeeChangesPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AppUpdatedModalContent;

View File

@@ -0,0 +1,145 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import { fetchUpdates } from 'Store/Actions/systemActions';
import UpdateChanges from 'System/Updates/UpdateChanges';
import Update from 'typings/Update';
import translate from 'Utilities/String/translate';
import AppState from './State/AppState';
import styles from './AppUpdatedModalContent.css';
function mergeUpdates(items: Update[], version: string, prevVersion?: string) {
let installedIndex = items.findIndex((u) => u.version === version);
let installedPreviouslyIndex = items.findIndex(
(u) => u.version === prevVersion
);
if (installedIndex === -1) {
installedIndex = 0;
}
if (installedPreviouslyIndex === -1) {
installedPreviouslyIndex = items.length;
} else if (installedPreviouslyIndex === installedIndex && items.length) {
installedPreviouslyIndex += 1;
}
const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex);
if (!appliedUpdates.length) {
return null;
}
const appliedChanges: Update['changes'] = { new: [], fixed: [] };
appliedUpdates.forEach((u: Update) => {
if (u.changes) {
appliedChanges.new.push(...u.changes.new);
appliedChanges.fixed.push(...u.changes.fixed);
}
});
const mergedUpdate: Update = Object.assign({}, appliedUpdates[0], {
changes: appliedChanges,
});
if (!appliedChanges.new.length && !appliedChanges.fixed.length) {
mergedUpdate.changes = null;
}
return mergedUpdate;
}
interface AppUpdatedModalContentProps {
onModalClose: () => void;
}
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
const dispatch = useDispatch();
const { version, prevVersion } = useSelector((state: AppState) => state.app);
const { isPopulated, error, items } = useSelector(
(state: AppState) => state.system.updates
);
const previousVersion = usePrevious(version);
const { onModalClose } = props;
const update = mergeUpdates(items, version, prevVersion);
const handleSeeChangesPress = useCallback(() => {
window.location.href = `${window.Prowlarr.urlBase}/system/updates`;
}, []);
useEffect(() => {
dispatch(fetchUpdates());
}, [dispatch]);
useEffect(() => {
if (version !== previousVersion) {
dispatch(fetchUpdates());
}
}, [version, previousVersion, dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('AppUpdated')}</ModalHeader>
<ModalBody>
<div>
<InlineMarkdown
data={translate('AppUpdatedVersion', { version })}
blockClassName={styles.version}
/>
</div>
{isPopulated && !error && !!update ? (
<div>
{update.changes ? (
<div className={styles.maintenance}>
{translate('MaintenanceRelease')}
</div>
) : null}
{update.changes ? (
<div>
<div className={styles.changes}>{translate('WhatsNew')}</div>
<UpdateChanges
title={translate('New')}
changes={update.changes.new}
/>
<UpdateChanges
title={translate('Fixed')}
changes={update.changes.fixed}
/>
</div>
) : null}
</div>
) : null}
{!isPopulated && !error ? <LoadingIndicator /> : null}
</ModalBody>
<ModalFooter>
<Button onPress={handleSeeChangesPress}>
{translate('RecentChanges')}
</Button>
<Button kind={kinds.PRIMARY} onPress={onModalClose}>
{translate('Reload')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default AppUpdatedModalContent;

View File

@@ -1,78 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchUpdates } from 'Store/Actions/systemActions';
import AppUpdatedModalContent from './AppUpdatedModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.app.version,
(state) => state.app.prevVersion,
(state) => state.system.updates,
(version, prevVersion, updates) => {
const {
isPopulated,
error,
items
} = updates;
return {
version,
prevVersion,
isPopulated,
error,
items
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchUpdates() {
dispatch(fetchUpdates());
},
onSeeChangesPress() {
window.location = `${window.Prowlarr.urlBase}/system/updates`;
}
};
}
class AppUpdatedModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchUpdates();
}
componentDidUpdate(prevProps) {
if (prevProps.version !== this.props.version) {
this.props.dispatchFetchUpdates();
}
}
//
// Render
render() {
const {
dispatchFetchUpdates,
...otherProps
} = this.props;
return (
<AppUpdatedModalContent {...otherProps} />
);
}
}
AppUpdatedModalContentConnector.propTypes = {
version: PropTypes.string.isRequired,
dispatchFetchUpdates: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector);

View File

@@ -1,13 +1,9 @@
import React, { Fragment, ReactNode, useCallback, useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import themes from 'Styles/Themes';
import AppState from './State/AppState';
interface ApplyThemeProps {
children: ReactNode;
}
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Prowlarr.theme,
@@ -17,7 +13,7 @@ function createThemeSelector() {
);
}
function ApplyTheme({ children }: ApplyThemeProps) {
function ApplyTheme() {
const theme = useSelector(createThemeSelector());
const updateCSSVariables = useCallback(() => {
@@ -31,7 +27,7 @@ function ApplyTheme({ children }: ApplyThemeProps) {
updateCSSVariables();
}, [updateCSSVariables, theme]);
return <Fragment>{children}</Fragment>;
return null;
}
export default ApplyTheme;

View File

@@ -1,5 +1,4 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, { useCallback } from 'react';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
@@ -10,36 +9,31 @@ import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ConnectionLostModal.css';
function ConnectionLostModal(props) {
const {
isOpen,
onModalClose
} = props;
interface ConnectionLostModalProps {
isOpen: boolean;
}
function ConnectionLostModal(props: ConnectionLostModalProps) {
const { isOpen } = props;
const handleModalClose = useCallback(() => {
location.reload();
}, []);
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('ConnectionLost')}
</ModalHeader>
<Modal isOpen={isOpen} onModalClose={handleModalClose}>
<ModalContent onModalClose={handleModalClose}>
<ModalHeader>{translate('ConnectionLost')}</ModalHeader>
<ModalBody>
<div>
{translate('ConnectionLostToBackend')}
</div>
<div>{translate('ConnectionLostToBackend')}</div>
<div className={styles.automatic}>
{translate('ConnectionLostReconnect')}
</div>
</ModalBody>
<ModalFooter>
<Button
kind={kinds.PRIMARY}
onPress={onModalClose}
>
<Button kind={kinds.PRIMARY} onPress={handleModalClose}>
{translate('Reload')}
</Button>
</ModalFooter>
@@ -48,9 +42,4 @@ function ConnectionLostModal(props) {
);
}
ConnectionLostModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ConnectionLostModal;

View File

@@ -1,12 +0,0 @@
import { connect } from 'react-redux';
import ConnectionLostModal from './ConnectionLostModal';
function createMapDispatchToProps(dispatch, props) {
return {
onModalClose() {
location.reload();
}
};
}
export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal);

View File

@@ -1,5 +1,6 @@
import Column from 'Components/Table/Column';
import SortDirection from 'Helpers/Props/SortDirection';
import { FilterBuilderProp } from './AppState';
import { FilterBuilderProp, PropertyFilter } from './AppState';
export interface Error {
responseJSON: {
@@ -18,10 +19,18 @@ export interface AppSectionSaveState {
}
export interface PagedAppSectionState {
page: number;
pageSize: number;
totalPages: number;
totalRecords?: number;
}
export interface TableAppSectionState {
columns: Column[];
}
export interface AppSectionFilterState<T> {
selectedFilterKey: string;
filters: PropertyFilter[];
filterBuilderProps: FilterBuilderProp<T>[];
}
@@ -38,6 +47,7 @@ export interface AppSectionItemState<T> {
isFetching: boolean;
isPopulated: boolean;
error: Error;
pendingChanges: Partial<T>;
item: T;
}

View File

@@ -43,6 +43,10 @@ export interface CustomFilter {
}
export interface AppSectionState {
isConnected: boolean;
isReconnecting: boolean;
version: string;
prevVersion?: string;
dimensions: {
isSmallScreen: boolean;
width: number;

View File

@@ -31,6 +31,8 @@ interface IndexerAppState
AppSectionDeleteState,
AppSectionSaveState {
itemMap: Record<number, number>;
isTestingAll: boolean;
}
export type IndexerStatusAppState = AppSectionState<IndexerStatus>;

View File

@@ -24,7 +24,9 @@ export interface ApplicationAppState
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
AppSectionDeleteState,
AppSectionSaveState {}
AppSectionSaveState {
isTestingAll: boolean;
}
export interface IndexerCategoryAppState
extends AppSectionState<IndexerCategory>,

View File

@@ -1,10 +1,19 @@
import Health from 'typings/Health';
import SystemStatus from 'typings/SystemStatus';
import { AppSectionItemState } from './AppSectionState';
import Task from 'typings/Task';
import Update from 'typings/Update';
import AppSectionState, { AppSectionItemState } from './AppSectionState';
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type TaskAppState = AppSectionState<Task>;
export type UpdateAppState = AppSectionState<Update>;
interface SystemAppState {
health: HealthAppState;
status: SystemStatusAppState;
tasks: TaskAppState;
updates: UpdateAppState;
}
export default SystemAppState;

View File

@@ -46,6 +46,10 @@ class StackedBarChart extends Component {
size: 14,
family: defaultFontFamily
}
},
tooltip: {
mode: 'index',
position: 'average'
}
}
},

View File

@@ -63,11 +63,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
<div>{info.componentStack}</div>
)}
{
<div className={styles.version}>
Version: {window.Prowlarr.version}
</div>
}
<div className={styles.version}>Version: {window.Prowlarr.version}</div>
</details>
</div>
);

View File

@@ -25,7 +25,7 @@ function FormInputHelpText(props) {
isCheckInput && styles.isCheckInput
)}
>
<div dangerouslySetInnerHTML={{ __html: text }} />
{text}
{
link ?

View File

@@ -1,7 +1,14 @@
.select {
@add-mixin truncate;
composes: input from '~Components/Form/Input.css';
padding: 0 11px;
padding: 0 30px 0 11px;
background-image: none, linear-gradient(-135deg, transparent 50%, var(--inputBackgroundColor) 50%), linear-gradient(-225deg, transparent 50%, var(--inputBackgroundColor) 50%), linear-gradient(var(--inputBackgroundColor) 42%, var(--textColor) 42%);
background-position: right 30px center, right bottom, right bottom, right bottom;
background-size: 1px 100%, 35px 27px, 30px 35px, 30px 100%;
background-repeat: no-repeat;
appearance: none;
}
.hasError {

View File

@@ -7,7 +7,7 @@ import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import IndexerSearchInputConnector from './IndexerSearchInputConnector';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
import styles from './PageHeader.css';
class PageHeader extends Component {
@@ -87,7 +87,8 @@ class PageHeader extends Component {
to="https://translate.servarr.com/projects/servarr/prowlarr/"
size={24}
/>
<PageHeaderActionsMenuConnector
<PageHeaderActionsMenu
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
/>
</div>

View File

@@ -1,90 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
import { align, icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './PageHeaderActionsMenu.css';
function PageHeaderActionsMenu(props) {
const {
formsAuth,
onKeyboardShortcutsPress,
onRestartPress,
onShutdownPress
} = props;
return (
<div>
<Menu alignMenu={align.RIGHT}>
<MenuButton className={styles.menuButton} aria-label="Menu Button">
<Icon
name={icons.INTERACTIVE}
title={translate('Menu')}
/>
</MenuButton>
<MenuContent>
<MenuItem onPress={onKeyboardShortcutsPress}>
<Icon
className={styles.itemIcon}
name={icons.KEYBOARD}
/>
{translate('KeyboardShortcuts')}
</MenuItem>
<MenuItemSeparator />
<MenuItem onPress={onRestartPress}>
<Icon
className={styles.itemIcon}
name={icons.RESTART}
/>
{translate('Restart')}
</MenuItem>
<MenuItem onPress={onShutdownPress}>
<Icon
className={styles.itemIcon}
name={icons.SHUTDOWN}
kind={kinds.DANGER}
/>
{translate('Shutdown')}
</MenuItem>
{
formsAuth &&
<div className={styles.separator} />
}
{
formsAuth &&
<MenuItem
to={`${window.Prowlarr.urlBase}/logout`}
noRouter={true}
>
<Icon
className={styles.itemIcon}
name={icons.LOGOUT}
/>
Logout
</MenuItem>
}
</MenuContent>
</Menu>
</div>
);
}
PageHeaderActionsMenu.propTypes = {
formsAuth: PropTypes.bool.isRequired,
onKeyboardShortcutsPress: PropTypes.func.isRequired,
onRestartPress: PropTypes.func.isRequired,
onShutdownPress: PropTypes.func.isRequired
};
export default PageHeaderActionsMenu;

View File

@@ -0,0 +1,90 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Icon from 'Components/Icon';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
import { align, icons, kinds } from 'Helpers/Props';
import { restart, shutdown } from 'Store/Actions/systemActions';
import translate from 'Utilities/String/translate';
import styles from './PageHeaderActionsMenu.css';
interface PageHeaderActionsMenuProps {
onKeyboardShortcutsPress(): void;
}
function PageHeaderActionsMenu(props: PageHeaderActionsMenuProps) {
const { onKeyboardShortcutsPress } = props;
const dispatch = useDispatch();
const { authentication, isDocker } = useSelector(
(state: AppState) => state.system.status.item
);
const formsAuth = authentication === 'forms';
const handleRestartPress = useCallback(() => {
dispatch(restart());
}, [dispatch]);
const handleShutdownPress = useCallback(() => {
dispatch(shutdown());
}, [dispatch]);
return (
<div>
<Menu alignMenu={align.RIGHT}>
<MenuButton className={styles.menuButton} aria-label="Menu Button">
<Icon name={icons.INTERACTIVE} title={translate('Menu')} />
</MenuButton>
<MenuContent>
<MenuItem onPress={onKeyboardShortcutsPress}>
<Icon className={styles.itemIcon} name={icons.KEYBOARD} />
{translate('KeyboardShortcuts')}
</MenuItem>
{isDocker ? null : (
<>
<MenuItemSeparator />
<MenuItem onPress={handleRestartPress}>
<Icon className={styles.itemIcon} name={icons.RESTART} />
{translate('Restart')}
</MenuItem>
<MenuItem onPress={handleShutdownPress}>
<Icon
className={styles.itemIcon}
name={icons.SHUTDOWN}
kind={kinds.DANGER}
/>
{translate('Shutdown')}
</MenuItem>
</>
)}
{formsAuth ? (
<>
<MenuItemSeparator />
<MenuItem
to={`${window.Prowlarr.urlBase}/logout`}
noRouter={true}
>
<Icon className={styles.itemIcon} name={icons.LOGOUT} />
{translate('Logout')}
</MenuItem>
</>
) : null}
</MenuContent>
</Menu>
</div>
);
}
export default PageHeaderActionsMenu;

View File

@@ -1,56 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { restart, shutdown } from 'Store/Actions/systemActions';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
function createMapStateToProps() {
return createSelector(
(state) => state.system.status,
(status) => {
return {
formsAuth: status.item.authentication === 'forms'
};
}
);
}
const mapDispatchToProps = {
restart,
shutdown
};
class PageHeaderActionsMenuConnector extends Component {
//
// Listeners
onRestartPress = () => {
this.props.restart();
};
onShutdownPress = () => {
this.props.shutdown();
};
//
// Render
render() {
return (
<PageHeaderActionsMenu
{...this.props}
onRestartPress={this.onRestartPress}
onShutdownPress={this.onShutdownPress}
/>
);
}
}
PageHeaderActionsMenuConnector.propTypes = {
restart: PropTypes.func.isRequired,
shutdown: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector);

View File

@@ -1,8 +1,8 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector';
import AppUpdatedModal from 'App/AppUpdatedModal';
import ColorImpairedContext from 'App/ColorImpairedContext';
import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector';
import ConnectionLostModal from 'App/ConnectionLostModal';
import SignalRConnector from 'Components/SignalRConnector';
import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal';
import locationShape from 'Helpers/Props/Shapes/locationShape';
@@ -102,12 +102,12 @@ class Page extends Component {
{children}
</div>
<AppUpdatedModalConnector
<AppUpdatedModal
isOpen={this.state.isUpdatedModalOpen}
onModalClose={this.onUpdatedModalClose}
/>
<ConnectionLostModalConnector
<ConnectionLostModal
isOpen={this.state.isConnectionLostModalOpen}
onModalClose={this.onConnectionLostModalClose}
/>

View File

@@ -8,7 +8,7 @@ import Scroller from 'Components/Scroller/Scroller';
import { icons } from 'Helpers/Props';
import locationShape from 'Helpers/Props/Shapes/locationShape';
import dimensions from 'Styles/Variables/dimensions';
import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector';
import HealthStatus from 'System/Status/Health/HealthStatus';
import translate from 'Utilities/String/translate';
import MessagesConnector from './Messages/MessagesConnector';
import PageSidebarItem from './PageSidebarItem';
@@ -87,7 +87,7 @@ const links = [
{
title: () => translate('Status'),
to: '/system/status',
statusComponent: HealthStatusConnector
statusComponent: HealthStatus
},
{
title: () => translate('Tasks'),

View File

@@ -2,9 +2,11 @@ import React from 'react';
type PropertyFunction<T> = () => T;
// TODO: Convert to generic so `name` can be a type
interface Column {
name: string;
label: string | PropertyFunction<string> | React.ReactNode;
className?: string;
columnLabel?: string;
isSortable?: boolean;
isVisible: boolean;

View File

@@ -0,0 +1,54 @@
import { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
interface PagingOptions {
page: number;
totalPages: number;
gotoPage: ({ page }: { page: number }) => void;
}
function usePaging(options: PagingOptions) {
const { page, totalPages, gotoPage } = options;
const dispatch = useDispatch();
const handleFirstPagePress = useCallback(() => {
dispatch(gotoPage({ page: 1 }));
}, [dispatch, gotoPage]);
const handlePreviousPagePress = useCallback(() => {
dispatch(gotoPage({ page: Math.max(page - 1, 1) }));
}, [page, dispatch, gotoPage]);
const handleNextPagePress = useCallback(() => {
dispatch(gotoPage({ page: Math.min(page + 1, totalPages) }));
}, [page, totalPages, dispatch, gotoPage]);
const handleLastPagePress = useCallback(() => {
dispatch(gotoPage({ page: totalPages }));
}, [totalPages, dispatch, gotoPage]);
const handlePageSelect = useCallback(
(page: number) => {
dispatch(gotoPage({ page }));
},
[dispatch, gotoPage]
);
return useMemo(() => {
return {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
};
}, [
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
]);
}
export default usePaging;

View File

@@ -1,7 +1,3 @@
enum DownloadProtocol {
Unknown = 'unknown',
Usenet = 'usenet',
Torrent = 'torrent',
}
type DownloadProtocol = 'usenet' | 'torrent' | 'unknown';
export default DownloadProtocol;

View File

@@ -0,0 +1,9 @@
import { useHistory } from 'react-router-dom';
function useCurrentPage() {
const history = useHistory();
return history.action === 'POP';
}
export default useCurrentPage;

View File

@@ -3,15 +3,15 @@ import { useCallback, useState } from 'react';
export default function useModalOpenState(
initialState: boolean
): [boolean, () => void, () => void] {
const [isOpen, setOpen] = useState(initialState);
const [isOpen, setIsOpen] = useState(initialState);
const setModalOpen = useCallback(() => {
setOpen(true);
}, [setOpen]);
setIsOpen(true);
}, [setIsOpen]);
const setModalClosed = useCallback(() => {
setOpen(false);
}, [setOpen]);
setIsOpen(false);
}, [setIsOpen]);
return [isOpen, setModalOpen, setModalClosed];
}

View File

@@ -0,0 +1,3 @@
type TooltipPosition = 'top' | 'right' | 'bottom' | 'left';
export default TooltipPosition;

View File

@@ -1,31 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import AddIndexerModalContentConnector from './AddIndexerModalContentConnector';
import styles from './AddIndexerModal.css';
function AddIndexerModal({ isOpen, onModalClose, onSelectIndexer, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
onModalClose={onModalClose}
className={styles.modal}
>
<AddIndexerModalContentConnector
{...otherProps}
onModalClose={onModalClose}
onSelectIndexer={onSelectIndexer}
/>
</Modal>
);
}
AddIndexerModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
onSelectIndexer: PropTypes.func.isRequired
};
export default AddIndexerModal;

View File

@@ -0,0 +1,44 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearIndexerSchema } from 'Store/Actions/indexerActions';
import AddIndexerModalContent from './AddIndexerModalContent';
import styles from './AddIndexerModal.css';
interface AddIndexerModalProps {
isOpen: boolean;
onSelectIndexer(): void;
onModalClose(): void;
}
function AddIndexerModal({
isOpen,
onSelectIndexer,
onModalClose,
...otherProps
}: AddIndexerModalProps) {
const dispatch = useDispatch();
const onModalClosePress = useCallback(() => {
dispatch(clearIndexerSchema());
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
className={styles.modal}
onModalClose={onModalClosePress}
>
<AddIndexerModalContent
{...otherProps}
onSelectIndexer={onSelectIndexer}
onModalClose={onModalClosePress}
/>
</Modal>
);
}
export default AddIndexerModal;

View File

@@ -19,12 +19,18 @@
margin-bottom: 16px;
}
.alert {
.notice {
composes: alert from '~Components/Alert.css';
margin-bottom: 20px;
}
.alert {
composes: alert from '~Components/Alert.css';
text-align: center;
}
.scroller {
flex: 1 1 auto;
}

View File

@@ -10,6 +10,7 @@ interface CssExports {
'indexers': string;
'modalBody': string;
'modalFooter': string;
'notice': string;
'scroller': string;
}
export const cssExports: CssExports;

View File

@@ -1,325 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import EnhancedSelectInput from 'Components/Form/EnhancedSelectInput';
import NewznabCategorySelectInputConnector from 'Components/Form/NewznabCategorySelectInputConnector';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Scroller from 'Components/Scroller/Scroller';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { kinds, scrollDirections } from 'Helpers/Props';
import sortByProp from 'Utilities/Array/sortByProp';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import SelectIndexerRow from './SelectIndexerRow';
import styles from './AddIndexerModalContent.css';
const columns = [
{
name: 'protocol',
label: () => translate('Protocol'),
isSortable: true,
isVisible: true
},
{
name: 'sortName',
label: () => translate('Name'),
isSortable: true,
isVisible: true
},
{
name: 'language',
label: () => translate('Language'),
isSortable: true,
isVisible: true
},
{
name: 'description',
label: () => translate('Description'),
isSortable: false,
isVisible: true
},
{
name: 'privacy',
label: () => translate('Privacy'),
isSortable: true,
isVisible: true
},
{
name: 'categories',
label: () => translate('Categories'),
isSortable: false,
isVisible: true
}
];
const protocols = [
{
key: 'torrent',
value: 'torrent'
},
{
key: 'usenet',
value: 'nzb'
}
];
const privacyLevels = [
{
key: 'private',
get value() {
return translate('Private');
}
},
{
key: 'semiPrivate',
get value() {
return translate('SemiPrivate');
}
},
{
key: 'public',
get value() {
return translate('Public');
}
}
];
class AddIndexerModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
filter: '',
filterProtocols: [],
filterLanguages: [],
filterPrivacyLevels: [],
filterCategories: []
};
}
//
// Listeners
onFilterChange = ({ value }) => {
this.setState({ filter: value });
};
//
// Render
render() {
const {
indexers,
onIndexerSelect,
sortKey,
sortDirection,
isFetching,
isPopulated,
error,
onSortPress,
onModalClose
} = this.props;
const languages = Array.from(new Set(indexers.map(({ language }) => language)))
.map((language) => ({ key: language, value: language }))
.sort(sortByProp('value'));
const filteredIndexers = indexers.filter((indexer) => {
const {
filter,
filterProtocols,
filterLanguages,
filterPrivacyLevels,
filterCategories
} = this.state;
if (!indexer.name.toLowerCase().includes(filter.toLocaleLowerCase()) && !indexer.description.toLowerCase().includes(filter.toLocaleLowerCase())) {
return false;
}
if (filterProtocols.length && !filterProtocols.includes(indexer.protocol)) {
return false;
}
if (filterLanguages.length && !filterLanguages.includes(indexer.language)) {
return false;
}
if (filterPrivacyLevels.length && !filterPrivacyLevels.includes(indexer.privacy)) {
return false;
}
if (filterCategories.length) {
const { categories = [] } = indexer.capabilities || {};
const flat = ({ id, subCategories = [] }) => [id, ...subCategories.flatMap(flat)];
const flatCategories = categories
.filter((item) => item.id < 100000)
.flatMap(flat);
if (!filterCategories.every((item) => flatCategories.includes(item))) {
return false;
}
}
return true;
});
const errorMessage = getErrorMessage(error, translate('UnableToLoadIndexers'));
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('AddIndexer')}
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<TextInput
className={styles.filterInput}
placeholder={translate('FilterPlaceHolder')}
name="filter"
value={this.state.filter}
autoFocus={true}
onChange={this.onFilterChange}
/>
<div className={styles.filterRow}>
<div className={styles.filterContainer}>
<label className={styles.filterLabel}>{translate('Protocol')}</label>
<EnhancedSelectInput
name="indexerProtocols"
value={this.state.filterProtocols}
values={protocols}
onChange={({ value }) => this.setState({ filterProtocols: value })}
/>
</div>
<div className={styles.filterContainer}>
<label className={styles.filterLabel}>{translate('Language')}</label>
<EnhancedSelectInput
name="indexerLanguages"
value={this.state.filterLanguages}
values={languages}
onChange={({ value }) => this.setState({ filterLanguages: value })}
/>
</div>
<div className={styles.filterContainer}>
<label className={styles.filterLabel}>{translate('Privacy')}</label>
<EnhancedSelectInput
name="indexerPrivacyLevels"
value={this.state.filterPrivacyLevels}
values={privacyLevels}
onChange={({ value }) => this.setState({ filterPrivacyLevels: value })}
/>
</div>
<div className={styles.filterContainer}>
<label className={styles.filterLabel}>{translate('Categories')}</label>
<NewznabCategorySelectInputConnector
name="indexerCategories"
value={this.state.filterCategories}
onChange={({ value }) => this.setState({ filterCategories: value })}
/>
</div>
</div>
<Alert
kind={kinds.INFO}
className={styles.alert}
>
<div>
{translate('ProwlarrSupportsAnyIndexer')}
</div>
</Alert>
<Scroller
className={styles.scroller}
autoFocus={false}
>
{
isFetching ? <LoadingIndicator /> : null
}
{
error ? <Alert kind={kinds.DANGER}>{errorMessage}</Alert> : null
}
{
isPopulated && !!indexers.length ?
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{
filteredIndexers.map((indexer) => (
<SelectIndexerRow
key={`${indexer.implementation}-${indexer.name}`}
implementation={indexer.implementation}
implementationName={indexer.implementationName}
{...indexer}
onIndexerSelect={onIndexerSelect}
/>
))
}
</TableBody>
</Table> :
null
}
{
isPopulated && !!indexers.length && !filteredIndexers.length ?
<Alert
kind={kinds.WARNING}
>
{translate('NoIndexersFound')}
</Alert> :
null
}
</Scroller>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.available}>
{
isPopulated ?
translate('CountIndexersAvailable', { count: filteredIndexers.length }) :
null
}
</div>
<div>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
}
AddIndexerModalContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
onSortPress: PropTypes.func.isRequired,
indexers: PropTypes.arrayOf(PropTypes.object).isRequired,
onIndexerSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddIndexerModalContent;

View File

@@ -0,0 +1,434 @@
import { some } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import IndexerAppState from 'App/State/IndexerAppState';
import Alert from 'Components/Alert';
import EnhancedSelectInput from 'Components/Form/EnhancedSelectInput';
import NewznabCategorySelectInputConnector from 'Components/Form/NewznabCategorySelectInputConnector';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Scroller from 'Components/Scroller/Scroller';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { kinds, scrollDirections } from 'Helpers/Props';
import Indexer, { IndexerCategory } from 'Indexer/Indexer';
import {
fetchIndexerSchema,
selectIndexerSchema,
setIndexerSchemaSort,
} from 'Store/Actions/indexerActions';
import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SortCallback } from 'typings/callbacks';
import sortByProp from 'Utilities/Array/sortByProp';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import SelectIndexerRow from './SelectIndexerRow';
import styles from './AddIndexerModalContent.css';
const COLUMNS = [
{
name: 'protocol',
label: () => translate('Protocol'),
isSortable: true,
isVisible: true,
},
{
name: 'sortName',
label: () => translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'language',
label: () => translate('Language'),
isSortable: true,
isVisible: true,
},
{
name: 'description',
label: () => translate('Description'),
isSortable: false,
isVisible: true,
},
{
name: 'privacy',
label: () => translate('Privacy'),
isSortable: true,
isVisible: true,
},
{
name: 'categories',
label: () => translate('Categories'),
isSortable: false,
isVisible: true,
},
];
const PROTOCOLS = [
{
key: 'torrent',
value: 'torrent',
},
{
key: 'usenet',
value: 'nzb',
},
];
const PRIVACY_LEVELS = [
{
key: 'private',
get value() {
return translate('Private');
},
},
{
key: 'semiPrivate',
get value() {
return translate('SemiPrivate');
},
},
{
key: 'public',
get value() {
return translate('Public');
},
},
];
interface IndexerSchema extends Indexer {
isExistingIndexer: boolean;
}
function createAddIndexersSelector() {
return createSelector(
createClientSideCollectionSelector('indexers.schema'),
createAllIndexersSelector(),
(indexers: IndexerAppState, allIndexers) => {
const { isFetching, isPopulated, error, items, sortDirection, sortKey } =
indexers;
const indexerList: IndexerSchema[] = items.map((item) => {
const { definitionName } = item;
return {
...item,
isExistingIndexer: some(allIndexers, { definitionName }),
};
});
return {
isFetching,
isPopulated,
error,
indexers: indexerList,
sortKey,
sortDirection,
};
}
);
}
interface AddIndexerModalContentProps {
onSelectIndexer(): void;
onModalClose(): void;
}
function AddIndexerModalContent(props: AddIndexerModalContentProps) {
const { onSelectIndexer, onModalClose } = props;
const { isFetching, isPopulated, error, indexers, sortKey, sortDirection } =
useSelector(createAddIndexersSelector());
const dispatch = useDispatch();
const [filter, setFilter] = useState('');
const [filterProtocols, setFilterProtocols] = useState<string[]>([]);
const [filterLanguages, setFilterLanguages] = useState<string[]>([]);
const [filterPrivacyLevels, setFilterPrivacyLevels] = useState<string[]>([]);
const [filterCategories, setFilterCategories] = useState<number[]>([]);
useEffect(
() => {
dispatch(fetchIndexerSchema());
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const onFilterChange = useCallback(
({ value }: { value: string }) => {
setFilter(value);
},
[setFilter]
);
const onFilterProtocolsChange = useCallback(
({ value }: { value: string[] }) => {
setFilterProtocols(value);
},
[setFilterProtocols]
);
const onFilterLanguagesChange = useCallback(
({ value }: { value: string[] }) => {
setFilterLanguages(value);
},
[setFilterLanguages]
);
const onFilterPrivacyLevelsChange = useCallback(
({ value }: { value: string[] }) => {
setFilterPrivacyLevels(value);
},
[setFilterPrivacyLevels]
);
const onFilterCategoriesChange = useCallback(
({ value }: { value: number[] }) => {
setFilterCategories(value);
},
[setFilterCategories]
);
const onIndexerSelect = useCallback(
({
implementation,
implementationName,
name,
}: {
implementation: string;
implementationName: string;
name: string;
}) => {
dispatch(
selectIndexerSchema({
implementation,
implementationName,
name,
})
);
onSelectIndexer();
},
[dispatch, onSelectIndexer]
);
const onSortPress = useCallback<SortCallback>(
(sortKey, sortDirection) => {
dispatch(setIndexerSchemaSort({ sortKey, sortDirection }));
},
[dispatch]
);
const languages = useMemo(
() =>
Array.from(new Set(indexers.map(({ language }) => language)))
.map((language) => ({ key: language, value: language }))
.sort(sortByProp('value')),
[indexers]
);
const filteredIndexers = useMemo(() => {
const flat = ({
id,
subCategories = [],
}: {
id: number;
subCategories: IndexerCategory[];
}): number[] => [id, ...subCategories.flatMap(flat)];
return indexers.filter((indexer) => {
if (
filter.length &&
!indexer.name.toLowerCase().includes(filter.toLocaleLowerCase()) &&
!indexer.description.toLowerCase().includes(filter.toLocaleLowerCase())
) {
return false;
}
if (
filterProtocols.length &&
!filterProtocols.includes(indexer.protocol)
) {
return false;
}
if (
filterLanguages.length &&
!filterLanguages.includes(indexer.language)
) {
return false;
}
if (
filterPrivacyLevels.length &&
!filterPrivacyLevels.includes(indexer.privacy)
) {
return false;
}
if (filterCategories.length) {
const { categories = [] } = indexer.capabilities || {};
const flatCategories = categories
.filter((item) => item.id < 100000)
.flatMap(flat);
if (
!filterCategories.every((categoryId) =>
flatCategories.includes(categoryId)
)
) {
return false;
}
}
return true;
});
}, [
indexers,
filter,
filterProtocols,
filterLanguages,
filterPrivacyLevels,
filterCategories,
]);
const errorMessage = getErrorMessage(
error,
translate('UnableToLoadIndexers')
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('AddIndexer')}</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<TextInput
className={styles.filterInput}
placeholder={translate('FilterPlaceHolder')}
name="filter"
value={filter}
autoFocus={true}
onChange={onFilterChange}
/>
<div className={styles.filterRow}>
<div className={styles.filterContainer}>
<label className={styles.filterLabel}>
{translate('Protocol')}
</label>
<EnhancedSelectInput
name="indexerProtocols"
value={filterProtocols}
values={PROTOCOLS}
onChange={onFilterProtocolsChange}
/>
</div>
<div className={styles.filterContainer}>
<label className={styles.filterLabel}>
{translate('Language')}
</label>
<EnhancedSelectInput
name="indexerLanguages"
value={filterLanguages}
values={languages}
onChange={onFilterLanguagesChange}
/>
</div>
<div className={styles.filterContainer}>
<label className={styles.filterLabel}>{translate('Privacy')}</label>
<EnhancedSelectInput
name="indexerPrivacyLevels"
value={filterPrivacyLevels}
values={PRIVACY_LEVELS}
onChange={onFilterPrivacyLevelsChange}
/>
</div>
<div className={styles.filterContainer}>
<label className={styles.filterLabel}>
{translate('Categories')}
</label>
<NewznabCategorySelectInputConnector
name="indexerCategories"
value={filterCategories}
onChange={onFilterCategoriesChange}
/>
</div>
</div>
<Alert kind={kinds.INFO} className={styles.notice}>
<div>{translate('ProwlarrSupportsAnyIndexer')}</div>
</Alert>
<Scroller className={styles.scroller} autoFocus={false}>
{isFetching ? <LoadingIndicator /> : null}
{error ? (
<Alert kind={kinds.DANGER} className={styles.alert}>
{errorMessage}
</Alert>
) : null}
{isPopulated && !!indexers.length ? (
<Table
columns={COLUMNS}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{filteredIndexers.map((indexer) => (
<SelectIndexerRow
{...indexer}
key={`${indexer.implementation}-${indexer.name}`}
implementation={indexer.implementation}
implementationName={indexer.implementationName}
onIndexerSelect={onIndexerSelect}
/>
))}
</TableBody>
</Table>
) : null}
{isPopulated && !!indexers.length && !filteredIndexers.length ? (
<Alert kind={kinds.WARNING} className={styles.alert}>
{translate('NoIndexersFound')}
</Alert>
) : null}
</Scroller>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.available}>
{isPopulated
? translate('CountIndexersAvailable', {
count: filteredIndexers.length,
})
: null}
</div>
<div>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default AddIndexerModalContent;

View File

@@ -1,94 +0,0 @@
import { some } from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchIndexerSchema, selectIndexerSchema, setIndexerSchemaSort } from 'Store/Actions/indexerActions';
import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import AddIndexerModalContent from './AddIndexerModalContent';
function createMapStateToProps() {
return createSelector(
createClientSideCollectionSelector('indexers.schema'),
createAllIndexersSelector(),
(indexers, allIndexers) => {
const {
isFetching,
isPopulated,
error,
items,
sortDirection,
sortKey
} = indexers;
const indexerList = items.map((item) => {
const { definitionName } = item;
return {
...item,
isExistingIndexer: some(allIndexers, { definitionName })
};
});
return {
isFetching,
isPopulated,
error,
indexers: indexerList,
sortKey,
sortDirection
};
}
);
}
const mapDispatchToProps = {
fetchIndexerSchema,
selectIndexerSchema,
setIndexerSchemaSort
};
class AddIndexerModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchIndexerSchema();
}
//
// Listeners
onIndexerSelect = ({ implementation, implementationName, name }) => {
this.props.selectIndexerSchema({ implementation, implementationName, name });
this.props.onSelectIndexer();
};
onSortPress = (sortKey, sortDirection) => {
this.props.setIndexerSchemaSort({ sortKey, sortDirection });
};
//
// Render
render() {
return (
<AddIndexerModalContent
{...this.props}
onSortPress={this.onSortPress}
onIndexerSelect={this.onIndexerSelect}
/>
);
}
}
AddIndexerModalContentConnector.propTypes = {
fetchIndexerSchema: PropTypes.func.isRequired,
selectIndexerSchema: PropTypes.func.isRequired,
setIndexerSchemaSort: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onSelectIndexer: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddIndexerModalContentConnector);

View File

@@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowButton from 'Components/Table/TableRowButton';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import { icons } from 'Helpers/Props';
import CapabilitiesLabel from 'Indexer/Index/Table/CapabilitiesLabel';
import PrivacyLabel from 'Indexer/Index/Table/PrivacyLabel';
@@ -12,7 +13,7 @@ import styles from './SelectIndexerRow.css';
interface SelectIndexerRowProps {
name: string;
protocol: string;
protocol: DownloadProtocol;
privacy: IndexerPrivacy;
language: string;
description: string;

View File

@@ -2,6 +2,7 @@ import { uniqBy } from 'lodash';
import React from 'react';
import Label from 'Components/Label';
import { IndexerCapabilities } from 'Indexer/Indexer';
import translate from 'Utilities/String/translate';
interface CapabilitiesLabelProps {
capabilities: IndexerCapabilities;
@@ -38,7 +39,7 @@ function CapabilitiesLabel(props: CapabilitiesLabelProps) {
);
})}
{filteredList.length === 0 ? <Label>{'None'}</Label> : null}
{filteredList.length === 0 ? <Label>{translate('None')}</Label> : null}
</span>
);
}

View File

@@ -46,11 +46,7 @@ const columnsSelector = createSelector(
(columns) => columns
);
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
index,
style,
data,
}) => {
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
const { items, sortKey, columns, isSelectMode, onCloneIndexerPress } = data;
if (index >= items.length) {
@@ -77,7 +73,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
/>
</div>
);
};
}
function getWindowScrollTopPosition() {
return document.documentElement.scrollTop || document.body.scrollTop || 0;

View File

@@ -1,4 +1,4 @@
import React, { Fragment, useCallback } from 'react';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@@ -32,19 +32,17 @@ function IndexerIndexTableOptions(props: IndexerIndexTableOptionsProps) {
);
return (
<Fragment>
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchHelpText')}
onChange={onTableOptionChangeWrapper}
/>
</FormGroup>
</Fragment>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchHelpText')}
onChange={onTableOptionChangeWrapper}
/>
</FormGroup>
);
}

View File

@@ -8,6 +8,30 @@ import translate from 'Utilities/String/translate';
import DisabledIndexerInfo from './DisabledIndexerInfo';
import styles from './IndexerStatusCell.css';
function getIconKind(enabled: boolean, redirect: boolean) {
if (enabled) {
return redirect ? kinds.INFO : kinds.SUCCESS;
}
return kinds.DEFAULT;
}
function getIconName(enabled: boolean, redirect: boolean) {
if (enabled) {
return redirect ? icons.REDIRECT : icons.CHECK;
}
return icons.BLOCKLIST;
}
function getIconTooltip(enabled: boolean, redirect: boolean) {
if (enabled) {
return redirect ? translate('EnabledRedirected') : translate('Enabled');
}
return translate('Disabled');
}
interface IndexerStatusCellProps {
className: string;
enabled: boolean;
@@ -30,22 +54,14 @@ function IndexerStatusCell(props: IndexerStatusCellProps) {
...otherProps
} = props;
const enableKind = redirect ? kinds.INFO : kinds.SUCCESS;
const enableIcon = redirect ? icons.REDIRECT : icons.CHECK;
const enableTitle = redirect
? translate('EnabledRedirected')
: translate('Enabled');
return (
<Component className={className} {...otherProps}>
{
<Icon
className={styles.statusIcon}
kind={enabled ? enableKind : kinds.DEFAULT}
name={enabled ? enableIcon : icons.BLOCKLIST}
title={enabled ? enableTitle : translate('Disabled')}
/>
}
<Icon
className={styles.statusIcon}
kind={getIconKind(enabled, redirect)}
name={getIconName(enabled, redirect)}
title={getIconTooltip(enabled, redirect)}
/>
{status ? (
<Popover
className={styles.indexerStatusTooltip}

View File

@@ -11,3 +11,7 @@
border-color: var(--usenetColor);
background-color: var(--usenetColor);
}
.unknown {
composes: label from '~Components/Label.css';
}

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'torrent': string;
'unknown': string;
'usenet': string;
}
export const cssExports: CssExports;

View File

@@ -1,18 +1,15 @@
import React from 'react';
import Label from 'Components/Label';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import styles from './ProtocolLabel.css';
interface ProtocolLabelProps {
protocol: string;
protocol: DownloadProtocol;
}
function ProtocolLabel(props: ProtocolLabelProps) {
const { protocol } = props;
function ProtocolLabel({ protocol }: ProtocolLabelProps) {
const protocolName = protocol === 'usenet' ? 'nzb' : protocol;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(7053)
return <Label className={styles[protocol]}>{protocolName}</Label>;
}

View File

@@ -1,4 +1,5 @@
import ModelBase from 'App/ModelBase';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
export interface IndexerStatus extends ModelBase {
disabledTill: Date;
@@ -38,6 +39,7 @@ export interface IndexerField extends ModelBase {
interface Indexer extends ModelBase {
name: string;
definitionName: string;
description: string;
encoding: string;
language: string;
@@ -48,7 +50,7 @@ interface Indexer extends ModelBase {
supportsSearch: boolean;
supportsRedirect: boolean;
supportsPagination: boolean;
protocol: string;
protocol: DownloadProtocol;
privacy: IndexerPrivacy;
priority: number;
fields: IndexerField[];

View File

@@ -83,8 +83,8 @@ function IndexerHistoryRow(props: IndexerHistoryRowProps) {
<TableRowCell className={styles.details}>
<IconButton
name={icons.INFO}
onPress={onDetailsModalPress}
title={translate('HistoryDetails')}
onPress={onDetailsModalPress}
/>
</TableRowCell>

View File

@@ -1,4 +1,6 @@
.message {
composes: alert from '~Components/Alert.css';
margin-top: 10px;
margin-bottom: 30px;
text-align: center;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
@@ -14,11 +15,9 @@ function NoIndexer(props: NoIndexerProps) {
if (totalItems > 0) {
return (
<div>
<div className={styles.message}>
{translate('AllIndexersHiddenDueToFilter')}
</div>
</div>
<Alert kind={kinds.WARNING} className={styles.message}>
{translate('AllIndexersHiddenDueToFilter')}
</Alert>
);
}
@@ -29,7 +28,7 @@ function NoIndexer(props: NoIndexerProps) {
</div>
<div className={styles.buttonContainer}>
<Button onPress={onAddIndexerPress} kind={kinds.PRIMARY}>
<Button kind={kinds.PRIMARY} onPress={onAddIndexerPress}>
{translate('AddNewIndexer')}
</Button>
</div>

View File

@@ -54,18 +54,20 @@ function getAverageResponseTimeData(indexerStats: IndexerStatsIndexer[]) {
}
function getFailureRateData(indexerStats: IndexerStatsIndexer[]) {
const data = indexerStats.map((indexer) => ({
label: indexer.indexerName,
value:
(indexer.numberOfFailedQueries +
indexer.numberOfFailedRssQueries +
indexer.numberOfFailedAuthQueries +
indexer.numberOfFailedGrabs) /
(indexer.numberOfQueries +
indexer.numberOfRssQueries +
indexer.numberOfAuthQueries +
indexer.numberOfGrabs),
}));
const data = [...indexerStats]
.map((indexer) => ({
label: indexer.indexerName,
value:
(indexer.numberOfFailedQueries +
indexer.numberOfFailedRssQueries +
indexer.numberOfFailedAuthQueries +
indexer.numberOfFailedGrabs) /
(indexer.numberOfQueries +
indexer.numberOfRssQueries +
indexer.numberOfAuthQueries +
indexer.numberOfGrabs),
}))
.filter((s) => s.value > 0);
data.sort((a, b) => b.value - a.value);
@@ -73,11 +75,20 @@ function getFailureRateData(indexerStats: IndexerStatsIndexer[]) {
}
function getTotalRequestsData(indexerStats: IndexerStatsIndexer[]) {
const statistics = [...indexerStats].sort((a, b) =>
a.numberOfQueries === b.numberOfQueries
? b.numberOfRssQueries - a.numberOfRssQueries
: b.numberOfQueries - a.numberOfQueries
);
const statistics = [...indexerStats]
.filter(
(s) =>
s.numberOfQueries > 0 ||
s.numberOfRssQueries > 0 ||
s.numberOfAuthQueries > 0
)
.sort(
(a, b) =>
b.numberOfQueries +
b.numberOfRssQueries +
b.numberOfAuthQueries -
(a.numberOfQueries + a.numberOfRssQueries + a.numberOfAuthQueries)
);
return {
labels: statistics.map((indexer) => indexer.indexerName),
@@ -99,10 +110,12 @@ function getTotalRequestsData(indexerStats: IndexerStatsIndexer[]) {
}
function getNumberGrabsData(indexerStats: IndexerStatsIndexer[]) {
const data = indexerStats.map((indexer) => ({
label: indexer.indexerName,
value: indexer.numberOfGrabs - indexer.numberOfFailedGrabs,
}));
const data = [...indexerStats]
.map((indexer) => ({
label: indexer.indexerName,
value: indexer.numberOfGrabs - indexer.numberOfFailedGrabs,
}))
.filter((s) => s.value > 0);
data.sort((a, b) => b.value - a.value);
@@ -228,9 +241,9 @@ function IndexerStats() {
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
onFilterSelect={onFilterSelect}
filterModalConnectorComponent={IndexerStatsFilterModal}
isDisabled={false}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>

View File

@@ -1,4 +1,6 @@
.message {
composes: alert from '~Components/Alert.css';
margin-top: 10px;
margin-bottom: 30px;
text-align: center;

View File

@@ -1,4 +1,6 @@
import React from 'react';
import Alert from 'Components/Alert';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './NoSearchResults.css';
@@ -11,18 +13,16 @@ function NoSearchResults(props: NoSearchResultsProps) {
if (totalItems > 0) {
return (
<div>
<div className={styles.message}>
{translate('AllIndexersHiddenDueToFilter')}
</div>
</div>
<Alert kind={kinds.WARNING} className={styles.message}>
{translate('AllSearchResultsHiddenByFilter')}
</Alert>
);
}
return (
<div>
<div className={styles.message}>{translate('NoSearchResultsFound')}</div>
</div>
<Alert kind={kinds.INFO} className={styles.message}>
{translate('NoSearchResultsFound')}
</Alert>
);
}

View File

@@ -17,7 +17,7 @@ function SelectDownloadClientModal(props: SelectDownloadClientModalProps) {
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={onModalClose}>
<SelectDownloadClientModalContent
protocol={protocol}
modalTitle={modalTitle}

View File

@@ -282,7 +282,7 @@ class SearchIndex extends Component {
const ViewComponent = getViewComponent(isSmallScreen);
const isLoaded = !!(!error && isPopulated && items.length && this.scrollerRef.current);
const hasNoIndexer = !totalItems;
const hasNoSearchResults = !totalItems;
return (
<PageContent title={translate('Search')}>
@@ -306,7 +306,7 @@ class SearchIndex extends Component {
<SearchIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoIndexer}
isDisabled={hasNoSearchResults}
onSortSelect={onSortSelect}
/>
@@ -314,7 +314,7 @@ class SearchIndex extends Component {
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoIndexer}
isDisabled={hasNoSearchResults}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>

View File

@@ -184,9 +184,9 @@ function SearchIndexRow(props: SearchIndexRowProps) {
if (name === 'select') {
return (
<VirtualTableSelectCell
key={name}
inputClassName={styles.checkInput}
id={guid}
key={name}
isSelected={isSelected}
isDisabled={false}
onSelectedChange={onSelectedChange}

View File

@@ -1,4 +1,4 @@
import React, { Fragment, useCallback, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { APP_INDEXER_SYNC } from 'Commands/commandNames';
@@ -56,7 +56,7 @@ function ApplicationSettings() {
// @ts-ignore
showSave={false}
additionalButtons={
<Fragment>
<>
<PageToolbarSeparator />
<PageToolbarButton
@@ -78,7 +78,7 @@ function ApplicationSettings() {
iconName={icons.MANAGE}
onPress={onManageApplicationsPress}
/>
</Fragment>
</>
}
/>

View File

@@ -57,6 +57,7 @@ class Application extends Component {
const {
id,
name,
enable,
syncLevel,
fields,
tags,
@@ -77,7 +78,7 @@ class Application extends Component {
</div>
{
applicationUrl ?
enable && applicationUrl ?
<IconButton
className={styles.externalLink}
name={icons.EXTERNAL_LINK}
@@ -140,6 +141,7 @@ class Application extends Component {
Application.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
enable: PropTypes.bool.isRequired,
syncLevel: PropTypes.string.isRequired,
fields: PropTypes.arrayOf(PropTypes.object).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,

View File

@@ -213,9 +213,9 @@ function ManageApplicationsModalContent(
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
sortKey={sortKey}
sortDirection={sortDirection}
onSelectAllChange={onSelectAllChange}
onSortPress={onSortPress}
>
<TableBody>
@@ -268,9 +268,9 @@ function ManageApplicationsModalContent(
<ManageApplicationsEditModal
isOpen={isEditModalOpen}
applicationIds={selectedIds}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
applicationIds={selectedIds}
/>
<TagsModal

View File

@@ -186,9 +186,9 @@ function ManageDownloadClientsModalContent(
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
sortKey={sortKey}
sortDirection={sortDirection}
onSelectAllChange={onSelectAllChange}
onSortPress={onSortPress}
>
<TableBody>
@@ -233,9 +233,9 @@ function ManageDownloadClientsModalContent(
<ManageDownloadClientsEditModal
isOpen={isEditModalOpen}
downloadClientIds={selectedIds}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
downloadClientIds={selectedIds}
/>
<ConfirmModal

View File

@@ -156,6 +156,7 @@ class GeneralSettings extends Component {
/>
<LoggingSettings
advancedSettings={advancedSettings}
settings={settings}
onInputChange={onInputChange}
/>

View File

@@ -15,12 +15,14 @@ const logLevelOptions = [
function LoggingSettings(props) {
const {
advancedSettings,
settings,
onInputChange
} = props;
const {
logLevel
logLevel,
logSizeLimit
} = settings;
return (
@@ -37,11 +39,30 @@ function LoggingSettings(props) {
{...logLevel}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('LogSizeLimit')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="logSizeLimit"
min={1}
max={10}
unit="MB"
helpText={translate('LogSizeLimitHelpText')}
onChange={onInputChange}
{...logSizeLimit}
/>
</FormGroup>
</FieldSet>
);
}
LoggingSettings.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
settings: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired
};

View File

@@ -3,9 +3,13 @@ import { createAction } from 'redux-actions';
import { filterTypePredicates, sortDirections } from 'Helpers/Props';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createSaveProviderHandler, {
createCancelSaveProviderHandler
} from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
import createTestProviderHandler, {
createCancelTestProviderHandler
} from 'Store/Actions/Creators/createTestProviderHandler';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk, handleThunks } from 'Store/thunks';
@@ -16,6 +20,7 @@ import translate from 'Utilities/String/translate';
import createBulkEditItemHandler from './Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from './Creators/createBulkRemoveItemHandler';
import createHandleActions from './Creators/createHandleActions';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
//
@@ -95,11 +100,22 @@ export const filterPredicates = {
};
export const sortPredicates = {
vipExpiration: function(item) {
const vipExpiration =
item.fields.find((field) => field.name === 'vipExpiration')?.value ?? '';
status: function({ enable, redirect }) {
let result = 0;
return vipExpiration;
if (redirect) {
result++;
}
if (enable) {
result += 2;
}
return result;
},
vipExpiration: function({ fields = [] }) {
return fields.find((field) => field.name === 'vipExpiration')?.value ?? '';
}
};
@@ -110,6 +126,7 @@ export const FETCH_INDEXERS = 'indexers/fetchIndexers';
export const FETCH_INDEXER_SCHEMA = 'indexers/fetchIndexerSchema';
export const SELECT_INDEXER_SCHEMA = 'indexers/selectIndexerSchema';
export const SET_INDEXER_SCHEMA_SORT = 'indexers/setIndexerSchemaSort';
export const CLEAR_INDEXER_SCHEMA = 'indexers/clearIndexerSchema';
export const CLONE_INDEXER = 'indexers/cloneIndexer';
export const SET_INDEXER_VALUE = 'indexers/setIndexerValue';
export const SET_INDEXER_FIELD_VALUE = 'indexers/setIndexerFieldValue';
@@ -129,6 +146,7 @@ export const fetchIndexers = createThunk(FETCH_INDEXERS);
export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA);
export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA);
export const setIndexerSchemaSort = createAction(SET_INDEXER_SCHEMA_SORT);
export const clearIndexerSchema = createAction(CLEAR_INDEXER_SCHEMA);
export const cloneIndexer = createAction(CLONE_INDEXER);
export const saveIndexer = createThunk(SAVE_INDEXER);
@@ -214,6 +232,8 @@ export const reducers = createHandleActions({
});
},
[CLEAR_INDEXER_SCHEMA]: createClearReducer(schemaSection, defaultState),
[CLONE_INDEXER]: function(state, { payload }) {
const id = payload.id;
const newState = getSectionState(state, section);

View File

@@ -110,7 +110,6 @@ export const defaultState = {
{
name: 'actions',
columnLabel: () => translate('Actions'),
isSortable: true,
isVisible: true,
isModifiable: false
}

View File

@@ -1,128 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import FieldSet from 'Components/FieldSet';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import StartTime from './StartTime';
import styles from './About.css';
class About extends Component {
//
// Render
render() {
const {
version,
packageVersion,
packageAuthor,
isNetCore,
isDocker,
runtimeVersion,
databaseVersion,
databaseType,
migrationVersion,
appData,
startupPath,
mode,
startTime,
timeFormat,
longDateFormat
} = this.props;
return (
<FieldSet legend={translate('About')}>
<DescriptionList className={styles.descriptionList}>
<DescriptionListItem
title={translate('Version')}
data={version}
/>
{
packageVersion &&
<DescriptionListItem
title={translate('PackageVersion')}
data={(packageAuthor ? <span> {packageVersion} {' by '} <InlineMarkdown data={packageAuthor} /> </span> : packageVersion)}
/>
}
{
isNetCore &&
<DescriptionListItem
title={translate('NetCore')}
data={`Yes (${runtimeVersion})`}
/>
}
{
isDocker &&
<DescriptionListItem
title={translate('Docker')}
data={'Yes'}
/>
}
<DescriptionListItem
title={translate('Database')}
data={`${titleCase(databaseType)} ${databaseVersion}`}
/>
<DescriptionListItem
title={translate('DatabaseMigration')}
data={migrationVersion}
/>
<DescriptionListItem
title={translate('AppDataDirectory')}
data={appData}
/>
<DescriptionListItem
title={translate('StartupDirectory')}
data={startupPath}
/>
<DescriptionListItem
title={translate('Mode')}
data={titleCase(mode)}
/>
<DescriptionListItem
title={translate('Uptime')}
data={
<StartTime
startTime={startTime}
timeFormat={timeFormat}
longDateFormat={longDateFormat}
/>
}
/>
</DescriptionList>
</FieldSet>
);
}
}
About.propTypes = {
version: PropTypes.string.isRequired,
packageVersion: PropTypes.string,
packageAuthor: PropTypes.string,
isNetCore: PropTypes.bool.isRequired,
runtimeVersion: PropTypes.string.isRequired,
isDocker: PropTypes.bool.isRequired,
databaseType: PropTypes.string.isRequired,
databaseVersion: PropTypes.string.isRequired,
migrationVersion: PropTypes.number.isRequired,
appData: PropTypes.string.isRequired,
startupPath: PropTypes.string.isRequired,
mode: PropTypes.string.isRequired,
startTime: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired
};
export default About;

View File

@@ -0,0 +1,103 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import FieldSet from 'Components/FieldSet';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import { fetchStatus } from 'Store/Actions/systemActions';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import StartTime from './StartTime';
import styles from './About.css';
function About() {
const dispatch = useDispatch();
const { item } = useSelector((state: AppState) => state.system.status);
const {
version,
packageVersion,
packageAuthor,
isNetCore,
isDocker,
runtimeVersion,
databaseVersion,
databaseType,
migrationVersion,
appData,
startupPath,
mode,
startTime,
} = item;
useEffect(() => {
dispatch(fetchStatus());
}, [dispatch]);
return (
<FieldSet legend={translate('About')}>
<DescriptionList className={styles.descriptionList}>
<DescriptionListItem title={translate('Version')} data={version} />
{packageVersion && (
<DescriptionListItem
title={translate('PackageVersion')}
data={
packageAuthor ? (
<InlineMarkdown
data={translate('PackageVersionInfo', {
packageVersion,
packageAuthor,
})}
/>
) : (
packageVersion
)
}
/>
)}
{isNetCore ? (
<DescriptionListItem
title={translate('NetCore')}
data={`Yes (${runtimeVersion})`}
/>
) : null}
{isDocker ? (
<DescriptionListItem title={translate('Docker')} data="Yes" />
) : null}
<DescriptionListItem
title={translate('Database')}
data={`${titleCase(databaseType)} ${databaseVersion}`}
/>
<DescriptionListItem
title={translate('DatabaseMigration')}
data={migrationVersion}
/>
<DescriptionListItem
title={translate('AppDataDirectory')}
data={appData}
/>
<DescriptionListItem
title={translate('StartupDirectory')}
data={startupPath}
/>
<DescriptionListItem title={translate('Mode')} data={titleCase(mode)} />
<DescriptionListItem
title={translate('Uptime')}
data={<StartTime startTime={startTime} />}
/>
</DescriptionList>
</FieldSet>
);
}
export default About;

View File

@@ -1,52 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchStatus } from 'Store/Actions/systemActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import About from './About';
function createMapStateToProps() {
return createSelector(
(state) => state.system.status,
createUISettingsSelector(),
(status, uiSettings) => {
return {
...status.item,
timeFormat: uiSettings.timeFormat,
longDateFormat: uiSettings.longDateFormat
};
}
);
}
const mapDispatchToProps = {
fetchStatus
};
class AboutConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchStatus();
}
//
// Render
render() {
return (
<About
{...this.props}
/>
);
}
}
AboutConnector.propTypes = {
fetchStatus: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AboutConnector);

View File

@@ -1,93 +0,0 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
function getUptime(startTime) {
return formatTimeSpan(moment().diff(startTime));
}
class StartTime extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
startTime,
timeFormat,
longDateFormat
} = props;
this._timeoutId = null;
this.state = {
uptime: getUptime(startTime),
startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
};
}
componentDidMount() {
this._timeoutId = setTimeout(this.onTimeout, 1000);
}
componentDidUpdate(prevProps) {
const {
startTime,
timeFormat,
longDateFormat
} = this.props;
if (
startTime !== prevProps.startTime ||
timeFormat !== prevProps.timeFormat ||
longDateFormat !== prevProps.longDateFormat
) {
this.setState({
uptime: getUptime(startTime),
startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
});
}
}
componentWillUnmount() {
if (this._timeoutId) {
this._timeoutId = clearTimeout(this._timeoutId);
}
}
//
// Listeners
onTimeout = () => {
this.setState({ uptime: getUptime(this.props.startTime) });
this._timeoutId = setTimeout(this.onTimeout, 1000);
};
//
// Render
render() {
const {
uptime,
startTime
} = this.state;
return (
<span title={startTime}>
{uptime}
</span>
);
}
}
StartTime.propTypes = {
startTime: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired
};
export default StartTime;

View File

@@ -0,0 +1,44 @@
import moment from 'moment';
import React, { useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
interface StartTimeProps {
startTime: string;
}
function StartTime(props: StartTimeProps) {
const { startTime } = props;
const { timeFormat, longDateFormat } = useSelector(
createUISettingsSelector()
);
const [time, setTime] = useState(Date.now());
const { formattedStartTime, uptime } = useMemo(() => {
return {
uptime: formatTimeSpan(moment(time).diff(startTime)),
formattedStartTime: formatDateTime(
startTime,
longDateFormat,
timeFormat,
{
includeSeconds: true,
}
),
};
}, [startTime, time, longDateFormat, timeFormat]);
useEffect(() => {
const interval = setInterval(() => setTime(Date.now()), 1000);
return () => {
clearInterval(interval);
};
}, [setTime]);
return <span title={formattedStartTime}>{uptime}</span>;
}
export default StartTime;

View File

@@ -1,64 +0,0 @@
import React, { Component } from 'react';
import FieldSet from 'Components/FieldSet';
import Link from 'Components/Link/Link';
import translate from 'Utilities/String/translate';
import styles from '../styles.css';
class Donations extends Component {
//
// Render
render() {
return (
<FieldSet legend={translate('Donations')}>
<div className={styles.logoContainer} title="Radarr">
<Link to="https://radarr.video/donate">
<img
className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-radarr.png`}
/>
</Link>
</div>
<div className={styles.logoContainer} title="Lidarr">
<Link to="https://lidarr.audio/donate">
<img
className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-lidarr.png`}
/>
</Link>
</div>
<div className={styles.logoContainer} title="Readarr">
<Link to="https://readarr.com/donate">
<img
className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-readarr.png`}
/>
</Link>
</div>
<div className={styles.logoContainer} title="Prowlarr">
<Link to="https://prowlarr.com/donate">
<img
className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-prowlarr.png`}
/>
</Link>
</div>
<div className={styles.logoContainer} title="Sonarr">
<Link to="https://opencollective.com/sonarr">
<img
className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-sonarr.png`}
/>
</Link>
</div>
</FieldSet>
);
}
}
Donations.propTypes = {
};
export default Donations;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import FieldSet from 'Components/FieldSet';
import Link from 'Components/Link/Link';
import translate from 'Utilities/String/translate';
import styles from '../styles.css';
function Donations() {
return (
<FieldSet legend={translate('Donations')}>
<div className={styles.logoContainer} title="Radarr">
<Link to="https://radarr.video/donate">
<img
className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-radarr.png`}
/>
</Link>
</div>
<div className={styles.logoContainer} title="Lidarr">
<Link to="https://lidarr.audio/donate">
<img
className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-lidarr.png`}
/>
</Link>
</div>
<div className={styles.logoContainer} title="Readarr">
<Link to="https://readarr.com/donate">
<img
className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-readarr.png`}
/>
</Link>
</div>
<div className={styles.logoContainer} title="Prowlarr">
<Link to="https://prowlarr.com/donate">
<img
className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-prowlarr.png`}
/>
</Link>
</div>
<div className={styles.logoContainer} title="Sonarr">
<Link to="https://opencollective.com/sonarr">
<img
className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-sonarr.png`}
/>
</Link>
</div>
</FieldSet>
);
}
export default Donations;

View File

@@ -1,250 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import styles from './Health.css';
function getInternalLink(source) {
switch (source) {
case 'ApplicationStatusCheck':
case 'ApplicationLongTermStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/applications"
/>
);
case 'DownloadClientStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/downloadclients"
/>
);
case 'NotificationStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/connect"
/>
);
case 'IndexerProxyStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/indexers"
/>
);
case 'IndexerRssCheck':
case 'IndexerSearchCheck':
case 'IndexerStatusCheck':
case 'IndexerLongTermStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/"
/>
);
case 'UpdateCheck':
return (
<IconButton
name={icons.UPDATE}
title={translate('Updates')}
to="/system/updates"
/>
);
default:
return;
}
}
function getTestLink(source, props) {
switch (source) {
case 'ApplicationStatusCheck':
case 'ApplicationLongTermStatusCheck':
return (
<SpinnerIconButton
name={icons.TEST}
title={translate('TestAll')}
isSpinning={props.isTestingAllApplications}
onPress={props.dispatchTestAllApplications}
/>
);
case 'DownloadClientStatusCheck':
return (
<SpinnerIconButton
name={icons.TEST}
title={translate('TestAll')}
isSpinning={props.isTestingAllDownloadClients}
onPress={props.dispatchTestAllDownloadClients}
/>
);
case 'IndexerStatusCheck':
case 'IndexerLongTermStatusCheck':
return (
<SpinnerIconButton
name={icons.TEST}
title={translate('TestAll')}
isSpinning={props.isTestingAllIndexers}
onPress={props.dispatchTestAllIndexers}
/>
);
default:
break;
}
}
const columns = [
{
className: styles.status,
name: 'type',
isVisible: true
},
{
name: 'message',
label: () => translate('Message'),
isVisible: true
},
{
name: 'actions',
label: () => translate('Actions'),
isVisible: true
}
];
class Health extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
items
} = this.props;
const healthIssues = !!items.length;
return (
<FieldSet
legend={
<div className={styles.legend}>
{translate('Health')}
{
isFetching && isPopulated &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
</div>
}
>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!healthIssues &&
<div className={styles.healthOk}>
{translate('HealthNoIssues')}
</div>
}
{
healthIssues &&
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
const internalLink = getInternalLink(item.source);
const testLink = getTestLink(item.source, this.props);
let kind = kinds.WARNING;
switch (item.type.toLowerCase()) {
case 'error':
kind = kinds.DANGER;
break;
default:
case 'warning':
kind = kinds.WARNING;
break;
case 'notice':
kind = kinds.INFO;
break;
}
return (
<TableRow key={`health${item.message}`}>
<TableRowCell>
<Icon
name={icons.DANGER}
kind={kind}
title={titleCase(item.type)}
/>
</TableRowCell>
<TableRowCell>{item.message}</TableRowCell>
<TableRowCell className={styles.actions}>
<IconButton
name={icons.WIKI}
to={item.wikiUrl}
title={translate('ReadTheWikiForMoreInformation')}
/>
{
internalLink
}
{
!!testLink &&
testLink
}
</TableRowCell>
</TableRow>
);
})
}
</TableBody>
</Table>
}
</FieldSet>
);
}
}
Health.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
items: PropTypes.array.isRequired,
isTestingAllApplications: PropTypes.bool.isRequired,
isTestingAllDownloadClients: PropTypes.bool.isRequired,
isTestingAllIndexers: PropTypes.bool.isRequired,
dispatchTestAllApplications: PropTypes.func.isRequired,
dispatchTestAllDownloadClients: PropTypes.func.isRequired,
dispatchTestAllIndexers: PropTypes.func.isRequired
};
export default Health;

View File

@@ -0,0 +1,191 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
import { testAllIndexers } from 'Store/Actions/indexerActions';
import {
testAllApplications,
testAllDownloadClients,
} from 'Store/Actions/settingsActions';
import { fetchHealth } from 'Store/Actions/systemActions';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import createHealthSelector from './createHealthSelector';
import HealthItemLink from './HealthItemLink';
import styles from './Health.css';
const columns: Column[] = [
{
className: styles.status,
name: 'type',
label: '',
isVisible: true,
},
{
name: 'message',
label: () => translate('Message'),
isVisible: true,
},
{
name: 'actions',
label: () => translate('Actions'),
isVisible: true,
},
];
function Health() {
const dispatch = useDispatch();
const { isFetching, isPopulated, items } = useSelector(
createHealthSelector()
);
const isTestingAllApplications = useSelector(
(state: AppState) => state.settings.applications.isTestingAll
);
const isTestingAllDownloadClients = useSelector(
(state: AppState) => state.settings.downloadClients.isTestingAll
);
const isTestingAllIndexers = useSelector(
(state: AppState) => state.indexers.isTestingAll
);
const healthIssues = !!items.length;
const handleTestAllApplicationsPress = useCallback(() => {
dispatch(testAllApplications());
}, [dispatch]);
const handleTestAllDownloadClientsPress = useCallback(() => {
dispatch(testAllDownloadClients());
}, [dispatch]);
const handleTestAllIndexersPress = useCallback(() => {
dispatch(testAllIndexers());
}, [dispatch]);
useEffect(() => {
dispatch(fetchHealth());
}, [dispatch]);
return (
<FieldSet
legend={
<div className={styles.legend}>
{translate('Health')}
{isFetching && isPopulated ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
</div>
}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isPopulated && !healthIssues ? (
<div className={styles.healthOk}>
{translate('NoIssuesWithYourConfiguration')}
</div>
) : null}
{healthIssues ? (
<>
<Table columns={columns}>
<TableBody>
{items.map((item) => {
const source = item.source;
let kind = kinds.WARNING;
switch (item.type.toLowerCase()) {
case 'error':
kind = kinds.DANGER;
break;
default:
case 'warning':
kind = kinds.WARNING;
break;
case 'notice':
kind = kinds.INFO;
break;
}
return (
<TableRow key={`health${item.message}`}>
<TableRowCell>
<Icon
name={icons.DANGER}
kind={kind}
title={titleCase(item.type)}
/>
</TableRowCell>
<TableRowCell>{item.message}</TableRowCell>
<TableRowCell>
<IconButton
name={icons.WIKI}
to={item.wikiUrl}
title={translate('ReadTheWikiForMoreInformation')}
/>
<HealthItemLink source={source} />
{source === 'ApplicationStatusCheck' ||
source === 'ApplicationLongTermStatusCheck' ? (
<SpinnerIconButton
name={icons.TEST}
title={translate('TestAll')}
isSpinning={isTestingAllApplications}
onPress={handleTestAllApplicationsPress}
/>
) : null}
{source === 'IndexerStatusCheck' ||
source === 'IndexerLongTermStatusCheck' ? (
<SpinnerIconButton
name={icons.TEST}
title={translate('TestAll')}
isSpinning={isTestingAllIndexers}
onPress={handleTestAllIndexersPress}
/>
) : null}
{source === 'DownloadClientStatusCheck' ? (
<SpinnerIconButton
name={icons.TEST}
title={translate('TestAll')}
isSpinning={isTestingAllDownloadClients}
onPress={handleTestAllDownloadClientsPress}
/>
) : null}
</TableRowCell>
</TableRow>
);
})}
</TableBody>
</Table>
<Alert kind={kinds.INFO}>
<InlineMarkdown
data={translate('HealthMessagesInfoBox', {
link: '/system/logs/files',
})}
/>
</Alert>
</>
) : null}
</FieldSet>
);
}
export default Health;

View File

@@ -1,74 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { testAllIndexers } from 'Store/Actions/indexerActions';
import { testAllApplications } from 'Store/Actions/Settings/applications';
import { testAllDownloadClients } from 'Store/Actions/Settings/downloadClients';
import { fetchHealth } from 'Store/Actions/systemActions';
import createHealthCheckSelector from 'Store/Selectors/createHealthCheckSelector';
import Health from './Health';
function createMapStateToProps() {
return createSelector(
createHealthCheckSelector(),
(state) => state.system.health,
(state) => state.settings.applications.isTestingAll,
(state) => state.settings.downloadClients.isTestingAll,
(state) => state.indexers.isTestingAll,
(items, health, isTestingAllApplications, isTestingAllDownloadClients, isTestingAllIndexers) => {
const {
isFetching,
isPopulated
} = health;
return {
isFetching,
isPopulated,
items,
isTestingAllApplications,
isTestingAllDownloadClients,
isTestingAllIndexers
};
}
);
}
const mapDispatchToProps = {
dispatchFetchHealth: fetchHealth,
dispatchTestAllApplications: testAllApplications,
dispatchTestAllDownloadClients: testAllDownloadClients,
dispatchTestAllIndexers: testAllIndexers
};
class HealthConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchHealth();
}
//
// Render
render() {
const {
dispatchFetchHealth,
...otherProps
} = this.props;
return (
<Health
{...otherProps}
/>
);
}
}
HealthConnector.propTypes = {
dispatchFetchHealth: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(HealthConnector);

View File

@@ -0,0 +1,71 @@
import React from 'react';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
interface HealthItemLinkProps {
source: string;
}
function HealthItemLink(props: HealthItemLinkProps) {
const { source } = props;
switch (source) {
case 'ApplicationStatusCheck':
case 'ApplicationLongTermStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/applications"
/>
);
case 'DownloadClientStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/downloadclients"
/>
);
case 'NotificationStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/connect"
/>
);
case 'IndexerProxyStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/indexers"
/>
);
case 'IndexerRssCheck':
case 'IndexerSearchCheck':
case 'IndexerStatusCheck':
case 'IndexerLongTermStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/"
/>
);
case 'UpdateCheck':
return (
<IconButton
name={icons.UPDATE}
title={translate('Updates')}
to="/system/updates"
/>
);
default:
return null;
}
}
export default HealthItemLink;

View File

@@ -0,0 +1,56 @@
import React, { useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { fetchHealth } from 'Store/Actions/systemActions';
import createHealthSelector from './createHealthSelector';
function HealthStatus() {
const dispatch = useDispatch();
const { isConnected, isReconnecting } = useSelector(
(state: AppState) => state.app
);
const { isPopulated, items } = useSelector(createHealthSelector());
const wasReconnecting = usePrevious(isReconnecting);
const { count, errors, warnings } = useMemo(() => {
let errors = false;
let warnings = false;
items.forEach((item) => {
if (item.type === 'error') {
errors = true;
}
if (item.type === 'warning') {
warnings = true;
}
});
return {
count: items.length,
errors,
warnings,
};
}, [items]);
useEffect(() => {
if (!isPopulated) {
dispatch(fetchHealth());
}
}, [isPopulated, dispatch]);
useEffect(() => {
if (isConnected && wasReconnecting) {
dispatch(fetchHealth());
}
}, [isConnected, wasReconnecting, dispatch]);
return (
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
);
}
export default HealthStatus;

View File

@@ -1,81 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
import { fetchHealth } from 'Store/Actions/systemActions';
import createHealthCheckSelector from 'Store/Selectors/createHealthCheckSelector';
function createMapStateToProps() {
return createSelector(
(state) => state.app,
createHealthCheckSelector(),
(state) => state.system.health,
(app, items, health) => {
const count = items.length;
let errors = false;
let warnings = false;
items.forEach((item) => {
if (item.type === 'error') {
errors = true;
}
if (item.type === 'warning') {
warnings = true;
}
});
return {
isConnected: app.isConnected,
isReconnecting: app.isReconnecting,
isPopulated: health.isPopulated,
count,
errors,
warnings
};
}
);
}
const mapDispatchToProps = {
fetchHealth
};
class HealthStatusConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.isPopulated) {
this.props.fetchHealth();
}
}
componentDidUpdate(prevProps) {
if (this.props.isConnected && prevProps.isReconnecting) {
this.props.fetchHealth();
}
}
//
// Render
render() {
return (
<PageSidebarStatus
{...this.props}
/>
);
}
}
HealthStatusConnector.propTypes = {
isConnected: PropTypes.bool.isRequired,
isReconnecting: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
fetchHealth: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(HealthStatusConnector);

View File

@@ -0,0 +1,13 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createHealthSelector() {
return createSelector(
(state: AppState) => state.system.health,
(health) => {
return health;
}
);
}
export default createHealthSelector;

View File

@@ -1,58 +0,0 @@
import React, { Component } from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import FieldSet from 'Components/FieldSet';
import Link from 'Components/Link/Link';
import translate from 'Utilities/String/translate';
class MoreInfo extends Component {
//
// Render
render() {
return (
<FieldSet legend={translate('MoreInfo')}>
<DescriptionList>
<DescriptionListItemTitle>{translate('HomePage')}</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://prowlarr.com/">prowlarr.com</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>{translate('Wiki')}</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://wiki.servarr.com/prowlarr">wiki.servarr.com/prowlarr</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>{translate('Reddit')}</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://reddit.com/r/prowlarr">r/prowlarr</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>{translate('Discord')}</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://prowlarr.com/discord">prowlarr.com/discord</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>{translate('Source')}</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://github.com/Prowlarr/Prowlarr/">github.com/Prowlarr/Prowlarr</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>{translate('FeatureRequests')}</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://github.com/Prowlarr/Prowlarr/issues">github.com/Prowlarr/Prowlarr/issues</Link>
</DescriptionListItemDescription>
</DescriptionList>
</FieldSet>
);
}
}
MoreInfo.propTypes = {
};
export default MoreInfo;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import FieldSet from 'Components/FieldSet';
import Link from 'Components/Link/Link';
import translate from 'Utilities/String/translate';
function MoreInfo() {
return (
<FieldSet legend={translate('MoreInfo')}>
<DescriptionList>
<DescriptionListItemTitle>
{translate('HomePage')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://prowlarr.com/">prowlarr.com</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>{translate('Wiki')}</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://wiki.servarr.com/prowlarr">
wiki.servarr.com/prowlarr
</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Reddit')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://reddit.com/r/prowlarr">r/prowlarr</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Discord')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://prowlarr.com/discord">prowlarr.com/discord</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Source')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://github.com/Prowlarr/Prowlarr/">
github.com/Prowlarr/Prowlarr
</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('FeatureRequests')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://github.com/Prowlarr/Prowlarr/issues">
github.com/Prowlarr/Prowlarr/issues
</Link>
</DescriptionListItemDescription>
</DescriptionList>
</FieldSet>
);
}
export default MoreInfo;

View File

@@ -1,30 +0,0 @@
import React, { Component } from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import translate from 'Utilities/String/translate';
import AboutConnector from './About/AboutConnector';
import Donations from './Donations/Donations';
import HealthConnector from './Health/HealthConnector';
import MoreInfo from './MoreInfo/MoreInfo';
class Status extends Component {
//
// Render
render() {
return (
<PageContent title={translate('Status')}>
<PageContentBody>
<HealthConnector />
<AboutConnector />
<MoreInfo />
<Donations />
</PageContentBody>
</PageContent>
);
}
}
export default Status;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import translate from 'Utilities/String/translate';
import About from './About/About';
import Donations from './Donations/Donations';
import Health from './Health/Health';
import MoreInfo from './MoreInfo/MoreInfo';
function Status() {
return (
<PageContent title={translate('Status')}>
<PageContentBody>
<Health />
<About />
<MoreInfo />
<Donations />
</PageContentBody>
</PageContent>
);
}
export default Status;

View File

@@ -1,203 +0,0 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons } from 'Helpers/Props';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import styles from './ScheduledTaskRow.css';
function getFormattedDates(props) {
const {
lastExecution,
nextExecution,
interval,
showRelativeDates,
shortDateFormat
} = props;
const isDisabled = interval === 0;
if (showRelativeDates) {
return {
lastExecutionTime: moment(lastExecution).fromNow(),
nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow()
};
}
return {
lastExecutionTime: formatDate(lastExecution, shortDateFormat),
nextExecutionTime: isDisabled ? '-' : formatDate(nextExecution, shortDateFormat)
};
}
class ScheduledTaskRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = getFormattedDates(props);
this._updateTimeoutId = null;
}
componentDidMount() {
this.setUpdateTimer();
}
componentDidUpdate(prevProps) {
const {
lastExecution,
nextExecution
} = this.props;
if (
lastExecution !== prevProps.lastExecution ||
nextExecution !== prevProps.nextExecution
) {
this.setState(getFormattedDates(this.props));
}
}
componentWillUnmount() {
if (this._updateTimeoutId) {
this._updateTimeoutId = clearTimeout(this._updateTimeoutId);
}
}
//
// Listeners
setUpdateTimer() {
const { interval } = this.props;
const timeout = interval < 60 ? 10000 : 60000;
this._updateTimeoutId = setTimeout(() => {
this.setState(getFormattedDates(this.props));
this.setUpdateTimer();
}, timeout);
}
//
// Render
render() {
const {
name,
interval,
lastExecution,
lastStartTime,
lastDuration,
nextExecution,
isQueued,
isExecuting,
longDateFormat,
timeFormat,
onExecutePress
} = this.props;
const {
lastExecutionTime,
nextExecutionTime
} = this.state;
const isDisabled = interval === 0;
const executeNow = !isDisabled && moment().isAfter(nextExecution);
const hasNextExecutionTime = !isDisabled && !executeNow;
const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1');
const hasLastStartTime = moment(lastStartTime).isAfter('2010-01-01');
return (
<TableRow>
<TableRowCell>{name}</TableRowCell>
<TableRowCell
className={styles.interval}
>
{isDisabled ? 'disabled' : duration}
</TableRowCell>
<TableRowCell
className={styles.lastExecution}
title={formatDateTime(lastExecution, longDateFormat, timeFormat)}
>
{lastExecutionTime}
</TableRowCell>
{
!hasLastStartTime &&
<TableRowCell className={styles.lastDuration}>-</TableRowCell>
}
{
hasLastStartTime &&
<TableRowCell
className={styles.lastDuration}
title={lastDuration}
>
{formatTimeSpan(lastDuration)}
</TableRowCell>
}
{
isDisabled &&
<TableRowCell className={styles.nextExecution}>-</TableRowCell>
}
{
executeNow && isQueued &&
<TableRowCell className={styles.nextExecution}>queued</TableRowCell>
}
{
executeNow && !isQueued &&
<TableRowCell className={styles.nextExecution}>now</TableRowCell>
}
{
hasNextExecutionTime &&
<TableRowCell
className={styles.nextExecution}
title={formatDateTime(nextExecution, longDateFormat, timeFormat, { includeSeconds: true })}
>
{nextExecutionTime}
</TableRowCell>
}
<TableRowCell
className={styles.actions}
>
<SpinnerIconButton
name={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isExecuting}
onPress={onExecutePress}
/>
</TableRowCell>
</TableRow>
);
}
}
ScheduledTaskRow.propTypes = {
name: PropTypes.string.isRequired,
interval: PropTypes.number.isRequired,
lastExecution: PropTypes.string.isRequired,
lastStartTime: PropTypes.string.isRequired,
lastDuration: PropTypes.string.isRequired,
nextExecution: PropTypes.string.isRequired,
isQueued: PropTypes.bool.isRequired,
isExecuting: PropTypes.bool.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onExecutePress: PropTypes.func.isRequired
};
export default ScheduledTaskRow;

View File

@@ -0,0 +1,170 @@
import moment from 'moment';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchTask } from 'Store/Actions/systemActions';
import createCommandSelector from 'Store/Selectors/createCommandSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { isCommandExecuting } from 'Utilities/Command';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import styles from './ScheduledTaskRow.css';
interface ScheduledTaskRowProps {
id: number;
taskName: string;
name: string;
interval: number;
lastExecution: string;
lastStartTime: string;
lastDuration: string;
nextExecution: string;
}
function ScheduledTaskRow(props: ScheduledTaskRowProps) {
const {
id,
taskName,
name,
interval,
lastExecution,
lastStartTime,
lastDuration,
nextExecution,
} = props;
const dispatch = useDispatch();
const { showRelativeDates, longDateFormat, shortDateFormat, timeFormat } =
useSelector(createUISettingsSelector());
const command = useSelector(createCommandSelector(taskName));
const [time, setTime] = useState(Date.now());
const isQueued = !!(command && command.status === 'queued');
const isExecuting = isCommandExecuting(command);
const wasExecuting = usePrevious(isExecuting);
const isDisabled = interval === 0;
const executeNow = !isDisabled && moment().isAfter(nextExecution);
const hasNextExecutionTime = !isDisabled && !executeNow;
const hasLastStartTime = moment(lastStartTime).isAfter('2010-01-01');
const duration = useMemo(() => {
return moment
.duration(interval, 'minutes')
.humanize()
.replace(/an?(?=\s)/, '1');
}, [interval]);
const { lastExecutionTime, nextExecutionTime } = useMemo(() => {
const isDisabled = interval === 0;
if (showRelativeDates && time) {
return {
lastExecutionTime: moment(lastExecution).fromNow(),
nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow(),
};
}
return {
lastExecutionTime: formatDate(lastExecution, shortDateFormat),
nextExecutionTime: isDisabled
? '-'
: formatDate(nextExecution, shortDateFormat),
};
}, [
time,
interval,
lastExecution,
nextExecution,
showRelativeDates,
shortDateFormat,
]);
const handleExecutePress = useCallback(() => {
dispatch(
executeCommand({
name: taskName,
})
);
}, [taskName, dispatch]);
useEffect(() => {
if (!isExecuting && wasExecuting) {
setTimeout(() => {
dispatch(fetchTask({ id }));
}, 1000);
}
}, [id, isExecuting, wasExecuting, dispatch]);
useEffect(() => {
const interval = setInterval(() => setTime(Date.now()), 1000);
return () => {
clearInterval(interval);
};
}, [setTime]);
return (
<TableRow>
<TableRowCell>{name}</TableRowCell>
<TableRowCell className={styles.interval}>
{isDisabled ? 'disabled' : duration}
</TableRowCell>
<TableRowCell
className={styles.lastExecution}
title={formatDateTime(lastExecution, longDateFormat, timeFormat)}
>
{lastExecutionTime}
</TableRowCell>
{hasLastStartTime ? (
<TableRowCell className={styles.lastDuration} title={lastDuration}>
{formatTimeSpan(lastDuration)}
</TableRowCell>
) : (
<TableRowCell className={styles.lastDuration}>-</TableRowCell>
)}
{isDisabled ? (
<TableRowCell className={styles.nextExecution}>-</TableRowCell>
) : null}
{executeNow && isQueued ? (
<TableRowCell className={styles.nextExecution}>queued</TableRowCell>
) : null}
{executeNow && !isQueued ? (
<TableRowCell className={styles.nextExecution}>now</TableRowCell>
) : null}
{hasNextExecutionTime ? (
<TableRowCell
className={styles.nextExecution}
title={formatDateTime(nextExecution, longDateFormat, timeFormat, {
includeSeconds: true,
})}
>
{nextExecutionTime}
</TableRowCell>
) : null}
<TableRowCell className={styles.actions}>
<SpinnerIconButton
name={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isExecuting}
onPress={handleExecutePress}
/>
</TableRowCell>
</TableRow>
);
}
export default ScheduledTaskRow;

View File

@@ -1,92 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchTask } from 'Store/Actions/systemActions';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import ScheduledTaskRow from './ScheduledTaskRow';
function createMapStateToProps() {
return createSelector(
(state, { taskName }) => taskName,
createCommandsSelector(),
createUISettingsSelector(),
(taskName, commands, uiSettings) => {
const command = findCommand(commands, { name: taskName });
return {
isQueued: !!(command && command.state === 'queued'),
isExecuting: isCommandExecuting(command),
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
const taskName = props.taskName;
return {
dispatchFetchTask() {
dispatch(fetchTask({
id: props.id
}));
},
onExecutePress() {
dispatch(executeCommand({
name: taskName
}));
}
};
}
class ScheduledTaskRowConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
const {
isExecuting,
dispatchFetchTask
} = this.props;
if (!isExecuting && prevProps.isExecuting) {
// Give the host a moment to update after the command completes
setTimeout(() => {
dispatchFetchTask();
}, 1000);
}
}
//
// Render
render() {
const {
dispatchFetchTask,
...otherProps
} = this.props;
return (
<ScheduledTaskRow
{...otherProps}
/>
);
}
}
ScheduledTaskRowConnector.propTypes = {
id: PropTypes.number.isRequired,
isExecuting: PropTypes.bool.isRequired,
dispatchFetchTask: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector);

View File

@@ -1,85 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import translate from 'Utilities/String/translate';
import ScheduledTaskRowConnector from './ScheduledTaskRowConnector';
const columns = [
{
name: 'name',
label: () => translate('Name'),
isVisible: true
},
{
name: 'interval',
label: () => translate('Interval'),
isVisible: true
},
{
name: 'lastExecution',
label: () => translate('LastExecution'),
isVisible: true
},
{
name: 'lastDuration',
label: () => translate('LastDuration'),
isVisible: true
},
{
name: 'nextExecution',
label: () => translate('NextExecution'),
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
function ScheduledTasks(props) {
const {
isFetching,
isPopulated,
items
} = props;
return (
<FieldSet legend={translate('Scheduled')}>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
isPopulated &&
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
return (
<ScheduledTaskRowConnector
key={item.id}
{...item}
/>
);
})
}
</TableBody>
</Table>
}
</FieldSet>
);
}
ScheduledTasks.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
items: PropTypes.array.isRequired
};
export default ScheduledTasks;

View File

@@ -0,0 +1,73 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { fetchTasks } from 'Store/Actions/systemActions';
import translate from 'Utilities/String/translate';
import ScheduledTaskRow from './ScheduledTaskRow';
const columns: Column[] = [
{
name: 'name',
label: () => translate('Name'),
isVisible: true,
},
{
name: 'interval',
label: () => translate('Interval'),
isVisible: true,
},
{
name: 'lastExecution',
label: () => translate('LastExecution'),
isVisible: true,
},
{
name: 'lastDuration',
label: () => translate('LastDuration'),
isVisible: true,
},
{
name: 'nextExecution',
label: () => translate('NextExecution'),
isVisible: true,
},
{
name: 'actions',
label: '',
isVisible: true,
},
];
function ScheduledTasks() {
const dispatch = useDispatch();
const { isFetching, isPopulated, items } = useSelector(
(state: AppState) => state.system.tasks
);
useEffect(() => {
dispatch(fetchTasks());
}, [dispatch]);
return (
<FieldSet legend={translate('Scheduled')}>
{isFetching && !isPopulated && <LoadingIndicator />}
{isPopulated && (
<Table columns={columns}>
<TableBody>
{items.map((item) => {
return <ScheduledTaskRow key={item.id} {...item} />;
})}
</TableBody>
</Table>
)}
</FieldSet>
);
}
export default ScheduledTasks;

View File

@@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchTasks } from 'Store/Actions/systemActions';
import ScheduledTasks from './ScheduledTasks';
function createMapStateToProps() {
return createSelector(
(state) => state.system.tasks,
(tasks) => {
return tasks;
}
);
}
const mapDispatchToProps = {
dispatchFetchTasks: fetchTasks
};
class ScheduledTasksConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchTasks();
}
//
// Render
render() {
return (
<ScheduledTasks
{...this.props}
/>
);
}
}
ScheduledTasksConnector.propTypes = {
dispatchFetchTasks: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector);

View File

@@ -3,13 +3,13 @@ import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import translate from 'Utilities/String/translate';
import QueuedTasks from './Queued/QueuedTasks';
import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector';
import ScheduledTasks from './Scheduled/ScheduledTasks';
function Tasks() {
return (
<PageContent title={translate('Tasks')}>
<PageContentBody>
<ScheduledTasksConnector />
<ScheduledTasks />
<QueuedTasks />
</PageContentBody>
</PageContent>

View File

@@ -18,12 +18,14 @@ class UpdateChanges extends Component {
return null;
}
const uniqueChanges = [...new Set(changes)];
return (
<div>
<div className={styles.title}>{title}</div>
<ul>
{
changes.map((change, index) => {
uniqueChanges.map((change, index) => {
const checkChange = change.replace(/#\d{3,5}\b/g, (match, contents) => {
return `[${match}](https://github.com/Prowlarr/Prowlarr/issues/${match.substring(1)})`;
});

View File

@@ -1,6 +1,6 @@
import { filesize } from 'filesize';
function formatBytes(input) {
function formatBytes(input: string | number) {
const size = Number(input);
if (isNaN(size)) {
@@ -9,7 +9,7 @@ function formatBytes(input) {
return `${filesize(size, {
base: 2,
round: 1
round: 1,
})}`;
}

View File

@@ -1,3 +0,0 @@
export default function getPathWithUrlBase(path) {
return `${window.Prowlarr.urlBase}${path}`;
}

Some files were not shown because too many files have changed in this diff Show More