mirror of
https://github.com/Sonarr/Sonarr.git
synced 2025-04-24 06:17:08 -04:00
Convert Log Events to React Query
This commit is contained in:
parent
5d7c94f8e9
commit
5342416659
20 changed files with 585 additions and 261 deletions
|
@ -11,7 +11,10 @@ type AddSeriesPayload = AddSeries & AddSeriesOptions;
|
|||
|
||||
export const useLookupSeries = (query: string) => {
|
||||
return useApiQuery<AddSeries[]>({
|
||||
path: `/series/lookup?term=${query}`,
|
||||
path: '/series/lookup',
|
||||
queryParams: {
|
||||
term: query,
|
||||
},
|
||||
queryOptions: {
|
||||
enabled: !!query,
|
||||
// Disable refetch on window focus to prevent refetching when the user switch tabs
|
||||
|
|
|
@ -49,7 +49,6 @@ export interface PropertyFilter {
|
|||
export interface Filter {
|
||||
key: string;
|
||||
label: string | (() => string);
|
||||
type: string;
|
||||
filters: PropertyFilter[];
|
||||
}
|
||||
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import LogEvent from 'typings/LogEvent';
|
||||
|
||||
interface LogsAppState
|
||||
extends AppSectionState<LogEvent>,
|
||||
AppSectionFilterState<LogEvent>,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState {}
|
||||
|
||||
export default LogsAppState;
|
|
@ -6,7 +6,6 @@ import Task from 'typings/Task';
|
|||
import Update from 'typings/Update';
|
||||
import AppSectionState, { AppSectionItemState } from './AppSectionState';
|
||||
import BackupAppState from './BackupAppState';
|
||||
import LogsAppState from './LogsAppState';
|
||||
|
||||
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
|
||||
export type HealthAppState = AppSectionState<Health>;
|
||||
|
@ -20,7 +19,6 @@ interface SystemAppState {
|
|||
diskSpace: DiskSpaceAppState;
|
||||
health: HealthAppState;
|
||||
logFiles: LogFilesAppState;
|
||||
logs: LogsAppState;
|
||||
status: SystemStatusAppState;
|
||||
tasks: TaskAppState;
|
||||
updateLogFiles: LogFilesAppState;
|
||||
|
|
|
@ -8,7 +8,7 @@ interface Column {
|
|||
name: string;
|
||||
label: string | PropertyFunction<string> | React.ReactNode;
|
||||
className?: string;
|
||||
columnLabel?: string;
|
||||
columnLabel?: string | PropertyFunction<string>;
|
||||
isSortable?: boolean;
|
||||
fixedSortDirection?: SortDirection;
|
||||
isVisible: boolean;
|
||||
|
|
|
@ -14,10 +14,10 @@ interface TablePagerProps {
|
|||
totalPages?: number;
|
||||
totalRecords?: number;
|
||||
isFetching?: boolean;
|
||||
onFirstPagePress: () => void;
|
||||
onPreviousPagePress: () => void;
|
||||
onNextPagePress: () => void;
|
||||
onLastPagePress: () => void;
|
||||
onFirstPagePress?: () => void;
|
||||
onPreviousPagePress?: () => void;
|
||||
onNextPagePress?: () => void;
|
||||
onLastPagePress?: () => void;
|
||||
onPageSelect: (page: number) => void;
|
||||
}
|
||||
|
||||
|
@ -26,10 +26,6 @@ function TablePager({
|
|||
totalPages,
|
||||
totalRecords = 0,
|
||||
isFetching,
|
||||
onFirstPagePress,
|
||||
onPreviousPagePress,
|
||||
onNextPagePress,
|
||||
onLastPagePress,
|
||||
onPageSelect,
|
||||
}: TablePagerProps) {
|
||||
const [isShowingPageSelect, setIsShowingPageSelect] = useState(false);
|
||||
|
@ -64,6 +60,34 @@ function TablePager({
|
|||
setIsShowingPageSelect(false);
|
||||
}, []);
|
||||
|
||||
const handleFirstPagePress = useCallback(() => {
|
||||
onPageSelect(1);
|
||||
}, [onPageSelect]);
|
||||
|
||||
const onPreviousPagePress = useCallback(() => {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
onPageSelect(page - 1);
|
||||
}, [onPageSelect, page]);
|
||||
|
||||
const onNextPagePress = useCallback(() => {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
onPageSelect(page + 1);
|
||||
}, [onPageSelect, page]);
|
||||
|
||||
const onLastPagePress = useCallback(() => {
|
||||
if (!totalPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
onPageSelect(totalPages);
|
||||
}, [onPageSelect, totalPages]);
|
||||
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
|
@ -84,7 +108,7 @@ function TablePager({
|
|||
isFirstPage && styles.disabledPageButton
|
||||
)}
|
||||
isDisabled={isFirstPage}
|
||||
onPress={onFirstPagePress}
|
||||
onPress={handleFirstPagePress}
|
||||
>
|
||||
<Icon name={icons.PAGE_FIRST} />
|
||||
</Link>
|
||||
|
|
|
@ -2,23 +2,25 @@ import { UndefinedInitialDataOptions, useQuery } from '@tanstack/react-query';
|
|||
import { useMemo } from 'react';
|
||||
import fetchJson, {
|
||||
ApiError,
|
||||
apiRoot,
|
||||
FetchJsonOptions,
|
||||
} from 'Utilities/Fetch/fetchJson';
|
||||
import getQueryPath from 'Utilities/Fetch/getQueryPath';
|
||||
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
|
||||
|
||||
interface QueryOptions<T> extends FetchJsonOptions<unknown> {
|
||||
export interface QueryOptions<T> extends FetchJsonOptions<unknown> {
|
||||
queryParams?: QueryParams;
|
||||
queryOptions?:
|
||||
| Omit<UndefinedInitialDataOptions<T, ApiError>, 'queryKey' | 'queryFn'>
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function useApiQuery<T>(options: QueryOptions<T>) {
|
||||
const useApiQuery = <T>(options: QueryOptions<T>) => {
|
||||
const requestOptions = useMemo(() => {
|
||||
const { queryOptions, ...otherOptions } = options;
|
||||
const { path: path, queryOptions, queryParams, ...otherOptions } = options;
|
||||
|
||||
return {
|
||||
...otherOptions,
|
||||
path: apiRoot + options.path,
|
||||
path: getQueryPath(path) + getQueryString(queryParams),
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
|
@ -32,6 +34,6 @@ function useApiQuery<T>(options: QueryOptions<T>) {
|
|||
queryFn: async ({ signal }) =>
|
||||
fetchJson<T, unknown>({ ...requestOptions, signal }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default useApiQuery;
|
||||
|
|
36
frontend/src/Helpers/Hooks/usePage.ts
Normal file
36
frontend/src/Helpers/Hooks/usePage.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface PageStore {
|
||||
events: number;
|
||||
}
|
||||
|
||||
const pageStore = create<PageStore>(() => ({
|
||||
events: 1,
|
||||
}));
|
||||
|
||||
const usePage = (kind: keyof PageStore) => {
|
||||
const { action } = useHistory();
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
pageStore.setState({ [kind]: page });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (action === 'POP') {
|
||||
pageStore.setState({ [kind]: 1 });
|
||||
}
|
||||
}, [action, kind]);
|
||||
|
||||
return {
|
||||
page: pageStore((state) => state[kind]),
|
||||
goToPage,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePage;
|
||||
|
||||
export const resetPage = (kind: keyof PageStore) => {
|
||||
pageStore.setState({ [kind]: 1 });
|
||||
};
|
81
frontend/src/Helpers/Hooks/usePagedApiQuery.ts
Normal file
81
frontend/src/Helpers/Hooks/usePagedApiQuery.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { PropertyFilter } from 'App/State/AppState';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import fetchJson from 'Utilities/Fetch/fetchJson';
|
||||
import getQueryPath from 'Utilities/Fetch/getQueryPath';
|
||||
import getQueryString from 'Utilities/Fetch/getQueryString';
|
||||
import { QueryOptions } from './useApiQuery';
|
||||
|
||||
interface PagedQueryOptions<T> extends QueryOptions<PagedQueryResponse<T>> {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
filters?: PropertyFilter[];
|
||||
}
|
||||
|
||||
interface PagedQueryResponse<T> {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortKey: string;
|
||||
sortDirection: string;
|
||||
totalRecords: number;
|
||||
totalPages: number;
|
||||
records: T[];
|
||||
}
|
||||
|
||||
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
||||
const requestOptions = useMemo(() => {
|
||||
const {
|
||||
path,
|
||||
page,
|
||||
pageSize,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
filters,
|
||||
queryParams,
|
||||
queryOptions,
|
||||
...otherOptions
|
||||
} = options;
|
||||
|
||||
return {
|
||||
...otherOptions,
|
||||
path:
|
||||
getQueryPath(path) +
|
||||
getQueryString({
|
||||
...queryParams,
|
||||
page,
|
||||
pageSize,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
filters,
|
||||
}),
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
return useQuery({
|
||||
...options.queryOptions,
|
||||
queryKey: [requestOptions.path],
|
||||
queryFn: async ({ signal }) => {
|
||||
const response = await fetchJson<PagedQueryResponse<T>, unknown>({
|
||||
...requestOptions,
|
||||
signal,
|
||||
});
|
||||
|
||||
return {
|
||||
...response,
|
||||
totalPages: Math.max(
|
||||
Math.ceil(response.totalRecords / options.pageSize),
|
||||
1
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default usePagedApiQuery;
|
|
@ -1,5 +1,6 @@
|
|||
import { create, type StateCreator } from 'zustand';
|
||||
import { persist, type PersistOptions } from 'zustand/middleware';
|
||||
import Column from 'Components/Table/Column';
|
||||
|
||||
export const createPersist = <T>(
|
||||
name: string,
|
||||
|
@ -18,3 +19,56 @@ export const createPersist = <T>(
|
|||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const mergeColumns = <T extends { columns: Column[] }>(
|
||||
persistedState: unknown,
|
||||
currentState: T
|
||||
) => {
|
||||
const currentColumns = currentState.columns;
|
||||
const persistedColumns = (persistedState as T).columns;
|
||||
const columns: Column[] = [];
|
||||
|
||||
// Add persisted columns in the same order they're currently in
|
||||
// as long as they haven't been removed.
|
||||
|
||||
persistedColumns.forEach((persistedColumn) => {
|
||||
const column = currentColumns.find((i) => i.name === persistedColumn.name);
|
||||
|
||||
if (column) {
|
||||
const newColumn: Partial<Column> = {};
|
||||
|
||||
// We can't use a spread operator or Object.assign to clone the column
|
||||
// or any accessors are lost and can break translations.
|
||||
for (const prop of Object.keys(column)) {
|
||||
const attributes = Object.getOwnPropertyDescriptor(column, prop);
|
||||
|
||||
if (!attributes) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(newColumn, prop, attributes);
|
||||
}
|
||||
|
||||
newColumn.isVisible = persistedColumn.isVisible;
|
||||
|
||||
columns.push(newColumn as Column);
|
||||
}
|
||||
});
|
||||
|
||||
// Add any columns added to the app in the initial position.
|
||||
currentColumns.forEach((currentColumn, index) => {
|
||||
const persistedColumnIndex = persistedColumns.findIndex(
|
||||
(i) => i.name === currentColumn.name
|
||||
);
|
||||
const column = Object.assign({}, currentColumn);
|
||||
|
||||
if (persistedColumnIndex === -1) {
|
||||
columns.splice(index, 0, column);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...(persistedState as T),
|
||||
columns,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,18 +1,12 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
import { filterTypes, sortDirections } from 'Helpers/Props';
|
||||
import { setAppValue } from 'Store/Actions/appActions';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { pingServer } from './appActions';
|
||||
import { set } from './baseActions';
|
||||
import createFetchHandler from './Creators/createFetchHandler';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
|
||||
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
|
||||
import createClearReducer from './Creators/Reducers/createClearReducer';
|
||||
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
@ -70,95 +64,6 @@ export const defaultState = {
|
|||
items: []
|
||||
},
|
||||
|
||||
logs: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
pageSize: 50,
|
||||
sortKey: 'time',
|
||||
sortDirection: sortDirections.DESCENDING,
|
||||
error: null,
|
||||
items: [],
|
||||
|
||||
columns: [
|
||||
{
|
||||
name: 'level',
|
||||
columnLabel: () => translate('Level'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
label: () => translate('Time'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
},
|
||||
{
|
||||
name: 'logger',
|
||||
label: () => translate('Component'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
label: () => translate('Message'),
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
columnLabel: () => translate('Actions'),
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
}
|
||||
],
|
||||
|
||||
selectedFilterKey: 'all',
|
||||
|
||||
filters: [
|
||||
{
|
||||
key: 'all',
|
||||
label: () => translate('All'),
|
||||
filters: []
|
||||
},
|
||||
{
|
||||
key: 'info',
|
||||
label: () => translate('Info'),
|
||||
filters: [
|
||||
{
|
||||
key: 'level',
|
||||
value: 'info',
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'warn',
|
||||
label: () => translate('Warn'),
|
||||
filters: [
|
||||
{
|
||||
key: 'level',
|
||||
value: 'warn',
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'error',
|
||||
label: () => translate('Error'),
|
||||
filters: [
|
||||
{
|
||||
key: 'level',
|
||||
value: 'error',
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
logFiles: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
|
@ -174,13 +79,6 @@ export const defaultState = {
|
|||
}
|
||||
};
|
||||
|
||||
export const persistState = [
|
||||
'system.logs.pageSize',
|
||||
'system.logs.sortKey',
|
||||
'system.logs.sortDirection',
|
||||
'system.logs.selectedFilterKey'
|
||||
];
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
|
@ -198,17 +96,6 @@ export const DELETE_BACKUP = 'system/backups/deleteBackup';
|
|||
|
||||
export const FETCH_UPDATES = 'system/updates/fetchUpdates';
|
||||
|
||||
export const FETCH_LOGS = 'system/logs/fetchLogs';
|
||||
export const GOTO_FIRST_LOGS_PAGE = 'system/logs/gotoLogsFirstPage';
|
||||
export const GOTO_PREVIOUS_LOGS_PAGE = 'system/logs/gotoLogsPreviousPage';
|
||||
export const GOTO_NEXT_LOGS_PAGE = 'system/logs/gotoLogsNextPage';
|
||||
export const GOTO_LAST_LOGS_PAGE = 'system/logs/gotoLogsLastPage';
|
||||
export const GOTO_LOGS_PAGE = 'system/logs/gotoLogsPage';
|
||||
export const SET_LOGS_SORT = 'system/logs/setLogsSort';
|
||||
export const SET_LOGS_FILTER = 'system/logs/setLogsFilter';
|
||||
export const SET_LOGS_TABLE_OPTION = 'system/logs/setLogsTableOption';
|
||||
export const CLEAR_LOGS_TABLE = 'system/logs/clearLogsTable';
|
||||
|
||||
export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles';
|
||||
export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles';
|
||||
|
||||
|
@ -232,17 +119,6 @@ export const deleteBackup = createThunk(DELETE_BACKUP);
|
|||
|
||||
export const fetchUpdates = createThunk(FETCH_UPDATES);
|
||||
|
||||
export const fetchLogs = createThunk(FETCH_LOGS);
|
||||
export const gotoLogsFirstPage = createThunk(GOTO_FIRST_LOGS_PAGE);
|
||||
export const gotoLogsPreviousPage = createThunk(GOTO_PREVIOUS_LOGS_PAGE);
|
||||
export const gotoLogsNextPage = createThunk(GOTO_NEXT_LOGS_PAGE);
|
||||
export const gotoLogsLastPage = createThunk(GOTO_LAST_LOGS_PAGE);
|
||||
export const gotoLogsPage = createThunk(GOTO_LOGS_PAGE);
|
||||
export const setLogsSort = createThunk(SET_LOGS_SORT);
|
||||
export const setLogsFilter = createThunk(SET_LOGS_FILTER);
|
||||
export const setLogsTableOption = createAction(SET_LOGS_TABLE_OPTION);
|
||||
export const clearLogsTable = createAction(CLEAR_LOGS_TABLE);
|
||||
|
||||
export const fetchLogFiles = createThunk(FETCH_LOG_FILES);
|
||||
export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES);
|
||||
|
||||
|
@ -328,22 +204,6 @@ export const actionHandlers = handleThunks({
|
|||
[FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'),
|
||||
[FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'),
|
||||
|
||||
...createServerSideCollectionHandlers(
|
||||
'system.logs',
|
||||
'/log',
|
||||
fetchLogs,
|
||||
{
|
||||
[serverSideCollectionHandlers.FETCH]: FETCH_LOGS,
|
||||
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_LOGS_PAGE,
|
||||
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_LOGS_PAGE,
|
||||
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_LOGS_PAGE,
|
||||
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_LOGS_PAGE,
|
||||
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_LOGS_PAGE,
|
||||
[serverSideCollectionHandlers.SORT]: SET_LOGS_SORT,
|
||||
[serverSideCollectionHandlers.FILTER]: SET_LOGS_FILTER
|
||||
}
|
||||
),
|
||||
|
||||
[RESTART]: function(getState, payload, dispatch) {
|
||||
const promise = createAjaxRequest({
|
||||
url: '/system/restart',
|
||||
|
@ -378,17 +238,6 @@ export const reducers = createHandleActions({
|
|||
restoreError: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
[SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs'),
|
||||
|
||||
[CLEAR_LOGS_TABLE]: createClearReducer(section, {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
items: [],
|
||||
totalPages: 0,
|
||||
totalRecords: 0
|
||||
})
|
||||
}
|
||||
|
||||
}, defaultState, section);
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
|
@ -14,106 +13,71 @@ import Table from 'Components/Table/Table';
|
|||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import usePaging from 'Components/Table/usePaging';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import {
|
||||
fetchLogs,
|
||||
gotoLogsFirstPage,
|
||||
gotoLogsPage,
|
||||
setLogsFilter,
|
||||
setLogsSort,
|
||||
setLogsTableOption,
|
||||
} from 'Store/Actions/systemActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import {
|
||||
setEventOption,
|
||||
setEventOptions,
|
||||
useEventOptions,
|
||||
} from './eventOptionsStore';
|
||||
import LogsTableRow from './LogsTableRow';
|
||||
import useEvents, { useFilters } from './useEvents';
|
||||
|
||||
function LogsTable() {
|
||||
const dispatch = useDispatch();
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
const { data, error, isFetching, isFetched, isLoading, page, goToPage } =
|
||||
useEvents();
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
filters,
|
||||
selectedFilterKey,
|
||||
} = useSelector((state: AppState) => state.system.logs);
|
||||
const { records = [], totalPages = 0, totalRecords } = data ?? {};
|
||||
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useEventOptions();
|
||||
|
||||
const filters = useFilters();
|
||||
|
||||
const isClearLogExecuting = useSelector(
|
||||
createCommandExecutingSelector(commandNames.CLEAR_LOGS)
|
||||
);
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoLogsPage,
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setLogsFilter({ selectedFilterKey }));
|
||||
setEventOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setLogsSort({ sortKey }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
const handleSortPress = useCallback((sortKey: string) => {
|
||||
setEventOption('sortKey', sortKey);
|
||||
}, []);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
dispatch(setLogsTableOption(payload));
|
||||
setEventOptions(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
dispatch(gotoLogsFirstPage({ page: 1 }));
|
||||
goToPage(1);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
dispatch(gotoLogsFirstPage());
|
||||
}, [dispatch]);
|
||||
goToPage(1);
|
||||
}, [goToPage]);
|
||||
|
||||
const handleClearLogsPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.CLEAR_LOGS,
|
||||
commandFinished: () => {
|
||||
dispatch(gotoLogsFirstPage());
|
||||
goToPage(1);
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchLogs());
|
||||
} else {
|
||||
dispatch(gotoLogsFirstPage({ page: 1 }));
|
||||
}
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
}, [dispatch, goToPage]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('Logs')}>
|
||||
|
@ -159,13 +123,13 @@ function LogsTable() {
|
|||
</PageToolbar>
|
||||
|
||||
<PageContentBody>
|
||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||
{isLoading ? <LoadingIndicator /> : null}
|
||||
|
||||
{isPopulated && !error && !items.length ? (
|
||||
{isFetched && !error && !records.length ? (
|
||||
<Alert kind={kinds.INFO}>{translate('NoEventsFound')}</Alert>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !error && items.length ? (
|
||||
{isFetched && !error && records.length ? (
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
|
@ -176,7 +140,7 @@ function LogsTable() {
|
|||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
{records.map((item) => {
|
||||
return (
|
||||
<LogsTableRow key={item.id} columns={columns} {...item} />
|
||||
);
|
||||
|
@ -189,11 +153,7 @@ function LogsTable() {
|
|||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
onPageSelect={goToPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
@ -14,7 +14,7 @@ interface LogsTableDetailsModalProps {
|
|||
isOpen: boolean;
|
||||
message: string;
|
||||
exception?: string;
|
||||
onModalClose: (...args: unknown[]) => unknown;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function LogsTableDetailsModal({
|
||||
|
@ -38,7 +38,7 @@ function LogsTableDetailsModal({
|
|||
{message}
|
||||
</Scroller>
|
||||
|
||||
{!!exception && (
|
||||
{exception ? (
|
||||
<div>
|
||||
<div>{translate('Exception')}</div>
|
||||
<Scroller
|
||||
|
@ -48,7 +48,7 @@ function LogsTableDetailsModal({
|
|||
{exception}
|
||||
</Scroller>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
|
|
85
frontend/src/System/Events/eventOptionsStore.tsx
Normal file
85
frontend/src/System/Events/eventOptionsStore.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import Column from 'Components/Table/Column';
|
||||
import { createPersist, mergeColumns } from 'Helpers/createPersist';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
export interface EventOptions {
|
||||
pageSize: number;
|
||||
selectedFilterKey: string | number;
|
||||
sortKey: string;
|
||||
sortDirection: SortDirection;
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
const eventOptionsStore = createPersist<EventOptions>(
|
||||
'event_options',
|
||||
() => {
|
||||
return {
|
||||
pageSize: 50,
|
||||
selectedFilterKey: 'all',
|
||||
sortKey: 'time',
|
||||
sortDirection: 'descending',
|
||||
columns: [
|
||||
{
|
||||
name: 'level',
|
||||
label: '',
|
||||
columnLabel: () => translate('Level'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
label: () => translate('Time'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
{
|
||||
name: 'logger',
|
||||
label: () => translate('Component'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
label: () => translate('Message'),
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: '',
|
||||
columnLabel: () => translate('Actions'),
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
{
|
||||
merge: mergeColumns,
|
||||
}
|
||||
);
|
||||
|
||||
export const useEventOptions = () => {
|
||||
return eventOptionsStore((state) => state);
|
||||
};
|
||||
|
||||
export const setEventOptions = (options: Partial<EventOptions>) => {
|
||||
eventOptionsStore.setState((state) => ({
|
||||
...state,
|
||||
...options,
|
||||
}));
|
||||
};
|
||||
|
||||
export const setEventOption = <K extends keyof EventOptions>(
|
||||
key: K,
|
||||
value: EventOptions[K]
|
||||
) => {
|
||||
eventOptionsStore.setState((state) => ({
|
||||
...state,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
92
frontend/src/System/Events/useEvents.ts
Normal file
92
frontend/src/System/Events/useEvents.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Filter } from 'App/State/AppState';
|
||||
import usePage from 'Helpers/Hooks/usePage';
|
||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||
import LogEvent from 'typings/LogEvent';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useEventOptions } from './eventOptionsStore';
|
||||
|
||||
export const FILTERS: Filter[] = [
|
||||
{
|
||||
key: 'all',
|
||||
label: () => translate('All'),
|
||||
filters: [],
|
||||
},
|
||||
{
|
||||
key: 'info',
|
||||
label: () => translate('Info'),
|
||||
filters: [
|
||||
{
|
||||
key: 'level',
|
||||
value: 'info',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'warn',
|
||||
label: () => translate('Warn'),
|
||||
filters: [
|
||||
{
|
||||
key: 'level',
|
||||
value: 'warn',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'error',
|
||||
label: () => translate('Error'),
|
||||
filters: [
|
||||
{
|
||||
key: 'level',
|
||||
value: 'error',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const useEvents = () => {
|
||||
const { page, goToPage } = usePage('events');
|
||||
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
||||
useEventOptions();
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return FILTERS.find((f) => f.key === selectedFilterKey)?.filters;
|
||||
}, [selectedFilterKey]);
|
||||
|
||||
const { refetch, ...query } = usePagedApiQuery<LogEvent>({
|
||||
path: '/log',
|
||||
page,
|
||||
pageSize,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
queryOptions: {
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
});
|
||||
|
||||
const handleGoToPage = useCallback(
|
||||
(page: number) => {
|
||||
goToPage(page);
|
||||
refetch();
|
||||
},
|
||||
[goToPage, refetch]
|
||||
);
|
||||
|
||||
return {
|
||||
...query,
|
||||
goToPage: handleGoToPage,
|
||||
page,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
export default useEvents;
|
||||
|
||||
export const useFilters = () => {
|
||||
return FILTERS;
|
||||
};
|
|
@ -33,6 +33,7 @@ export interface FetchJsonOptions<TData> extends Omit<RequestInit, 'body'> {
|
|||
timeout?: number;
|
||||
}
|
||||
|
||||
export const urlBase = window.Sonarr.urlBase;
|
||||
export const apiRoot = '/api/v5'; // window.Sonarr.apiRoot;
|
||||
|
||||
async function fetchJson<T, TData>({
|
||||
|
|
7
frontend/src/Utilities/Fetch/getQueryPath.ts
Normal file
7
frontend/src/Utilities/Fetch/getQueryPath.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { apiRoot, urlBase } from 'Utilities/Fetch/fetchJson';
|
||||
|
||||
const getQueryPath = (path: string) => {
|
||||
return urlBase + apiRoot + path;
|
||||
};
|
||||
|
||||
export default getQueryPath;
|
37
frontend/src/Utilities/Fetch/getQueryString.ts
Normal file
37
frontend/src/Utilities/Fetch/getQueryString.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { PropertyFilter } from 'App/State/AppState';
|
||||
|
||||
export interface QueryParams {
|
||||
[key: string]: string | number | boolean | PropertyFilter[] | undefined;
|
||||
}
|
||||
|
||||
const getQueryString = (queryParams?: QueryParams) => {
|
||||
if (!queryParams) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const filteredParams = Object.keys(queryParams).reduce<
|
||||
Record<string, string>
|
||||
>((acc, key) => {
|
||||
const value = queryParams[key];
|
||||
|
||||
if (value == null) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((filter) => {
|
||||
acc[filter.key] = String(filter.value);
|
||||
});
|
||||
} else {
|
||||
acc[key] = String(value);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const paramsString = new URLSearchParams(filteredParams).toString();
|
||||
|
||||
return `?${paramsString}`;
|
||||
};
|
||||
|
||||
export default getQueryString;
|
78
src/Sonarr.Api.V5/Logs/LogController.cs
Normal file
78
src/Sonarr.Api.V5/Logs/LogController.cs
Normal file
|
@ -0,0 +1,78 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Instrumentation;
|
||||
using Sonarr.Http;
|
||||
using Sonarr.Http.Extensions;
|
||||
|
||||
namespace Sonarr.Api.V5.Logs
|
||||
{
|
||||
[V5ApiController]
|
||||
public class LogController : Controller
|
||||
{
|
||||
private readonly ILogService _logService;
|
||||
private readonly IConfigFileProvider _configFileProvider;
|
||||
|
||||
public LogController(ILogService logService, IConfigFileProvider configFileProvider)
|
||||
{
|
||||
_logService = logService;
|
||||
_configFileProvider = configFileProvider;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Produces("application/json")]
|
||||
public PagingResource<LogResource> GetLogs([FromQuery] PagingRequestResource paging, string? level)
|
||||
{
|
||||
if (!_configFileProvider.LogDbEnabled)
|
||||
{
|
||||
return new PagingResource<LogResource>();
|
||||
}
|
||||
|
||||
var pagingResource = new PagingResource<LogResource>(paging);
|
||||
var pageSpec = pagingResource.MapToPagingSpec<LogResource, Log>(new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"id",
|
||||
"time"
|
||||
});
|
||||
|
||||
if (pageSpec.SortKey == "time")
|
||||
{
|
||||
pageSpec.SortKey = "id";
|
||||
}
|
||||
|
||||
if (level.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
switch (level)
|
||||
{
|
||||
case "fatal":
|
||||
pageSpec.FilterExpressions.Add(h => h.Level == "Fatal");
|
||||
break;
|
||||
case "error":
|
||||
pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error");
|
||||
break;
|
||||
case "warn":
|
||||
pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn");
|
||||
break;
|
||||
case "info":
|
||||
pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info");
|
||||
break;
|
||||
case "debug":
|
||||
pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug");
|
||||
break;
|
||||
case "trace":
|
||||
pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug" || h.Level == "Trace");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var response = pageSpec.ApplyToPage(_logService.Paged, LogResourceMapper.ToResource);
|
||||
|
||||
if (pageSpec.SortKey == "id")
|
||||
{
|
||||
response.SortKey = "time";
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
32
src/Sonarr.Api.V5/Logs/LogResource.cs
Normal file
32
src/Sonarr.Api.V5/Logs/LogResource.cs
Normal file
|
@ -0,0 +1,32 @@
|
|||
using NzbDrone.Core.Instrumentation;
|
||||
using Sonarr.Http.REST;
|
||||
|
||||
namespace Sonarr.Api.V5.Logs
|
||||
{
|
||||
public class LogResource : RestResource
|
||||
{
|
||||
public DateTime Time { get; set; }
|
||||
public string? Exception { get; set; }
|
||||
public string? ExceptionType { get; set; }
|
||||
public required string Level { get; set; }
|
||||
public required string Logger { get; set; }
|
||||
public required string Message { get; set; }
|
||||
}
|
||||
|
||||
public static class LogResourceMapper
|
||||
{
|
||||
public static LogResource ToResource(this Log model)
|
||||
{
|
||||
return new LogResource
|
||||
{
|
||||
Id = model.Id,
|
||||
Time = model.Time,
|
||||
Exception = model.Exception,
|
||||
ExceptionType = model.ExceptionType,
|
||||
Level = model.Level.ToLowerInvariant(),
|
||||
Logger = model.Logger,
|
||||
Message = model.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue