Use react-query for backups

This commit is contained in:
Mark McDowall 2025-11-07 21:10:55 -08:00
parent 29170f17d2
commit c295e24fc6
12 changed files with 183 additions and 228 deletions

View file

@ -22,7 +22,6 @@ import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState';
import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState';
import WantedAppState from './WantedAppState';
@ -103,7 +102,6 @@ interface AppState {
seriesHistory: SeriesHistoryAppState;
seriesIndex: SeriesIndexAppState;
settings: SettingsAppState;
system: SystemAppState;
tags: TagsAppState;
wanted: WantedAppState;
}

View file

@ -1,7 +0,0 @@
import BackupAppState from './BackupAppState';
interface SystemAppState {
backups: BackupAppState;
}
export default SystemAppState;

View file

@ -1,5 +1,4 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Icon from 'Components/Icon';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
@ -7,8 +6,8 @@ import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
import { align, icons, kinds } from 'Helpers/Props';
import { restart, shutdown } from 'Store/Actions/systemActions';
import { useSystemStatusData } from 'System/Status/useSystemStatus';
import { useRestart, useShutdown } from 'System/useSystem';
import translate from 'Utilities/String/translate';
import styles from './PageHeaderActionsMenu.css';
@ -19,18 +18,19 @@ interface PageHeaderActionsMenuProps {
function PageHeaderActionsMenu(props: PageHeaderActionsMenuProps) {
const { onKeyboardShortcutsPress } = props;
const dispatch = useDispatch();
const { authentication, isDocker } = useSystemStatusData();
const { mutate: restart } = useRestart();
const { mutate: shutdown } = useShutdown();
const formsAuth = authentication === 'forms';
const handleRestartPress = useCallback(() => {
dispatch(restart());
}, [dispatch]);
restart();
}, [restart]);
const handleShutdownPress = useCallback(() => {
dispatch(shutdown());
}, [dispatch]);
shutdown();
}, [shutdown]);
return (
<div>

View file

@ -16,10 +16,10 @@ import {
saveGeneralSettings,
setGeneralSettingsValue,
} from 'Store/Actions/settingsActions';
import { restart } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import { useIsWindowsService } from 'System/Status/useSystemStatus';
import { useRestart } from 'System/useSystem';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import AnalyticSettings from './AnalyticSettings';
@ -46,6 +46,7 @@ const requiresRestartKeys = [
function GeneralSettings() {
const dispatch = useDispatch();
const isWindowsService = useIsWindowsService();
const { mutate: restart } = useRestart();
const isResettingApiKey = useSelector(
createCommandExecutingSelector(commandNames.RESET_API_KEY)
);
@ -85,8 +86,8 @@ function GeneralSettings() {
const handleConfirmRestart = useCallback(() => {
setIsRestartRequiredModalOpen(false);
dispatch(restart());
}, [dispatch]);
restart();
}, [restart]);
const handleCloseRestartRequiredModalOpen = useCallback(() => {
setIsRestartRequiredModalOpen(false);

View file

@ -20,7 +20,6 @@ import * as series from './seriesActions';
import * as seriesHistory from './seriesHistoryActions';
import * as seriesIndex from './seriesIndexActions';
import * as settings from './settingsActions';
import * as system from './systemActions';
import * as tags from './tagActions';
import * as wanted from './wantedActions';
@ -47,7 +46,6 @@ export default [
seriesHistory,
seriesIndex,
settings,
system,
tags,
wanted
];

View file

@ -1,160 +0,0 @@
import { createAction } from 'redux-actions';
import { setAppValue } from 'Store/Actions/appActions';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { pingServer } from './appActions';
import { set } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
//
// Variables
export const section = 'system';
const backupsSection = 'system.backups';
//
// State
export const defaultState = {
backups: {
isFetching: false,
isPopulated: false,
error: null,
isRestoring: false,
restoreError: null,
isDeleting: false,
deleteError: null,
items: []
}
};
//
// Actions Types
export const FETCH_BACKUPS = 'system/backups/fetchBackups';
export const RESTORE_BACKUP = 'system/backups/restoreBackup';
export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup';
export const DELETE_BACKUP = 'system/backups/deleteBackup';
export const RESTART = 'system/restart';
export const SHUTDOWN = 'system/shutdown';
//
// Action Creators
export const fetchBackups = createThunk(FETCH_BACKUPS);
export const restoreBackup = createThunk(RESTORE_BACKUP);
export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP);
export const deleteBackup = createThunk(DELETE_BACKUP);
export const restart = createThunk(RESTART);
export const shutdown = createThunk(SHUTDOWN);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_BACKUPS]: createFetchHandler(backupsSection, '/system/backup'),
[RESTORE_BACKUP]: function(getState, payload, dispatch) {
const {
id,
file
} = payload;
dispatch(set({
section: backupsSection,
isRestoring: true
}));
let ajaxOptions = null;
if (id) {
ajaxOptions = {
url: `/system/backup/restore/${id}`,
method: 'POST',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({
id
})
};
} else if (file) {
const formData = new FormData();
formData.append('restore', file);
ajaxOptions = {
url: '/system/backup/restore/upload',
method: 'POST',
processData: false,
contentType: false,
data: formData
};
} else {
dispatch(set({
section: backupsSection,
isRestoring: false,
restoreError: 'Error restoring backup'
}));
}
const promise = createAjaxRequest(ajaxOptions).request;
promise.done((data) => {
dispatch(set({
section: backupsSection,
isRestoring: false,
restoreError: null
}));
});
promise.fail((xhr) => {
dispatch(set({
section: backupsSection,
isRestoring: false,
restoreError: xhr
}));
});
},
[DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'),
[RESTART]: function(getState, payload, dispatch) {
const promise = createAjaxRequest({
url: '/system/restart',
method: 'POST'
}).request;
promise.done(() => {
dispatch(setAppValue({ isRestarting: true }));
dispatch(pingServer());
});
},
[SHUTDOWN]: function() {
createAjaxRequest({
url: '/system/shutdown',
method: 'POST'
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[CLEAR_RESTORE_BACKUP]: function(state, { payload }) {
return {
...state,
backups: {
...state.backups,
isRestoring: false,
restoreError: null
}
};
}
}, defaultState, section);

View file

@ -1,5 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
@ -8,11 +7,11 @@ import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
import { deleteBackup } from 'Store/Actions/systemActions';
import { BackupType } from 'typings/Backup';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import RestoreBackupModal from './RestoreBackupModal';
import { useDeleteBackup } from './useBackups';
import styles from './BackupRow.css';
interface BackupRowProps {
@ -25,7 +24,7 @@ interface BackupRowProps {
}
function BackupRow({ id, type, name, path, size, time }: BackupRowProps) {
const dispatch = useDispatch();
const deleteBackupMutation = useDeleteBackup(id);
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] =
useState(false);
@ -68,9 +67,15 @@ function BackupRow({ id, type, name, path, size, time }: BackupRowProps) {
}, []);
const handleConfirmDeletePress = useCallback(() => {
dispatch(deleteBackup({ id }));
setIsConfirmDeleteModalOpen(false);
}, [id, dispatch]);
deleteBackupMutation.mutate(undefined, {
onSuccess: () => {
setIsConfirmDeleteModalOpen(false);
},
onError: (error) => {
console.error('Failed to delete backup:', error);
},
});
}, [deleteBackupMutation]);
return (
<TableRow key={id}>

View file

@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useState } 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';
@ -15,11 +14,11 @@ import TableBody from 'Components/Table/TableBody';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, kinds } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchBackups } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import translate from 'Utilities/String/translate';
import BackupRow from './BackupRow';
import RestoreBackupModal from './RestoreBackupModal';
import useBackups from './useBackups';
const columns: Column[] = [
{
@ -51,10 +50,7 @@ const columns: Column[] = [
function Backups() {
const dispatch = useDispatch();
const { isFetching, isPopulated, error, items } = useSelector(
(state: AppState) => state.system.backups
);
const { data: items, isLoading: isFetching, error, refetch } = useBackups();
const isBackupExecuting = useSelector(
createCommandExecutingSelector(commandNames.BACKUP)
@ -63,8 +59,8 @@ function Backups() {
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const wasBackupExecuting = usePrevious(isBackupExecuting);
const hasBackups = isPopulated && !!items.length;
const noBackups = isPopulated && !items.length;
const hasBackups = !!items.length;
const noBackups = !items.length && !isFetching && !error;
const handleBackupPress = useCallback(() => {
dispatch(
@ -82,15 +78,11 @@ function Backups() {
setIsRestoreModalOpen(false);
}, []);
useEffect(() => {
dispatch(fetchBackups());
}, [dispatch]);
useEffect(() => {
if (wasBackupExecuting && !isBackupExecuting) {
dispatch(fetchBackups());
refetch();
}
}, [isBackupExecuting, wasBackupExecuting, dispatch]);
}, [isBackupExecuting, wasBackupExecuting, refetch]);
return (
<PageContent title={translate('Backups')}>
@ -112,7 +104,7 @@ function Backups() {
</PageToolbar>
<PageContentBody>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('BackupsLoadError')}</Alert>

View file

@ -1,7 +1,5 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { clearRestoreBackup } from 'Store/Actions/systemActions';
import RestoreBackupModalContent, {
RestoreBackupModalContentProps,
} from './RestoreBackupModalContent';
@ -16,19 +14,9 @@ function RestoreBackupModal({
onModalClose,
...otherProps
}: RestoreBackupModalProps) {
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
dispatch(clearRestoreBackup());
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal isOpen={isOpen} onModalClose={handleModalClose}>
<RestoreBackupModalContent
{...otherProps}
onModalClose={handleModalClose}
/>
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<RestoreBackupModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}

View file

@ -12,9 +12,10 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, kinds } from 'Helpers/Props';
import { restart, restoreBackup } from 'Store/Actions/systemActions';
import { useRestart } from 'System/useSystem';
import { FileInputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import { useRestoreBackup, useRestoreBackupUpload } from './useBackups';
import styles from './RestoreBackupModalContent.css';
function getErrorMessage(error: Error) {
@ -78,18 +79,29 @@ function RestoreBackupModalContent({
name,
onModalClose,
}: RestoreBackupModalContentProps) {
const { isRestoring, restoreError } = useSelector(
(state: AppState) => state.system.backups
);
const { isRestarting } = useSelector((state: AppState) => state.app);
const dispatch = useDispatch();
const {
mutate: restoreBackupById,
isPending: isRestoreBackupPending,
error: restoreBackupError,
} = useRestoreBackup(id || 0);
const {
mutate: uploadBackupFile,
isPending: isUploadBackupPending,
error: uploadBackupError,
} = useRestoreBackupUpload();
const { mutate: restart } = useRestart();
const [path, setPath] = useState('');
const [file, setFile] = useState<File | null>(null);
const [isRestored, setIsRestored] = useState(false);
const [isRestarted, setIsRestarted] = useState(false);
const [isReloading, setIsReloading] = useState(false);
const isRestoring = isRestoreBackupPending || isUploadBackupPending;
const restoreError = restoreBackupError || uploadBackupError;
const wasRestoring = usePrevious(isRestoring);
const wasRestarting = usePrevious(isRestarting);
@ -106,15 +118,21 @@ function RestoreBackupModalContent({
}, []);
const handleRestorePress = useCallback(() => {
dispatch(restoreBackup({ id, file }));
}, [id, file, dispatch]);
if (id) {
restoreBackupById();
} else if (file) {
const formData = new FormData();
formData.append('restore', file);
uploadBackupFile(formData);
}
}, [id, file, restoreBackupById, uploadBackupFile]);
useEffect(() => {
if (wasRestoring && !isRestoring && !restoreError) {
setIsRestored(true);
dispatch(restart());
restart();
}
}, [isRestoring, wasRestoring, restoreError, dispatch]);
}, [isRestoring, wasRestoring, restoreError, restart]);
useEffect(() => {
if (wasRestarting && !isRestarting) {
@ -147,7 +165,7 @@ function RestoreBackupModalContent({
<div className={styles.stepState}>
<Icon
size={20}
{...getStepIconProps(isRestoring, isRestored, restoreError)}
{...getStepIconProps(isRestoring, isRestored, undefined)}
/>
</div>

View file

@ -0,0 +1,82 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Backup from 'typings/Backup';
const useBackups = () => {
const result = useApiQuery<Backup[]>({
path: '/system/backup',
queryOptions: {
staleTime: 30 * 1000, // 30 seconds
},
});
return {
...result,
data: result.data ?? [],
};
};
export default useBackups;
export const useDeleteBackup = (id: number) => {
const queryClient = useQueryClient();
return useApiMutation<object, void>({
path: `/system/backup/${id}`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/system/backup'] });
},
},
});
};
interface RestoreBackupResponse {
restartRequired: boolean;
}
export const useRestoreBackup = (id: number) => {
const queryClient = useQueryClient();
return useApiMutation<RestoreBackupResponse, void>({
path: `/system/backup/restore/${id}`,
method: 'POST',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/system/backup'] });
},
},
});
};
export const useRestoreBackupUpload = () => {
const queryClient = useQueryClient();
return useMutation<RestoreBackupResponse, Error, FormData>({
mutationFn: async (formData: FormData) => {
const response = await fetch(
`${window.Sonarr.urlBase}/api/v5/system/backup/restore/upload`,
{
method: 'POST',
headers: {
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
// Don't set Content-Type, let browser set it with boundary for multipart/form-data
},
body: formData,
}
);
if (!response.ok) {
throw new Error(`Failed to restore backup: ${response.statusText}`);
}
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/system/backup'] });
},
});
};

View file

@ -0,0 +1,40 @@
import { useMutation } from '@tanstack/react-query';
import { useDispatch } from 'react-redux';
import { pingServer, setAppValue } from 'Store/Actions/appActions';
const createSystemMutationFn = (endpoint: string) => {
return async () => {
const response = await fetch(
`${window.Sonarr.urlBase}/system/${endpoint}`,
{
method: 'POST',
headers: {
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
}
);
if (!response.ok) {
throw new Error(`Failed to ${endpoint}: ${response.statusText}`);
}
};
};
export const useRestart = () => {
const dispatch = useDispatch();
return useMutation<void, Error, void>({
mutationFn: createSystemMutationFn('restart'),
onSuccess: () => {
dispatch(setAppValue({ isRestarting: true }));
dispatch(pingServer());
},
});
};
export const useShutdown = () => {
return useMutation<void, Error, void>({
mutationFn: createSystemMutationFn('shutdown'),
});
};