New: Custom Filters for Stats

pull/1853/head
Qstick 1 year ago
parent c873b3ffac
commit b608e38454

@ -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<IndexerStats> {
filterBuilderProps: FilterBuilderProp<Indexer>[];
selectedFilterKey: string;
filters: Filter[];
}

@ -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;

@ -1,6 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'chartContainer': string;
'fullWidthChart': string;
'halfWidthChart': string;
}

@ -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() {
<PageContent>
<PageToolbar>
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}>
<IndexerStatsFilterMenu
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
onFilterSelect={onFilterSelect}
filterModalConnectorComponent={IndexerStatsFilterModal}
isDisabled={false}
/>
</PageToolbarSection>
@ -213,57 +229,73 @@ function IndexerStats() {
{isLoaded && (
<div>
<div className={styles.fullWidthChart}>
<BarChart
data={getAverageResponseTimeData(item.indexers)}
title={translate('AverageResponseTimesMs')}
/>
<div className={styles.chartContainer}>
<BarChart
data={getAverageResponseTimeData(item.indexers)}
title={translate('AverageResponseTimesMs')}
/>
</div>
</div>
<div className={styles.fullWidthChart}>
<BarChart
data={getFailureRateData(item.indexers)}
title={translate('IndexerFailureRate')}
kind={kinds.WARNING}
/>
<div className={styles.chartContainer}>
<BarChart
data={getFailureRateData(item.indexers)}
title={translate('IndexerFailureRate')}
kind={kinds.WARNING}
/>
</div>
</div>
<div className={styles.halfWidthChart}>
<StackedBarChart
data={getTotalRequestsData(item.indexers)}
title={translate('TotalIndexerQueries')}
/>
<div className={styles.chartContainer}>
<StackedBarChart
data={getTotalRequestsData(item.indexers)}
title={translate('TotalIndexerQueries')}
/>
</div>
</div>
<div className={styles.halfWidthChart}>
<BarChart
data={getNumberGrabsData(item.indexers)}
title={translate('TotalIndexerSuccessfulGrabs')}
/>
<div className={styles.chartContainer}>
<BarChart
data={getNumberGrabsData(item.indexers)}
title={translate('TotalIndexerSuccessfulGrabs')}
/>
</div>
</div>
<div className={styles.halfWidthChart}>
<BarChart
data={getUserAgentQueryData(item.userAgents)}
title={translate('TotalUserAgentQueries')}
horizontal={true}
/>
<div className={styles.chartContainer}>
<BarChart
data={getUserAgentQueryData(item.userAgents)}
title={translate('TotalUserAgentQueries')}
horizontal={true}
/>
</div>
</div>
<div className={styles.halfWidthChart}>
<BarChart
data={getUserAgentGrabsData(item.userAgents)}
title={translate('TotalUserAgentGrabs')}
horizontal={true}
/>
<div className={styles.chartContainer}>
<BarChart
data={getUserAgentGrabsData(item.userAgents)}
title={translate('TotalUserAgentGrabs')}
horizontal={true}
/>
</div>
</div>
<div className={styles.halfWidthChart}>
<DoughnutChart
data={getHostQueryData(item.hosts)}
title={translate('TotalHostQueries')}
horizontal={true}
/>
<div className={styles.chartContainer}>
<DoughnutChart
data={getHostQueryData(item.hosts)}
title={translate('TotalHostQueries')}
horizontal={true}
/>
</div>
</div>
<div className={styles.halfWidthChart}>
<DoughnutChart
data={getHostGrabsData(item.hosts)}
title={translate('TotalHostGrabs')}
horizontal={true}
/>
<div className={styles.chartContainer}>
<DoughnutChart
data={getHostGrabsData(item.hosts)}
title={translate('TotalHostGrabs')}
horizontal={true}
/>
</div>
</div>
</div>
)}

@ -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 (
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={isDisabled}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={[]}
onFilterSelect={onFilterSelect}
/>
);
}
export default IndexerStatsFilterMenu;

@ -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 (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

@ -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 = {

@ -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']

@ -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']

@ -46,7 +46,7 @@ namespace NzbDrone.Core.Test.IndexerStatsTests
.Setup(o => o.Between(It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.Returns<DateTime, DateTime>((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<int> { 5 });
statistics.IndexerStatistics.Count.Should().Be(1);
statistics.IndexerStatistics.First().AverageResponseTime.Should().Be(0);

@ -8,7 +8,7 @@ namespace NzbDrone.Core.IndexerStats
{
public interface IIndexerStatisticsService
{
CombinedStatistics IndexerStatistics(DateTime start, DateTime end);
CombinedStatistics IndexerStatistics(DateTime start, DateTime end, List<int> 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<int> 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<IndexerStatistics>();
var userAgentStatsList = new List<UserAgentStatistics>();

@ -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<int>();
var parsedTags = new List<int>();
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
{

Loading…
Cancel
Save