Convert Log Events to React Query

pull/7700/head
Mark McDowall 2 months ago
parent 5d7c94f8e9
commit 5342416659

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

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

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

@ -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,
}));
};

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

@ -0,0 +1,7 @@
import { apiRoot, urlBase } from 'Utilities/Fetch/fetchJson';
const getQueryPath = (path: string) => {
return urlBase + apiRoot + path;
};
export default getQueryPath;

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

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

@ -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…
Cancel
Save