From c295e24fc69cd2024d76ec796b9b63255f5a1239 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Fri, 7 Nov 2025 21:10:55 -0800 Subject: [PATCH] Use react-query for backups --- frontend/src/App/State/AppState.ts | 2 - frontend/src/App/State/SystemAppState.ts | 7 - .../Page/Header/PageHeaderActionsMenu.tsx | 14 +- .../src/Settings/General/GeneralSettings.tsx | 7 +- frontend/src/Store/Actions/index.js | 2 - frontend/src/Store/Actions/systemActions.js | 160 ------------------ frontend/src/System/Backup/BackupRow.tsx | 17 +- frontend/src/System/Backup/Backups.tsx | 22 +-- .../src/System/Backup/RestoreBackupModal.tsx | 18 +- .../Backup/RestoreBackupModalContent.tsx | 40 +++-- frontend/src/System/Backup/useBackups.ts | 82 +++++++++ frontend/src/System/useSystem.ts | 40 +++++ 12 files changed, 183 insertions(+), 228 deletions(-) delete mode 100644 frontend/src/App/State/SystemAppState.ts delete mode 100644 frontend/src/Store/Actions/systemActions.js create mode 100644 frontend/src/System/Backup/useBackups.ts create mode 100644 frontend/src/System/useSystem.ts diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 1b37add0f..79b760255 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -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; } diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts deleted file mode 100644 index a6d16f452..000000000 --- a/frontend/src/App/State/SystemAppState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import BackupAppState from './BackupAppState'; - -interface SystemAppState { - backups: BackupAppState; -} - -export default SystemAppState; diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx index 069fdad05..2d494ae8e 100644 --- a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx @@ -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 (
diff --git a/frontend/src/Settings/General/GeneralSettings.tsx b/frontend/src/Settings/General/GeneralSettings.tsx index 09c225d78..8bb29cf18 100644 --- a/frontend/src/Settings/General/GeneralSettings.tsx +++ b/frontend/src/Settings/General/GeneralSettings.tsx @@ -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); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 25d3bef5b..608b3c585 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -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 ]; diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js deleted file mode 100644 index 5ae534ccc..000000000 --- a/frontend/src/Store/Actions/systemActions.js +++ /dev/null @@ -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); diff --git a/frontend/src/System/Backup/BackupRow.tsx b/frontend/src/System/Backup/BackupRow.tsx index c368559f3..97cd5c446 100644 --- a/frontend/src/System/Backup/BackupRow.tsx +++ b/frontend/src/System/Backup/BackupRow.tsx @@ -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 ( diff --git a/frontend/src/System/Backup/Backups.tsx b/frontend/src/System/Backup/Backups.tsx index c2313a8a1..4dc2272ea 100644 --- a/frontend/src/System/Backup/Backups.tsx +++ b/frontend/src/System/Backup/Backups.tsx @@ -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 ( @@ -112,7 +104,7 @@ function Backups() { - {isFetching && !isPopulated ? : null} + {isFetching ? : null} {!isFetching && !!error ? ( {translate('BackupsLoadError')} diff --git a/frontend/src/System/Backup/RestoreBackupModal.tsx b/frontend/src/System/Backup/RestoreBackupModal.tsx index b2cee204c..0ea73dfc1 100644 --- a/frontend/src/System/Backup/RestoreBackupModal.tsx +++ b/frontend/src/System/Backup/RestoreBackupModal.tsx @@ -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 ( - - + + ); } diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.tsx b/frontend/src/System/Backup/RestoreBackupModalContent.tsx index 29e9fa29d..16dc47347 100644 --- a/frontend/src/System/Backup/RestoreBackupModalContent.tsx +++ b/frontend/src/System/Backup/RestoreBackupModalContent.tsx @@ -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(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({
diff --git a/frontend/src/System/Backup/useBackups.ts b/frontend/src/System/Backup/useBackups.ts new file mode 100644 index 000000000..7b2dfad8c --- /dev/null +++ b/frontend/src/System/Backup/useBackups.ts @@ -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({ + 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({ + 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({ + path: `/system/backup/restore/${id}`, + method: 'POST', + mutationOptions: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/system/backup'] }); + }, + }, + }); +}; + +export const useRestoreBackupUpload = () => { + const queryClient = useQueryClient(); + + return useMutation({ + 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'] }); + }, + }); +}; diff --git a/frontend/src/System/useSystem.ts b/frontend/src/System/useSystem.ts new file mode 100644 index 000000000..14c357bb5 --- /dev/null +++ b/frontend/src/System/useSystem.ts @@ -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({ + mutationFn: createSystemMutationFn('restart'), + onSuccess: () => { + dispatch(setAppValue({ isRestarting: true })); + dispatch(pingServer()); + }, + }); +}; + +export const useShutdown = () => { + return useMutation({ + mutationFn: createSystemMutationFn('shutdown'), + }); +};