From 53424166596f1a21ca3ed97461463581cc87da6b Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Thu, 27 Feb 2025 20:20:56 -0800 Subject: [PATCH] Convert Log Events to React Query --- .../AddSeries/AddNewSeries/useAddSeries.ts | 5 +- frontend/src/App/State/AppState.ts | 1 - frontend/src/App/State/LogsAppState.ts | 14 -- frontend/src/App/State/SystemAppState.ts | 2 - frontend/src/Components/Table/Column.ts | 2 +- frontend/src/Components/Table/TablePager.tsx | 42 +++-- frontend/src/Helpers/Hooks/useApiQuery.ts | 14 +- frontend/src/Helpers/Hooks/usePage.ts | 36 +++++ .../src/Helpers/Hooks/usePagedApiQuery.ts | 81 ++++++++++ frontend/src/Helpers/createPersist.ts | 54 +++++++ frontend/src/Store/Actions/systemActions.js | 153 +----------------- frontend/src/System/Events/LogsTable.tsx | 106 ++++-------- .../System/Events/LogsTableDetailsModal.tsx | 6 +- .../src/System/Events/eventOptionsStore.tsx | 85 ++++++++++ frontend/src/System/Events/useEvents.ts | 92 +++++++++++ frontend/src/Utilities/Fetch/fetchJson.ts | 1 + frontend/src/Utilities/Fetch/getQueryPath.ts | 7 + .../src/Utilities/Fetch/getQueryString.ts | 37 +++++ src/Sonarr.Api.V5/Logs/LogController.cs | 78 +++++++++ src/Sonarr.Api.V5/Logs/LogResource.cs | 32 ++++ 20 files changed, 586 insertions(+), 262 deletions(-) delete mode 100644 frontend/src/App/State/LogsAppState.ts create mode 100644 frontend/src/Helpers/Hooks/usePage.ts create mode 100644 frontend/src/Helpers/Hooks/usePagedApiQuery.ts create mode 100644 frontend/src/System/Events/eventOptionsStore.tsx create mode 100644 frontend/src/System/Events/useEvents.ts create mode 100644 frontend/src/Utilities/Fetch/getQueryPath.ts create mode 100644 frontend/src/Utilities/Fetch/getQueryString.ts create mode 100644 src/Sonarr.Api.V5/Logs/LogController.cs create mode 100644 src/Sonarr.Api.V5/Logs/LogResource.cs diff --git a/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts b/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts index 10316a4cf..1ab2c6d32 100644 --- a/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts +++ b/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts @@ -11,7 +11,10 @@ type AddSeriesPayload = AddSeries & AddSeriesOptions; export const useLookupSeries = (query: string) => { return useApiQuery({ - 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 diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 6b9e6bee9..ae6ca95f4 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -49,7 +49,6 @@ export interface PropertyFilter { export interface Filter { key: string; label: string | (() => string); - type: string; filters: PropertyFilter[]; } diff --git a/frontend/src/App/State/LogsAppState.ts b/frontend/src/App/State/LogsAppState.ts deleted file mode 100644 index 3eca35496..000000000 --- a/frontend/src/App/State/LogsAppState.ts +++ /dev/null @@ -1,14 +0,0 @@ -import AppSectionState, { - AppSectionFilterState, - PagedAppSectionState, - TableAppSectionState, -} from 'App/State/AppSectionState'; -import LogEvent from 'typings/LogEvent'; - -interface LogsAppState - extends AppSectionState, - AppSectionFilterState, - PagedAppSectionState, - TableAppSectionState {} - -export default LogsAppState; diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts index 6f13b61e1..0c07849f8 100644 --- a/frontend/src/App/State/SystemAppState.ts +++ b/frontend/src/App/State/SystemAppState.ts @@ -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; export type HealthAppState = AppSectionState; @@ -20,7 +19,6 @@ interface SystemAppState { diskSpace: DiskSpaceAppState; health: HealthAppState; logFiles: LogFilesAppState; - logs: LogsAppState; status: SystemStatusAppState; tasks: TaskAppState; updateLogFiles: LogFilesAppState; diff --git a/frontend/src/Components/Table/Column.ts b/frontend/src/Components/Table/Column.ts index 22d22e963..7276f65f4 100644 --- a/frontend/src/Components/Table/Column.ts +++ b/frontend/src/Components/Table/Column.ts @@ -8,7 +8,7 @@ interface Column { name: string; label: string | PropertyFunction | React.ReactNode; className?: string; - columnLabel?: string; + columnLabel?: string | PropertyFunction; isSortable?: boolean; fixedSortDirection?: SortDirection; isVisible: boolean; diff --git a/frontend/src/Components/Table/TablePager.tsx b/frontend/src/Components/Table/TablePager.tsx index d21833de1..e38edbb51 100644 --- a/frontend/src/Components/Table/TablePager.tsx +++ b/frontend/src/Components/Table/TablePager.tsx @@ -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} > diff --git a/frontend/src/Helpers/Hooks/useApiQuery.ts b/frontend/src/Helpers/Hooks/useApiQuery.ts index 6cc0168b6..5ee535ddd 100644 --- a/frontend/src/Helpers/Hooks/useApiQuery.ts +++ b/frontend/src/Helpers/Hooks/useApiQuery.ts @@ -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 extends FetchJsonOptions { +export interface QueryOptions extends FetchJsonOptions { + queryParams?: QueryParams; queryOptions?: | Omit, 'queryKey' | 'queryFn'> | undefined; } -function useApiQuery(options: QueryOptions) { +const useApiQuery = (options: QueryOptions) => { 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(options: QueryOptions) { queryFn: async ({ signal }) => fetchJson({ ...requestOptions, signal }), }); -} +}; export default useApiQuery; diff --git a/frontend/src/Helpers/Hooks/usePage.ts b/frontend/src/Helpers/Hooks/usePage.ts new file mode 100644 index 000000000..5b677224d --- /dev/null +++ b/frontend/src/Helpers/Hooks/usePage.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; +import { useHistory } from 'react-router'; +import { create } from 'zustand'; + +interface PageStore { + events: number; +} + +const pageStore = create(() => ({ + 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 }); +}; diff --git a/frontend/src/Helpers/Hooks/usePagedApiQuery.ts b/frontend/src/Helpers/Hooks/usePagedApiQuery.ts new file mode 100644 index 000000000..5b9b88fdc --- /dev/null +++ b/frontend/src/Helpers/Hooks/usePagedApiQuery.ts @@ -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 extends QueryOptions> { + page: number; + pageSize: number; + sortKey?: string; + sortDirection?: SortDirection; + filters?: PropertyFilter[]; +} + +interface PagedQueryResponse { + page: number; + pageSize: number; + sortKey: string; + sortDirection: string; + totalRecords: number; + totalPages: number; + records: T[]; +} + +const usePagedApiQuery = (options: PagedQueryOptions) => { + 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, unknown>({ + ...requestOptions, + signal, + }); + + return { + ...response, + totalPages: Math.max( + Math.ceil(response.totalRecords / options.pageSize), + 1 + ), + }; + }, + }); +}; + +export default usePagedApiQuery; diff --git a/frontend/src/Helpers/createPersist.ts b/frontend/src/Helpers/createPersist.ts index e13f8a250..09fe75711 100644 --- a/frontend/src/Helpers/createPersist.ts +++ b/frontend/src/Helpers/createPersist.ts @@ -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 = ( name: string, @@ -18,3 +19,56 @@ export const createPersist = ( }) ); }; + +export const mergeColumns = ( + 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 = {}; + + // 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, + }; +}; diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js index 2c53bda7b..8e273b217 100644 --- a/frontend/src/Store/Actions/systemActions.js +++ b/frontend/src/Store/Actions/systemActions.js @@ -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); diff --git a/frontend/src/System/Events/LogsTable.tsx b/frontend/src/System/Events/LogsTable.tsx index 893d6b2d8..74b0e0cf1 100644 --- a/frontend/src/System/Events/LogsTable.tsx +++ b/frontend/src/System/Events/LogsTable.tsx @@ -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 { - isFetching, - isPopulated, - error, - items, - columns, - page, - pageSize, - totalPages, - totalRecords, - sortKey, - sortDirection, - filters, - selectedFilterKey, - } = useSelector((state: AppState) => state.system.logs); + const { data, error, isFetching, isFetched, isLoading, page, goToPage } = + useEvents(); + + 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 ( @@ -159,13 +123,13 @@ function LogsTable() { - {isFetching && !isPopulated ? : null} + {isLoading ? : null} - {isPopulated && !error && !items.length ? ( + {isFetched && !error && !records.length ? ( {translate('NoEventsFound')} ) : null} - {isPopulated && !error && items.length ? ( + {isFetched && !error && records.length ? (
- {items.map((item) => { + {records.map((item) => { return ( ); @@ -189,11 +153,7 @@ function LogsTable() { totalPages={totalPages} totalRecords={totalRecords} isFetching={isFetching} - onFirstPagePress={handleFirstPagePress} - onPreviousPagePress={handlePreviousPagePress} - onNextPagePress={handleNextPagePress} - onLastPagePress={handleLastPagePress} - onPageSelect={handlePageSelect} + onPageSelect={goToPage} /> ) : null} diff --git a/frontend/src/System/Events/LogsTableDetailsModal.tsx b/frontend/src/System/Events/LogsTableDetailsModal.tsx index 640f34514..9bfa2c8a6 100644 --- a/frontend/src/System/Events/LogsTableDetailsModal.tsx +++ b/frontend/src/System/Events/LogsTableDetailsModal.tsx @@ -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} - {!!exception && ( + {exception ? (
{translate('Exception')}
- )} + ) : null} diff --git a/frontend/src/System/Events/eventOptionsStore.tsx b/frontend/src/System/Events/eventOptionsStore.tsx new file mode 100644 index 000000000..30f01c273 --- /dev/null +++ b/frontend/src/System/Events/eventOptionsStore.tsx @@ -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( + '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) => { + eventOptionsStore.setState((state) => ({ + ...state, + ...options, + })); +}; + +export const setEventOption = ( + key: K, + value: EventOptions[K] +) => { + eventOptionsStore.setState((state) => ({ + ...state, + [key]: value, + })); +}; diff --git a/frontend/src/System/Events/useEvents.ts b/frontend/src/System/Events/useEvents.ts new file mode 100644 index 000000000..c0e366bdd --- /dev/null +++ b/frontend/src/System/Events/useEvents.ts @@ -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({ + 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; +}; diff --git a/frontend/src/Utilities/Fetch/fetchJson.ts b/frontend/src/Utilities/Fetch/fetchJson.ts index 7ebd37b99..05d9326f9 100644 --- a/frontend/src/Utilities/Fetch/fetchJson.ts +++ b/frontend/src/Utilities/Fetch/fetchJson.ts @@ -33,6 +33,7 @@ export interface FetchJsonOptions extends Omit { timeout?: number; } +export const urlBase = window.Sonarr.urlBase; export const apiRoot = '/api/v5'; // window.Sonarr.apiRoot; async function fetchJson({ diff --git a/frontend/src/Utilities/Fetch/getQueryPath.ts b/frontend/src/Utilities/Fetch/getQueryPath.ts new file mode 100644 index 000000000..671f22cda --- /dev/null +++ b/frontend/src/Utilities/Fetch/getQueryPath.ts @@ -0,0 +1,7 @@ +import { apiRoot, urlBase } from 'Utilities/Fetch/fetchJson'; + +const getQueryPath = (path: string) => { + return urlBase + apiRoot + path; +}; + +export default getQueryPath; diff --git a/frontend/src/Utilities/Fetch/getQueryString.ts b/frontend/src/Utilities/Fetch/getQueryString.ts new file mode 100644 index 000000000..611cdfb4c --- /dev/null +++ b/frontend/src/Utilities/Fetch/getQueryString.ts @@ -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 + >((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; diff --git a/src/Sonarr.Api.V5/Logs/LogController.cs b/src/Sonarr.Api.V5/Logs/LogController.cs new file mode 100644 index 000000000..4f9a8e09a --- /dev/null +++ b/src/Sonarr.Api.V5/Logs/LogController.cs @@ -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 GetLogs([FromQuery] PagingRequestResource paging, string? level) + { + if (!_configFileProvider.LogDbEnabled) + { + return new PagingResource(); + } + + var pagingResource = new PagingResource(paging); + var pageSpec = pagingResource.MapToPagingSpec(new HashSet(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; + } + } +} diff --git a/src/Sonarr.Api.V5/Logs/LogResource.cs b/src/Sonarr.Api.V5/Logs/LogResource.cs new file mode 100644 index 000000000..c5d82a0ae --- /dev/null +++ b/src/Sonarr.Api.V5/Logs/LogResource.cs @@ -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 + }; + } + } +}