mirror of
https://github.com/Prowlarr/Prowlarr.git
synced 2025-09-17 17:14:18 +02:00
Convert Indexer Stats to Typescript
This commit is contained in:
@@ -26,7 +26,8 @@ module.exports = {
|
|||||||
globals: {
|
globals: {
|
||||||
expect: false,
|
expect: false,
|
||||||
chai: false,
|
chai: false,
|
||||||
sinon: false
|
sinon: false,
|
||||||
|
JSX: true
|
||||||
},
|
},
|
||||||
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
@@ -5,7 +5,7 @@ import NotFound from 'Components/NotFound';
|
|||||||
import Switch from 'Components/Router/Switch';
|
import Switch from 'Components/Router/Switch';
|
||||||
import HistoryConnector from 'History/HistoryConnector';
|
import HistoryConnector from 'History/HistoryConnector';
|
||||||
import IndexerIndex from 'Indexer/Index/IndexerIndex';
|
import IndexerIndex from 'Indexer/Index/IndexerIndex';
|
||||||
import StatsConnector from 'Indexer/Stats/StatsConnector';
|
import IndexerStats from 'Indexer/Stats/IndexerStats';
|
||||||
import SearchIndexConnector from 'Search/SearchIndexConnector';
|
import SearchIndexConnector from 'Search/SearchIndexConnector';
|
||||||
import ApplicationSettingsConnector from 'Settings/Applications/ApplicationSettingsConnector';
|
import ApplicationSettingsConnector from 'Settings/Applications/ApplicationSettingsConnector';
|
||||||
import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector';
|
import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector';
|
||||||
@@ -60,7 +60,7 @@ function AppRoutes(props) {
|
|||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/indexers/stats"
|
path="/indexers/stats"
|
||||||
component={StatsConnector}
|
component={IndexerStats}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import IndexerAppState, { IndexerIndexAppState } from './IndexerAppState';
|
import IndexerAppState, { IndexerIndexAppState } from './IndexerAppState';
|
||||||
|
import IndexerStatsAppState from './IndexerStatsAppState';
|
||||||
import SettingsAppState from './SettingsAppState';
|
import SettingsAppState from './SettingsAppState';
|
||||||
import TagsAppState from './TagsAppState';
|
import TagsAppState from './TagsAppState';
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export interface CustomFilter {
|
|||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
indexerIndex: IndexerIndexAppState;
|
indexerIndex: IndexerIndexAppState;
|
||||||
|
indexerStats: IndexerStatsAppState;
|
||||||
indexers: IndexerAppState;
|
indexers: IndexerAppState;
|
||||||
settings: SettingsAppState;
|
settings: SettingsAppState;
|
||||||
tags: TagsAppState;
|
tags: TagsAppState;
|
||||||
|
11
frontend/src/App/State/IndexerStatsAppState.ts
Normal file
11
frontend/src/App/State/IndexerStatsAppState.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { AppSectionItemState } from 'App/State/AppSectionState';
|
||||||
|
import { Filter } from 'App/State/AppState';
|
||||||
|
import { IndexerStats } from 'typings/IndexerStats';
|
||||||
|
|
||||||
|
export interface IndexerStatsAppState
|
||||||
|
extends AppSectionItemState<IndexerStats> {
|
||||||
|
selectedFilterKey: string;
|
||||||
|
filters: Filter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IndexerStatsAppState;
|
@@ -270,6 +270,7 @@ FormInputGroup.propTypes = {
|
|||||||
helpTexts: PropTypes.arrayOf(PropTypes.string),
|
helpTexts: PropTypes.arrayOf(PropTypes.string),
|
||||||
helpTextWarning: PropTypes.string,
|
helpTextWarning: PropTypes.string,
|
||||||
helpLink: PropTypes.string,
|
helpLink: PropTypes.string,
|
||||||
|
autoFocus: PropTypes.bool,
|
||||||
includeNoChange: PropTypes.bool,
|
includeNoChange: PropTypes.bool,
|
||||||
includeNoChangeDisabled: PropTypes.bool,
|
includeNoChangeDisabled: PropTypes.bool,
|
||||||
selectedValueOptions: PropTypes.object,
|
selectedValueOptions: PropTypes.object,
|
||||||
|
@@ -68,6 +68,7 @@ class PathInputConnector extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PathInputConnector.propTypes = {
|
PathInputConnector.propTypes = {
|
||||||
|
...PathInput.props,
|
||||||
includeFiles: PropTypes.bool.isRequired,
|
includeFiles: PropTypes.bool.isRequired,
|
||||||
dispatchFetchPaths: PropTypes.func.isRequired,
|
dispatchFetchPaths: PropTypes.func.isRequired,
|
||||||
dispatchClearPaths: PropTypes.func.isRequired
|
dispatchClearPaths: PropTypes.func.isRequired
|
||||||
|
7
frontend/src/Components/Table/Cells/TableRowCellButton.css.d.ts
vendored
Normal file
7
frontend/src/Components/Table/Cells/TableRowCellButton.css.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
'cell': string;
|
||||||
|
}
|
||||||
|
export const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
@@ -1,25 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import TableRowCell from './TableRowCell';
|
|
||||||
import styles from './TableRowCellButton.css';
|
|
||||||
|
|
||||||
function TableRowCellButton({ className, ...otherProps }) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
className={className}
|
|
||||||
component={TableRowCell}
|
|
||||||
{...otherProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TableRowCellButton.propTypes = {
|
|
||||||
className: PropTypes.string.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
TableRowCellButton.defaultProps = {
|
|
||||||
className: styles.cell
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableRowCellButton;
|
|
19
frontend/src/Components/Table/Cells/TableRowCellButton.tsx
Normal file
19
frontend/src/Components/Table/Cells/TableRowCellButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import Link, { LinkProps } from 'Components/Link/Link';
|
||||||
|
import TableRowCell from './TableRowCell';
|
||||||
|
import styles from './TableRowCellButton.css';
|
||||||
|
|
||||||
|
interface TableRowCellButtonProps extends LinkProps {
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRowCellButton(props: TableRowCellButtonProps) {
|
||||||
|
const { className = styles.cell, ...otherProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className={className} component={TableRowCell} {...otherProps} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TableRowCellButton;
|
@@ -1,15 +1,16 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Button from 'Components/Link/Button';
|
import Button from 'Components/Link/Button';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './NoIndexer.css';
|
import styles from './NoIndexer.css';
|
||||||
|
|
||||||
function NoIndexer(props) {
|
interface NoIndexerProps {
|
||||||
const {
|
totalItems: number;
|
||||||
totalItems,
|
onAddIndexerPress(): void;
|
||||||
onAddIndexerPress
|
}
|
||||||
} = props;
|
|
||||||
|
function NoIndexer(props: NoIndexerProps) {
|
||||||
|
const { totalItems, onAddIndexerPress } = props;
|
||||||
|
|
||||||
if (totalItems > 0) {
|
if (totalItems > 0) {
|
||||||
return (
|
return (
|
||||||
@@ -28,10 +29,7 @@ function NoIndexer(props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
<Button
|
<Button onPress={onAddIndexerPress} kind={kinds.PRIMARY}>
|
||||||
onPress={onAddIndexerPress}
|
|
||||||
kind={kinds.PRIMARY}
|
|
||||||
>
|
|
||||||
{translate('AddNewIndexer')}
|
{translate('AddNewIndexer')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,9 +37,4 @@ function NoIndexer(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
NoIndexer.propTypes = {
|
|
||||||
totalItems: PropTypes.number.isRequired,
|
|
||||||
onAddIndexerPress: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NoIndexer;
|
export default NoIndexer;
|
275
frontend/src/Indexer/Stats/IndexerStats.tsx
Normal file
275
frontend/src/Indexer/Stats/IndexerStats.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
import IndexerStatsAppState from 'App/State/IndexerStatsAppState';
|
||||||
|
import Alert from 'Components/Alert';
|
||||||
|
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 PageContent from 'Components/Page/PageContent';
|
||||||
|
import PageContentBody from 'Components/Page/PageContentBody';
|
||||||
|
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||||
|
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||||
|
import { align, kinds } from 'Helpers/Props';
|
||||||
|
import {
|
||||||
|
fetchIndexerStats,
|
||||||
|
setIndexerStatsFilter,
|
||||||
|
} from 'Store/Actions/indexerStatsActions';
|
||||||
|
import {
|
||||||
|
IndexerStatsHost,
|
||||||
|
IndexerStatsIndexer,
|
||||||
|
IndexerStatsUserAgent,
|
||||||
|
} from 'typings/IndexerStats';
|
||||||
|
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import IndexerStatsFilterMenu from './IndexerStatsFilterMenu';
|
||||||
|
import styles from './IndexerStats.css';
|
||||||
|
|
||||||
|
function getAverageResponseTimeData(indexerStats: IndexerStatsIndexer[]) {
|
||||||
|
const data = indexerStats.map((indexer) => {
|
||||||
|
return {
|
||||||
|
label: indexer.indexerName,
|
||||||
|
value: indexer.averageResponseTime,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
data.sort((a, b) => {
|
||||||
|
return b.value - a.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFailureRateData(indexerStats: IndexerStatsIndexer[]) {
|
||||||
|
const data = indexerStats.map((indexer) => {
|
||||||
|
return {
|
||||||
|
label: indexer.indexerName,
|
||||||
|
value:
|
||||||
|
(indexer.numberOfFailedQueries +
|
||||||
|
indexer.numberOfFailedRssQueries +
|
||||||
|
indexer.numberOfFailedAuthQueries +
|
||||||
|
indexer.numberOfFailedGrabs) /
|
||||||
|
(indexer.numberOfQueries +
|
||||||
|
indexer.numberOfRssQueries +
|
||||||
|
indexer.numberOfAuthQueries +
|
||||||
|
indexer.numberOfGrabs),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
data.sort((a, b) => {
|
||||||
|
return b.value - a.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalRequestsData(indexerStats: IndexerStatsIndexer[]) {
|
||||||
|
const data = {
|
||||||
|
labels: indexerStats.map((indexer) => indexer.indexerName),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: translate('SearchQueries'),
|
||||||
|
data: indexerStats.map((indexer) => indexer.numberOfQueries),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: translate('RssQueries'),
|
||||||
|
data: indexerStats.map((indexer) => indexer.numberOfRssQueries),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: translate('AuthQueries'),
|
||||||
|
data: indexerStats.map((indexer) => indexer.numberOfAuthQueries),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumberGrabsData(indexerStats: IndexerStatsIndexer[]) {
|
||||||
|
const data = indexerStats.map((indexer) => {
|
||||||
|
return {
|
||||||
|
label: indexer.indexerName,
|
||||||
|
value: indexer.numberOfGrabs - indexer.numberOfFailedGrabs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
data.sort((a, b) => {
|
||||||
|
return b.value - a.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserAgentGrabsData(indexerStats: IndexerStatsUserAgent[]) {
|
||||||
|
const data = indexerStats.map((indexer) => {
|
||||||
|
return {
|
||||||
|
label: indexer.userAgent ? indexer.userAgent : 'Other',
|
||||||
|
value: indexer.numberOfGrabs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
data.sort((a, b) => {
|
||||||
|
return b.value - a.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserAgentQueryData(indexerStats: IndexerStatsUserAgent[]) {
|
||||||
|
const data = indexerStats.map((indexer) => {
|
||||||
|
return {
|
||||||
|
label: indexer.userAgent ? indexer.userAgent : 'Other',
|
||||||
|
value: indexer.numberOfQueries,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
data.sort((a, b) => {
|
||||||
|
return b.value - a.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHostGrabsData(indexerStats: IndexerStatsHost[]) {
|
||||||
|
const data = indexerStats.map((indexer) => {
|
||||||
|
return {
|
||||||
|
label: indexer.host ? indexer.host : 'Other',
|
||||||
|
value: indexer.numberOfGrabs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
data.sort((a, b) => {
|
||||||
|
return b.value - a.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHostQueryData(indexerStats: IndexerStatsHost[]) {
|
||||||
|
const data = indexerStats.map((indexer) => {
|
||||||
|
return {
|
||||||
|
label: indexer.host ? indexer.host : 'Other',
|
||||||
|
value: indexer.numberOfQueries,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
data.sort((a, b) => {
|
||||||
|
return b.value - a.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexerStatsSelector = () => {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.indexerStats,
|
||||||
|
(indexerStats: IndexerStatsAppState) => {
|
||||||
|
return indexerStats;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function IndexerStats() {
|
||||||
|
const { isFetching, isPopulated, item, error, filters, selectedFilterKey } =
|
||||||
|
useSelector(indexerStatsSelector());
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchIndexerStats());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const onFilterSelect = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
dispatch(setIndexerStatsFilter({ selectedFilterKey: value }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoaded = !error && isPopulated;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContent>
|
||||||
|
<PageToolbar>
|
||||||
|
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}>
|
||||||
|
<IndexerStatsFilterMenu
|
||||||
|
selectedFilterKey={selectedFilterKey}
|
||||||
|
filters={filters}
|
||||||
|
onFilterSelect={onFilterSelect}
|
||||||
|
isDisabled={false}
|
||||||
|
/>
|
||||||
|
</PageToolbarSection>
|
||||||
|
</PageToolbar>
|
||||||
|
<PageContentBody>
|
||||||
|
{isFetching && !isPopulated && <LoadingIndicator />}
|
||||||
|
|
||||||
|
{!isFetching && !!error && (
|
||||||
|
<Alert kind={kinds.DANGER}>
|
||||||
|
{getErrorMessage(error, 'Failed to load indexer stats from API')}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoaded && (
|
||||||
|
<div>
|
||||||
|
<div className={styles.fullWidthChart}>
|
||||||
|
<BarChart
|
||||||
|
data={getAverageResponseTimeData(item.indexers)}
|
||||||
|
title={translate('AverageResponseTimesMs')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fullWidthChart}>
|
||||||
|
<BarChart
|
||||||
|
data={getFailureRateData(item.indexers)}
|
||||||
|
title={translate('IndexerFailureRate')}
|
||||||
|
kind={kinds.WARNING}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.halfWidthChart}>
|
||||||
|
<StackedBarChart
|
||||||
|
data={getTotalRequestsData(item.indexers)}
|
||||||
|
title={translate('TotalIndexerQueries')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.halfWidthChart}>
|
||||||
|
<BarChart
|
||||||
|
data={getNumberGrabsData(item.indexers)}
|
||||||
|
title={translate('TotalIndexerSuccessfulGrabs')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.halfWidthChart}>
|
||||||
|
<BarChart
|
||||||
|
data={getUserAgentQueryData(item.userAgents)}
|
||||||
|
title={translate('TotalUserAgentQueries')}
|
||||||
|
horizontal={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.halfWidthChart}>
|
||||||
|
<BarChart
|
||||||
|
data={getUserAgentGrabsData(item.userAgents)}
|
||||||
|
title={translate('TotalUserAgentGrabs')}
|
||||||
|
horizontal={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.halfWidthChart}>
|
||||||
|
<DoughnutChart
|
||||||
|
data={getHostQueryData(item.hosts)}
|
||||||
|
title={translate('TotalHostQueries')}
|
||||||
|
horizontal={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.halfWidthChart}>
|
||||||
|
<DoughnutChart
|
||||||
|
data={getHostGrabsData(item.hosts)}
|
||||||
|
title={translate('TotalHostGrabs')}
|
||||||
|
horizontal={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContentBody>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IndexerStats;
|
27
frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx
Normal file
27
frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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;
|
@@ -1,261 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Alert from 'Components/Alert';
|
|
||||||
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 PageContent from 'Components/Page/PageContent';
|
|
||||||
import PageContentBody from 'Components/Page/PageContentBody';
|
|
||||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
|
||||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
|
||||||
import { align, kinds } from 'Helpers/Props';
|
|
||||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import StatsFilterMenu from './StatsFilterMenu';
|
|
||||||
import styles from './Stats.css';
|
|
||||||
|
|
||||||
function getAverageResponseTimeData(indexerStats) {
|
|
||||||
const data = indexerStats.map((indexer) => {
|
|
||||||
return {
|
|
||||||
label: indexer.indexerName,
|
|
||||||
value: indexer.averageResponseTime
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
data.sort((a, b) => {
|
|
||||||
return b.value - a.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFailureRateData(indexerStats) {
|
|
||||||
const data = indexerStats.map((indexer) => {
|
|
||||||
return {
|
|
||||||
label: indexer.indexerName,
|
|
||||||
value: (indexer.numberOfFailedQueries + indexer.numberOfFailedRssQueries + indexer.numberOfFailedAuthQueries + indexer.numberOfFailedGrabs) /
|
|
||||||
(indexer.numberOfQueries + indexer.numberOfRssQueries + indexer.numberOfAuthQueries + indexer.numberOfGrabs)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
data.sort((a, b) => {
|
|
||||||
return b.value - a.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTotalRequestsData(indexerStats) {
|
|
||||||
const data = {
|
|
||||||
labels: indexerStats.map((indexer) => indexer.indexerName),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: 'Search Queries',
|
|
||||||
data: indexerStats.map((indexer) => indexer.numberOfQueries)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Rss Queries',
|
|
||||||
data: indexerStats.map((indexer) => indexer.numberOfRssQueries)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Auth Queries',
|
|
||||||
data: indexerStats.map((indexer) => indexer.numberOfAuthQueries)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNumberGrabsData(indexerStats) {
|
|
||||||
const data = indexerStats.map((indexer) => {
|
|
||||||
return {
|
|
||||||
label: indexer.indexerName,
|
|
||||||
value: indexer.numberOfGrabs - indexer.numberOfFailedGrabs
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
data.sort((a, b) => {
|
|
||||||
return b.value - a.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserAgentGrabsData(indexerStats) {
|
|
||||||
const data = indexerStats.map((indexer) => {
|
|
||||||
return {
|
|
||||||
label: indexer.userAgent ? indexer.userAgent : 'Other',
|
|
||||||
value: indexer.numberOfGrabs
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
data.sort((a, b) => {
|
|
||||||
return b.value - a.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserAgentQueryData(indexerStats) {
|
|
||||||
const data = indexerStats.map((indexer) => {
|
|
||||||
return {
|
|
||||||
label: indexer.userAgent ? indexer.userAgent : 'Other',
|
|
||||||
value: indexer.numberOfQueries
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
data.sort((a, b) => {
|
|
||||||
return b.value - a.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHostGrabsData(indexerStats) {
|
|
||||||
const data = indexerStats.map((indexer) => {
|
|
||||||
return {
|
|
||||||
label: indexer.host ? indexer.host : 'Other',
|
|
||||||
value: indexer.numberOfGrabs
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
data.sort((a, b) => {
|
|
||||||
return b.value - a.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHostQueryData(indexerStats) {
|
|
||||||
const data = indexerStats.map((indexer) => {
|
|
||||||
return {
|
|
||||||
label: indexer.host ? indexer.host : 'Other',
|
|
||||||
value: indexer.numberOfQueries
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
data.sort((a, b) => {
|
|
||||||
return b.value - a.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stats(props) {
|
|
||||||
const {
|
|
||||||
item,
|
|
||||||
isFetching,
|
|
||||||
isPopulated,
|
|
||||||
error,
|
|
||||||
filters,
|
|
||||||
selectedFilterKey,
|
|
||||||
onFilterSelect
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const isLoaded = !!(!error && isPopulated);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContent>
|
|
||||||
<PageToolbar>
|
|
||||||
<PageToolbarSection
|
|
||||||
alignContent={align.RIGHT}
|
|
||||||
collapseButtons={false}
|
|
||||||
>
|
|
||||||
<StatsFilterMenu
|
|
||||||
selectedFilterKey={selectedFilterKey}
|
|
||||||
filters={filters}
|
|
||||||
onFilterSelect={onFilterSelect}
|
|
||||||
isDisabled={false}
|
|
||||||
/>
|
|
||||||
</PageToolbarSection>
|
|
||||||
</PageToolbar>
|
|
||||||
<PageContentBody>
|
|
||||||
{
|
|
||||||
isFetching && !isPopulated &&
|
|
||||||
<LoadingIndicator />
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!isFetching && !!error &&
|
|
||||||
<Alert kind={kinds.DANGER}>
|
|
||||||
{getErrorMessage(error, 'Failed to load indexer stats from API')}
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
isLoaded &&
|
|
||||||
<div>
|
|
||||||
<div className={styles.fullWidthChart}>
|
|
||||||
<BarChart
|
|
||||||
data={getAverageResponseTimeData(item.indexers)}
|
|
||||||
title={translate('AverageResponseTimesMs')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.fullWidthChart}>
|
|
||||||
<BarChart
|
|
||||||
data={getFailureRateData(item.indexers)}
|
|
||||||
title={translate('IndexerFailureRate')}
|
|
||||||
kind={kinds.WARNING}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.halfWidthChart}>
|
|
||||||
<StackedBarChart
|
|
||||||
data={getTotalRequestsData(item.indexers)}
|
|
||||||
title={translate('TotalIndexerQueries')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.halfWidthChart}>
|
|
||||||
<BarChart
|
|
||||||
data={getNumberGrabsData(item.indexers)}
|
|
||||||
title={translate('TotalIndexerSuccessfulGrabs')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.halfWidthChart}>
|
|
||||||
<BarChart
|
|
||||||
data={getUserAgentQueryData(item.userAgents)}
|
|
||||||
title={translate('TotalUserAgentQueries')}
|
|
||||||
horizontal={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.halfWidthChart}>
|
|
||||||
<BarChart
|
|
||||||
data={getUserAgentGrabsData(item.userAgents)}
|
|
||||||
title={translate('TotalUserAgentGrabs')}
|
|
||||||
horizontal={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.halfWidthChart}>
|
|
||||||
<DoughnutChart
|
|
||||||
data={getHostQueryData(item.hosts)}
|
|
||||||
title={translate('TotalHostQueries')}
|
|
||||||
horizontal={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.halfWidthChart}>
|
|
||||||
<DoughnutChart
|
|
||||||
data={getHostGrabsData(item.hosts)}
|
|
||||||
title={translate('TotalHostGrabs')}
|
|
||||||
horizontal={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</PageContentBody>
|
|
||||||
</PageContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Stats.propTypes = {
|
|
||||||
item: PropTypes.object.isRequired,
|
|
||||||
isFetching: PropTypes.bool.isRequired,
|
|
||||||
isPopulated: PropTypes.bool.isRequired,
|
|
||||||
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
selectedFilterKey: PropTypes.string.isRequired,
|
|
||||||
onFilterSelect: PropTypes.func.isRequired,
|
|
||||||
error: PropTypes.object,
|
|
||||||
data: PropTypes.object
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Stats;
|
|
@@ -1,51 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { fetchIndexerStats, setIndexerStatsFilter } from 'Store/Actions/indexerStatsActions';
|
|
||||||
import Stats from './Stats';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.indexerStats,
|
|
||||||
(indexerStats) => indexerStats
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMapDispatchToProps(dispatch, props) {
|
|
||||||
return {
|
|
||||||
onFilterSelect(selectedFilterKey) {
|
|
||||||
dispatch(setIndexerStatsFilter({ selectedFilterKey }));
|
|
||||||
},
|
|
||||||
dispatchFetchIndexerStats() {
|
|
||||||
dispatch(fetchIndexerStats());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class StatsConnector extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.dispatchFetchIndexerStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Stats
|
|
||||||
{...this.props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StatsConnector.propTypes = {
|
|
||||||
dispatchFetchIndexerStats: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps, createMapDispatchToProps)(StatsConnector);
|
|
@@ -1,37 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
|
||||||
import { align } from 'Helpers/Props';
|
|
||||||
|
|
||||||
function StatsFilterMenu(props) {
|
|
||||||
const {
|
|
||||||
selectedFilterKey,
|
|
||||||
filters,
|
|
||||||
isDisabled,
|
|
||||||
onFilterSelect
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FilterMenu
|
|
||||||
alignMenu={align.RIGHT}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
selectedFilterKey={selectedFilterKey}
|
|
||||||
filters={filters}
|
|
||||||
customFilters={[]}
|
|
||||||
onFilterSelect={onFilterSelect}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
StatsFilterMenu.propTypes = {
|
|
||||||
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
|
||||||
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
isDisabled: PropTypes.bool.isRequired,
|
|
||||||
onFilterSelect: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
StatsFilterMenu.defaultProps = {
|
|
||||||
showCustomFilters: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StatsFilterMenu;
|
|
@@ -1,24 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import FilterModal from 'Components/Filter/FilterModal';
|
|
||||||
import { setIndexerStatsFilter } from 'Store/Actions/indexerStatsActions';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.indexerStats.items,
|
|
||||||
(state) => state.indexerStats.filterBuilderProps,
|
|
||||||
(sectionItems, filterBuilderProps) => {
|
|
||||||
return {
|
|
||||||
sectionItems,
|
|
||||||
filterBuilderProps,
|
|
||||||
customFilterType: 'indexerStats'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
dispatchSetFilter: setIndexerStatsFilter
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
|
|
31
frontend/src/typings/IndexerStats.ts
Normal file
31
frontend/src/typings/IndexerStats.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export interface IndexerStatsIndexer {
|
||||||
|
indexerId: number;
|
||||||
|
indexerName: string;
|
||||||
|
averageResponseTime: number;
|
||||||
|
numberOfQueries: number;
|
||||||
|
numberOfGrabs: number;
|
||||||
|
numberOfRssQueries: number;
|
||||||
|
numberOfAuthQueries: number;
|
||||||
|
numberOfFailedQueries: number;
|
||||||
|
numberOfFailedGrabs: number;
|
||||||
|
numberOfFailedRssQueries: number;
|
||||||
|
numberOfFailedAuthQueries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexerStatsUserAgent {
|
||||||
|
userAgent: string;
|
||||||
|
numberOfQueries: number;
|
||||||
|
numberOfGrabs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexerStatsHost {
|
||||||
|
host: string;
|
||||||
|
numberOfQueries: number;
|
||||||
|
numberOfGrabs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexerStats {
|
||||||
|
indexers: IndexerStatsIndexer[];
|
||||||
|
userAgents: IndexerStatsUserAgent[];
|
||||||
|
hosts: IndexerStatsHost[];
|
||||||
|
}
|
@@ -56,6 +56,7 @@
|
|||||||
"Artist": "Artist",
|
"Artist": "Artist",
|
||||||
"AudioSearch": "Audio Search",
|
"AudioSearch": "Audio Search",
|
||||||
"Auth": "Auth",
|
"Auth": "Auth",
|
||||||
|
"AuthQueries": "Auth Queries",
|
||||||
"Authentication": "Authentication",
|
"Authentication": "Authentication",
|
||||||
"AuthenticationMethodHelpText": "Require Username and Password to access Prowlarr",
|
"AuthenticationMethodHelpText": "Require Username and Password to access Prowlarr",
|
||||||
"AuthenticationRequired": "Authentication Required",
|
"AuthenticationRequired": "Authentication Required",
|
||||||
@@ -398,6 +399,7 @@
|
|||||||
"Result": "Result",
|
"Result": "Result",
|
||||||
"Retention": "Retention",
|
"Retention": "Retention",
|
||||||
"RssFeed": "RSS Feed",
|
"RssFeed": "RSS Feed",
|
||||||
|
"RssQueries": "RSS Queries",
|
||||||
"SSLCertPassword": "SSL Cert Password",
|
"SSLCertPassword": "SSL Cert Password",
|
||||||
"SSLCertPasswordHelpText": "Password for pfx file",
|
"SSLCertPasswordHelpText": "Password for pfx file",
|
||||||
"SSLCertPath": "SSL Cert Path",
|
"SSLCertPath": "SSL Cert Path",
|
||||||
@@ -413,6 +415,7 @@
|
|||||||
"SearchCapabilities": "Search Capabilities",
|
"SearchCapabilities": "Search Capabilities",
|
||||||
"SearchCountIndexers": "Search {0} indexers",
|
"SearchCountIndexers": "Search {0} indexers",
|
||||||
"SearchIndexers": "Search Indexers",
|
"SearchIndexers": "Search Indexers",
|
||||||
|
"SearchQueries": "Search Queries",
|
||||||
"SearchType": "Search Type",
|
"SearchType": "Search Type",
|
||||||
"SearchTypes": "Search Types",
|
"SearchTypes": "Search Types",
|
||||||
"Season": "Season",
|
"Season": "Season",
|
||||||
|
Reference in New Issue
Block a user