From b608e384543b5479957a303aa067ca6074fe8624 Mon Sep 17 00:00:00 2001 From: Qstick Date: Sun, 3 Sep 2023 10:56:43 -0500 Subject: [PATCH] New: Custom Filters for Stats --- .../src/App/State/IndexerStatsAppState.ts | 4 +- frontend/src/Indexer/Stats/IndexerStats.css | 11 +- .../src/Indexer/Stats/IndexerStats.css.d.ts | 1 + frontend/src/Indexer/Stats/IndexerStats.tsx | 118 +++++++++++------- .../Indexer/Stats/IndexerStatsFilterMenu.tsx | 27 ---- .../Indexer/Stats/IndexerStatsFilterModal.tsx | 56 +++++++++ .../src/Store/Actions/indexerStatsActions.js | 46 ++++--- frontend/src/Styles/Themes/dark.js | 1 + frontend/src/Styles/Themes/light.js | 1 + .../IndexerStatisticsServiceFixture.cs | 2 +- .../IndexerStats/IndexerStatisticsService.cs | 12 +- .../Indexers/IndexerStatsController.cs | 32 ++++- 12 files changed, 214 insertions(+), 97 deletions(-) delete mode 100644 frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx create mode 100644 frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx diff --git a/frontend/src/App/State/IndexerStatsAppState.ts b/frontend/src/App/State/IndexerStatsAppState.ts index a696860b3..8d3ae660a 100644 --- a/frontend/src/App/State/IndexerStatsAppState.ts +++ b/frontend/src/App/State/IndexerStatsAppState.ts @@ -1,9 +1,11 @@ import { AppSectionItemState } from 'App/State/AppSectionState'; -import { Filter } from 'App/State/AppState'; +import { Filter, FilterBuilderProp } from 'App/State/AppState'; +import Indexer from 'Indexer/Indexer'; import { IndexerStats } from 'typings/IndexerStats'; export interface IndexerStatsAppState extends AppSectionItemState { + filterBuilderProps: FilterBuilderProp[]; selectedFilterKey: string; filters: Filter[]; } diff --git a/frontend/src/Indexer/Stats/IndexerStats.css b/frontend/src/Indexer/Stats/IndexerStats.css index 249dcc448..a6ec01190 100644 --- a/frontend/src/Indexer/Stats/IndexerStats.css +++ b/frontend/src/Indexer/Stats/IndexerStats.css @@ -1,20 +1,25 @@ .fullWidthChart { display: inline-block; - padding: 15px 25px; width: 100%; - height: 300px; } .halfWidthChart { display: inline-block; - padding: 15px 25px; width: 50%; +} + +.chartContainer { + margin: 5px; + padding: 15px 25px; height: 300px; + border-radius: 10px; + background-color: var(--chartBackgroundColor); } @media only screen and (max-width: $breakpointSmall) { .halfWidthChart { display: inline-block; + margin: 5px; padding: 15px 25px; width: 100%; height: 300px; diff --git a/frontend/src/Indexer/Stats/IndexerStats.css.d.ts b/frontend/src/Indexer/Stats/IndexerStats.css.d.ts index ce2364202..6bb12271e 100644 --- a/frontend/src/Indexer/Stats/IndexerStats.css.d.ts +++ b/frontend/src/Indexer/Stats/IndexerStats.css.d.ts @@ -1,6 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { + 'chartContainer': string; 'fullWidthChart': string; 'halfWidthChart': string; } diff --git a/frontend/src/Indexer/Stats/IndexerStats.tsx b/frontend/src/Indexer/Stats/IndexerStats.tsx index 0a61fc8fc..312690d6a 100644 --- a/frontend/src/Indexer/Stats/IndexerStats.tsx +++ b/frontend/src/Indexer/Stats/IndexerStats.tsx @@ -8,6 +8,7 @@ import BarChart from 'Components/Chart/BarChart'; import DoughnutChart from 'Components/Chart/DoughnutChart'; import StackedBarChart from 'Components/Chart/StackedBarChart'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; @@ -17,6 +18,7 @@ import { fetchIndexerStats, setIndexerStatsFilter, } from 'Store/Actions/indexerStatsActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import { IndexerStatsHost, IndexerStatsIndexer, @@ -24,7 +26,7 @@ import { } from 'typings/IndexerStats'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; import translate from 'Utilities/String/translate'; -import IndexerStatsFilterMenu from './IndexerStatsFilterMenu'; +import IndexerStatsFilterModal from './IndexerStatsFilterModal'; import styles from './IndexerStats.css'; function getAverageResponseTimeData(indexerStats: IndexerStatsIndexer[]) { @@ -165,15 +167,26 @@ function getHostQueryData(indexerStats: IndexerStatsHost[]) { const indexerStatsSelector = () => { return createSelector( (state: AppState) => state.indexerStats, - (indexerStats: IndexerStatsAppState) => { - return indexerStats; + createCustomFiltersSelector('indexerStats'), + (indexerStats: IndexerStatsAppState, customFilters) => { + return { + ...indexerStats, + customFilters, + }; } ); }; function IndexerStats() { - const { isFetching, isPopulated, item, error, filters, selectedFilterKey } = - useSelector(indexerStatsSelector()); + const { + isFetching, + isPopulated, + item, + error, + filters, + customFilters, + selectedFilterKey, + } = useSelector(indexerStatsSelector()); const dispatch = useDispatch(); useEffect(() => { @@ -193,10 +206,13 @@ function IndexerStats() { - @@ -213,57 +229,73 @@ function IndexerStats() { {isLoaded && (
- +
+ +
- +
+ +
- +
+ +
- +
+ +
- +
+ +
- +
+ +
- +
+ +
- +
+ +
)} diff --git a/frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx b/frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx deleted file mode 100644 index 7b30be4c3..000000000 --- a/frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import { align } from 'Helpers/Props'; - -interface IndexerStatsFilterMenuProps { - selectedFilterKey: string | number; - filters: object[]; - isDisabled: boolean; - onFilterSelect(filterName: string): unknown; -} - -function IndexerStatsFilterMenu(props: IndexerStatsFilterMenuProps) { - const { selectedFilterKey, filters, isDisabled, onFilterSelect } = props; - - return ( - - ); -} - -export default IndexerStatsFilterMenu; diff --git a/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx b/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx new file mode 100644 index 000000000..6e3a49dfb --- /dev/null +++ b/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setIndexerStatsFilter } from 'Store/Actions/indexerStatsActions'; + +function createIndexerStatsSelector() { + return createSelector( + (state: AppState) => state.indexerStats.item, + (indexerStats) => { + return indexerStats; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.indexerStats.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface IndexerStatsFilterModalProps { + isOpen: boolean; +} + +export default function IndexerStatsFilterModal( + props: IndexerStatsFilterModalProps +) { + const sectionItems = [useSelector(createIndexerStatsSelector())]; + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'indexerStats'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setIndexerStatsFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Store/Actions/indexerStatsActions.js b/frontend/src/Store/Actions/indexerStatsActions.js index 9171ee340..dc928d9b5 100644 --- a/frontend/src/Store/Actions/indexerStatsActions.js +++ b/frontend/src/Store/Actions/indexerStatsActions.js @@ -3,6 +3,7 @@ import { batchActions } from 'redux-batched-actions'; import { filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; import translate from 'Utilities/String/translate'; import { set, update } from './baseActions'; import createHandleActions from './Creators/createHandleActions'; @@ -55,19 +56,20 @@ export const defaultState = { filterBuilderProps: [ { - name: 'startDate', - label: 'Start Date', - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.DATE + name: 'indexers', + label: () => translate('Indexers'), + type: filterBuilderTypes.CONTAINS, + valueType: filterBuilderValueTypes.INDEXER }, { - name: 'endDate', - label: 'End Date', - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.DATE + name: 'tags', + label: () => translate('Tags'), + type: filterBuilderTypes.CONTAINS, + valueType: filterBuilderValueTypes.TAG } ], - selectedFilterKey: 'all' + selectedFilterKey: 'all', + customFilters: [] }; export const persistState = [ @@ -81,6 +83,10 @@ export const persistState = [ export const FETCH_INDEXER_STATS = 'indexerStats/fetchIndexerStats'; export const SET_INDEXER_STATS_FILTER = 'indexerStats/setIndexerStatsFilter'; +function getCustomFilters(state, type) { + return state.customFilters.items.filter((customFilter) => customFilter.type === type); +} + // // Action Creators @@ -94,23 +100,35 @@ export const actionHandlers = handleThunks({ [FETCH_INDEXER_STATS]: function(getState, payload, dispatch) { const state = getState(); const indexerStats = state.indexerStats; + const customFilters = getCustomFilters(state, section); + const selectedFilters = findSelectedFilters(indexerStats.selectedFilterKey, indexerStats.filters, customFilters); const requestParams = { endDate: moment().toISOString() }; + selectedFilters.forEach((selectedFilter) => { + if (selectedFilter.key === 'indexers') { + requestParams.indexers = selectedFilter.value.join(','); + } + + if (selectedFilter.key === 'tags') { + requestParams.tags = selectedFilter.value.join(','); + } + }); + if (indexerStats.selectedFilterKey !== 'all') { - let dayCount = 7; + if (indexerStats.selectedFilterKey === 'lastSeven') { + requestParams.startDate = moment().add(-7, 'days').endOf('day').toISOString(); + } if (indexerStats.selectedFilterKey === 'lastThirty') { - dayCount = 30; + requestParams.startDate = moment().add(-30, 'days').endOf('day').toISOString(); } if (indexerStats.selectedFilterKey === 'lastNinety') { - dayCount = 90; + requestParams.startDate = moment().add(-90, 'days').endOf('day').toISOString(); } - - requestParams.startDate = moment().add(-dayCount, 'days').endOf('day').toISOString(); } const basesAttrs = { diff --git a/frontend/src/Styles/Themes/dark.js b/frontend/src/Styles/Themes/dark.js index 06c0304ea..79911686f 100644 --- a/frontend/src/Styles/Themes/dark.js +++ b/frontend/src/Styles/Themes/dark.js @@ -187,6 +187,7 @@ module.exports = { // // Charts + chartBackgroundColor: '#262626', failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'], chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'], chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'] diff --git a/frontend/src/Styles/Themes/light.js b/frontend/src/Styles/Themes/light.js index 5ff84460c..8a6d123b2 100644 --- a/frontend/src/Styles/Themes/light.js +++ b/frontend/src/Styles/Themes/light.js @@ -187,6 +187,7 @@ module.exports = { // // Charts + chartBackgroundColor: '#fff', failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'], chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'], chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'] diff --git a/src/NzbDrone.Core.Test/IndexerStatsTests/IndexerStatisticsServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerStatsTests/IndexerStatisticsServiceFixture.cs index c0ec172b4..b8f4ef702 100644 --- a/src/NzbDrone.Core.Test/IndexerStatsTests/IndexerStatisticsServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerStatsTests/IndexerStatisticsServiceFixture.cs @@ -46,7 +46,7 @@ namespace NzbDrone.Core.Test.IndexerStatsTests .Setup(o => o.Between(It.IsAny(), It.IsAny())) .Returns((s, f) => history); - var statistics = Subject.IndexerStatistics(DateTime.UtcNow.AddMonths(-1), DateTime.UtcNow); + var statistics = Subject.IndexerStatistics(DateTime.UtcNow.AddMonths(-1), DateTime.UtcNow, new List { 5 }); statistics.IndexerStatistics.Count.Should().Be(1); statistics.IndexerStatistics.First().AverageResponseTime.Should().Be(0); diff --git a/src/NzbDrone.Core/IndexerStats/IndexerStatisticsService.cs b/src/NzbDrone.Core/IndexerStats/IndexerStatisticsService.cs index bb92b2d1d..fd603ece8 100644 --- a/src/NzbDrone.Core/IndexerStats/IndexerStatisticsService.cs +++ b/src/NzbDrone.Core/IndexerStats/IndexerStatisticsService.cs @@ -8,7 +8,7 @@ namespace NzbDrone.Core.IndexerStats { public interface IIndexerStatisticsService { - CombinedStatistics IndexerStatistics(DateTime start, DateTime end); + CombinedStatistics IndexerStatistics(DateTime start, DateTime end, List indexerIds); } public class IndexerStatisticsService : IIndexerStatisticsService @@ -22,13 +22,15 @@ namespace NzbDrone.Core.IndexerStats _indexerFactory = indexerFactory; } - public CombinedStatistics IndexerStatistics(DateTime start, DateTime end) + public CombinedStatistics IndexerStatistics(DateTime start, DateTime end, List indexerIds) { var history = _historyService.Between(start, end); - var groupedByIndexer = history.GroupBy(h => h.IndexerId); - var groupedByUserAgent = history.GroupBy(h => h.Data.GetValueOrDefault("source") ?? ""); - var groupedByHost = history.GroupBy(h => h.Data.GetValueOrDefault("host") ?? ""); + var filteredHistory = history.Where(h => indexerIds.Contains(h.IndexerId)); + + var groupedByIndexer = filteredHistory.GroupBy(h => h.IndexerId); + var groupedByUserAgent = filteredHistory.GroupBy(h => h.Data.GetValueOrDefault("source") ?? ""); + var groupedByHost = filteredHistory.GroupBy(h => h.Data.GetValueOrDefault("host") ?? ""); var indexerStatsList = new List(); var userAgentStatsList = new List(); diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerStatsController.cs b/src/Prowlarr.Api.V1/Indexers/IndexerStatsController.cs index 0e352506c..1b9b1ec91 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerStatsController.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerStatsController.cs @@ -1,6 +1,11 @@ using System; +using System.Collections.Generic; +using System.Linq; using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Indexers; using NzbDrone.Core.IndexerStats; +using NzbDrone.Core.Tags; using Prowlarr.Http; namespace Prowlarr.Api.V1.Indexers @@ -9,20 +14,41 @@ namespace Prowlarr.Api.V1.Indexers public class IndexerStatsController : Controller { private readonly IIndexerStatisticsService _indexerStatisticsService; + private readonly IIndexerFactory _indexerFactory; + private readonly ITagService _tagService; - public IndexerStatsController(IIndexerStatisticsService indexerStatisticsService) + public IndexerStatsController(IIndexerStatisticsService indexerStatisticsService, IIndexerFactory indexerFactory, ITagService tagService) { _indexerStatisticsService = indexerStatisticsService; + _indexerFactory = indexerFactory; + _tagService = tagService; } [HttpGet] [Produces("application/json")] - public IndexerStatsResource GetAll(DateTime? startDate, DateTime? endDate) + public IndexerStatsResource GetAll(DateTime? startDate, DateTime? endDate, string indexers, string tags) { var statsStartDate = startDate ?? DateTime.MinValue; var statsEndDate = endDate ?? DateTime.Now; + var parsedIndexers = new List(); + var parsedTags = new List(); + var indexerIds = _indexerFactory.All().Select(i => i.Id).ToList(); - var indexerStats = _indexerStatisticsService.IndexerStatistics(statsStartDate, statsEndDate); + if (tags.IsNotNullOrWhiteSpace()) + { + parsedTags.AddRange(tags.Split(',').Select(_tagService.GetTag).Select(t => t.Id)); + + indexerIds = indexerIds.Intersect(parsedTags.SelectMany(t => _indexerFactory.AllForTag(t).Select(i => i.Id))).ToList(); + } + + if (indexers.IsNotNullOrWhiteSpace()) + { + parsedIndexers.AddRange(indexers.Split(',').Select(x => Convert.ToInt32(x))); + + indexerIds = indexerIds.Intersect(parsedIndexers).ToList(); + } + + var indexerStats = _indexerStatisticsService.IndexerStatistics(statsStartDate, statsEndDate, indexerIds); var indexerResource = new IndexerStatsResource {