From 5bfaba9360f062965e672dd336fe82208c716d13 Mon Sep 17 00:00:00 2001 From: morpheus65535 Date: Tue, 22 Feb 2022 22:55:07 -0500 Subject: [PATCH] Implemented backup and restore feature for configuration file and database --- bazarr/api/system/__init__.py | 2 + bazarr/api/system/backups.py | 37 ++++ bazarr/backup.py | 197 ++++++++++++++++++ bazarr/config.py | 10 +- bazarr/init.py | 6 + bazarr/scheduler.py | 43 +++- frontend/src/@types/settings.d.ts | 9 + frontend/src/@types/system.d.ts | 7 + frontend/src/Navigation/index.ts | 6 + frontend/src/apis/hooks/system.ts | 44 ++++ frontend/src/apis/queries/keys.ts | 1 + frontend/src/apis/raw/system.ts | 17 ++ .../src/components/inputs/FileBrowser.tsx | 4 +- frontend/src/pages/Settings/General/index.tsx | 5 + .../src/pages/Settings/Scheduler/index.tsx | 29 +++ .../src/pages/Settings/Scheduler/options.ts | 2 + .../src/pages/Settings/components/forms.tsx | 22 ++ .../System/Backups/BackupDeleteModal.tsx | 57 +++++ .../System/Backups/BackupRestoreModal.tsx | 58 ++++++ frontend/src/pages/System/Backups/index.tsx | 39 ++++ frontend/src/pages/System/Backups/table.tsx | 70 +++++++ 21 files changed, 652 insertions(+), 13 deletions(-) create mode 100644 bazarr/api/system/backups.py create mode 100644 bazarr/backup.py create mode 100644 frontend/src/pages/System/Backups/BackupDeleteModal.tsx create mode 100644 frontend/src/pages/System/Backups/BackupRestoreModal.tsx create mode 100644 frontend/src/pages/System/Backups/index.tsx create mode 100644 frontend/src/pages/System/Backups/table.tsx diff --git a/bazarr/api/system/__init__.py b/bazarr/api/system/__init__.py index cbe54a13b..4f7863eec 100644 --- a/bazarr/api/system/__init__.py +++ b/bazarr/api/system/__init__.py @@ -6,6 +6,7 @@ from flask_restful import Api from .system import System from .searches import Searches from .account import SystemAccount +from .backups import SystemBackups from .tasks import SystemTasks from .logs import SystemLogs from .status import SystemStatus @@ -22,6 +23,7 @@ api = Api(api_bp_system) api.add_resource(System, '/system') api.add_resource(Searches, '/system/searches') api.add_resource(SystemAccount, '/system/account') +api.add_resource(SystemBackups, '/system/backups') api.add_resource(SystemTasks, '/system/tasks') api.add_resource(SystemLogs, '/system/logs') api.add_resource(SystemStatus, '/system/status') diff --git a/bazarr/api/system/backups.py b/bazarr/api/system/backups.py new file mode 100644 index 000000000..f7294815e --- /dev/null +++ b/bazarr/api/system/backups.py @@ -0,0 +1,37 @@ +# coding=utf-8 + +from flask import jsonify, request +from flask_restful import Resource + +from ..utils import authenticate +from backup import get_backup_files, prepare_restore, delete_backup_file, backup_to_zip + + +class SystemBackups(Resource): + @authenticate + def get(self): + backups = get_backup_files(fullpath=False) + return jsonify(data=backups) + + @authenticate + def post(self): + backup_to_zip() + return '', 204 + + @authenticate + def patch(self): + filename = request.form.get('filename') + if filename: + restored = prepare_restore(filename) + if restored: + return '', 204 + return '', 501 + + @authenticate + def delete(self): + filename = request.form.get('filename') + if filename: + deleted = delete_backup_file(filename) + if deleted: + return '', 204 + return '', 501 diff --git a/bazarr/backup.py b/bazarr/backup.py new file mode 100644 index 000000000..53e93bcd2 --- /dev/null +++ b/bazarr/backup.py @@ -0,0 +1,197 @@ +# coding=utf-8 + +import os +import io +import sqlite3 +import shutil +import logging + +from datetime import datetime, timedelta +from zipfile import ZipFile, BadZipFile +from glob import glob + +from get_args import args +from config import settings + + +def get_backup_path(): + backup_dir = settings.backup.folder + if not os.path.isdir(backup_dir): + os.makedirs(backup_dir) + logging.debug(f'Backup directory path is: {backup_dir}') + return backup_dir + + +def get_restore_path(): + restore_dir = os.path.join(args.config_dir, 'restore') + if not os.path.isdir(restore_dir): + os.makedirs(restore_dir) + logging.debug(f'Restore directory path is: {restore_dir}') + return restore_dir + + +def get_backup_files(fullpath=True): + backup_file_pattern = os.path.join(get_backup_path(), 'bazarr_backup_v*.zip') + file_list = glob(backup_file_pattern) + if fullpath: + return file_list + else: + return [{ + 'type': 'backup', + 'filename': os.path.basename(x), + 'date': datetime.fromtimestamp(os.path.getmtime(x)).strftime("%b %d %Y") + } for x in file_list] + + +def backup_to_zip(): + now = datetime.now() + now_string = now.strftime("%Y.%m.%d_%H.%M.%S") + backup_filename = f"bazarr_backup_v{os.environ['BAZARR_VERSION']}_{now_string}.zip" + logging.debug(f'Backup filename will be: {backup_filename}') + + database_src_file = os.path.join(args.config_dir, 'db', 'bazarr.db') + logging.debug(f'Database file path to backup is: {database_src_file}') + + try: + database_src_con = sqlite3.connect(database_src_file) + + database_backup_file = os.path.join(get_backup_path(), 'bazarr_temp.db') + database_backup_con = sqlite3.connect(database_backup_file) + + with database_backup_con: + database_src_con.backup(database_backup_con) + + database_backup_con.close() + database_src_con.close() + except Exception: + database_backup_file = None + logging.exception('Unable to backup database file.') + + config_file = os.path.join(args.config_dir, 'config', 'config.ini') + logging.debug(f'Config file path to backup is: {config_file}') + + with ZipFile(os.path.join(get_backup_path(), backup_filename), 'w') as backupZip: + if database_backup_file: + backupZip.write(database_backup_file, 'bazarr.db') + else: + logging.debug(f'Database file is not included in backup. See previous exception') + backupZip.write(config_file, 'config.ini') + + try: + os.remove(database_backup_file) + except OSError: + logging.exception(f'Unable to delete temporary database backup file: {database_backup_file}') + + +def restore_from_backup(): + restore_config_path = os.path.join(get_restore_path(), 'config.ini') + dest_config_path = os.path.join(args.config_dir, 'config', 'config.ini') + restore_database_path = os.path.join(get_restore_path(), 'bazarr.db') + dest_database_path = os.path.join(args.config_dir, 'db', 'bazarr.db') + + if os.path.isfile(restore_config_path) and os.path.isfile(restore_database_path): + try: + shutil.copy(restore_config_path, dest_config_path) + os.remove(restore_config_path) + except OSError: + logging.exception(f'Unable to restore or delete config.ini to {dest_config_path}') + + try: + shutil.copy(restore_database_path, dest_database_path) + os.remove(restore_database_path) + except OSError: + logging.exception(f'Unable to restore or delete bazarr.db to {dest_database_path}') + else: + try: + if os.path.isfile(dest_database_path + '-shm'): + os.remove(dest_database_path + '-shm') + if os.path.isfile(dest_database_path + '-wal'): + os.remove(dest_database_path + '-wal') + except OSError: + logging.exception('Unable to delete SHM and WAL file.') + + logging.info('Backup restored successfully. Bazarr will restart.') + + try: + restart_file = io.open(os.path.join(args.config_dir, "bazarr.restart"), "w", encoding='UTF-8') + except Exception as e: + logging.error('BAZARR Cannot create bazarr.restart file: ' + repr(e)) + else: + logging.info('Bazarr is being restarted...') + restart_file.write(str('')) + restart_file.close() + os._exit(0) + elif os.path.isfile(restore_config_path) or os.path.isfile(restore_database_path): + logging.debug('Cannot restore a partial backup. You must have both config and database.') + else: + logging.debug('No backup to restore.') + return + + try: + os.remove(restore_config_path) + except OSError: + logging.exception(f'Unable to delete {dest_config_path}') + + try: + os.remove(restore_database_path) + except OSError: + logging.exception(f'Unable to delete {dest_database_path}') + + +def prepare_restore(filename): + src_zip_file_path = os.path.join(get_backup_path(), filename) + dest_zip_file_path = os.path.join(get_restore_path(), filename) + success = False + try: + shutil.copy(src_zip_file_path, dest_zip_file_path) + except OSError: + logging.exception(f'Unable to copy backup archive to {dest_zip_file_path}') + else: + try: + with ZipFile(dest_zip_file_path, 'r') as zipObj: + zipObj.extractall(path=get_restore_path()) + except BadZipFile: + logging.exception(f'Unable to extract files from backup archive {dest_zip_file_path}') + + success = True + finally: + try: + os.remove(dest_zip_file_path) + except OSError: + logging.exception(f'Unable to delete backup archive {dest_zip_file_path}') + + if success: + logging.debug('time to restart') + from server import webserver + webserver.restart() + + +def backup_rotation(): + backup_retention = settings.backup.retention + try: + int(backup_retention) + except ValueError: + logging.error('Backup retention time must be a valid integer. Please fix this in your settings.') + return + + backup_files = get_backup_files() + + logging.debug(f'Cleaning up backup files older than {backup_retention} days') + for file in backup_files: + if datetime.fromtimestamp(os.path.getmtime(file)) + timedelta(days=backup_retention) < datetime.utcnow(): + logging.debug(f'Deleting old backup file {file}') + try: + os.remove(file) + except OSError: + logging.debug(f'Unable to delete backup file {file}') + logging.debug('Finished cleaning up old backup files') + + +def delete_backup_file(filename): + backup_file_path = os.path.join(get_backup_path(), filename) + try: + os.remove(backup_file_path) + return True + except OSError: + logging.debug(f'Unable to delete backup file {backup_file_path}') + return False diff --git a/bazarr/config.py b/bazarr/config.py index c037b1e74..cfe29c3f4 100644 --- a/bazarr/config.py +++ b/bazarr/config.py @@ -81,6 +81,13 @@ defaults = { 'username': '', 'password': '' }, + 'backup': { + 'folder': os.path.join(args.config_dir, 'backup'), + 'retention': '31', + 'frequency': 'Weekly', + 'day': '6', + 'hour': '3' + }, 'sonarr': { 'ip': '127.0.0.1', 'port': '8989', @@ -383,7 +390,8 @@ def save_settings(settings_items): 'settings-sonarr-full_update', 'settings-sonarr-full_update_day', 'settings-sonarr-full_update_hour', 'settings-radarr-full_update', 'settings-radarr-full_update_day', 'settings-radarr-full_update_hour', 'settings-general-wanted_search_frequency', 'settings-general-wanted_search_frequency_movie', - 'settings-general-upgrade_frequency']: + 'settings-general-upgrade_frequency', 'settings-backup-frequency', 'settings-backup-day', + 'settings-backup-hour']: update_schedule = True if key in ['settings-general-use_sonarr', 'settings-sonarr-ip', 'settings-sonarr-port', diff --git a/bazarr/init.py b/bazarr/init.py index ea4b8049f..05edcbdd2 100644 --- a/bazarr/init.py +++ b/bazarr/init.py @@ -10,6 +10,7 @@ from config import settings, configure_captcha_func from get_args import args from logger import configure_logging from helper import path_mappings +from backup import restore_from_backup from dogpile.cache.region import register_backend as register_cache_backend import subliminal @@ -20,6 +21,9 @@ import time global startTime startTime = time.time() +# restore backup if required +restore_from_backup() + # set subliminal_patch user agent os.environ["SZ_USER_AGENT"] = "Bazarr/{}".format(os.environ["BAZARR_VERSION"]) @@ -46,6 +50,8 @@ if not os.path.exists(os.path.join(args.config_dir, 'log')): os.mkdir(os.path.join(args.config_dir, 'log')) if not os.path.exists(os.path.join(args.config_dir, 'cache')): os.mkdir(os.path.join(args.config_dir, 'cache')) +if not os.path.exists(os.path.join(args.config_dir, 'restore')): + os.mkdir(os.path.join(args.config_dir, 'restore')) configure_logging(settings.general.getboolean('debug') or args.debug) import logging # noqa E402 diff --git a/bazarr/scheduler.py b/bazarr/scheduler.py index d359ac823..7133ecfd7 100644 --- a/bazarr/scheduler.py +++ b/bazarr/scheduler.py @@ -1,5 +1,18 @@ # coding=utf-8 +import os +import pretty + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.interval import IntervalTrigger +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger +from apscheduler.events import EVENT_JOB_SUBMITTED, EVENT_JOB_EXECUTED, EVENT_JOB_ERROR +from apscheduler.jobstores.base import JobLookupError +from datetime import datetime, timedelta +from calendar import day_name +from random import randrange + from get_episodes import sync_episodes, update_all_episodes from get_movies import update_movies, update_all_movies from get_series import update_series @@ -12,17 +25,8 @@ if not args.no_update: from check_update import check_if_new_update, check_releases else: from check_update import check_releases -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.interval import IntervalTrigger -from apscheduler.triggers.cron import CronTrigger -from apscheduler.triggers.date import DateTrigger -from apscheduler.events import EVENT_JOB_SUBMITTED, EVENT_JOB_EXECUTED, EVENT_JOB_ERROR -from datetime import datetime, timedelta -from calendar import day_name -import pretty -from random import randrange from event_handler import event_stream -import os +from backup import backup_to_zip class Scheduler: @@ -62,6 +66,7 @@ class Scheduler: self.__search_wanted_subtitles_task() self.__upgrade_subtitles_task() self.__randomize_interval_task() + self.__automatic_backup() if args.no_tasks: self.__no_task() @@ -166,6 +171,24 @@ class Scheduler: self.aps_scheduler.add_job(check_health, IntervalTrigger(hours=6), max_instances=1, coalesce=True, misfire_grace_time=15, id='check_health', name='Check health') + def __automatic_backup(self): + backup = settings.backup.frequency + if backup == "Daily": + self.aps_scheduler.add_job( + backup_to_zip, CronTrigger(hour=settings.backup.hour), max_instances=1, coalesce=True, + misfire_grace_time=15, id='backup', name='Backup database and configuration file', + replace_existing=True) + elif backup == "Weekly": + self.aps_scheduler.add_job( + backup_to_zip, CronTrigger(day_of_week=settings.backup.day, hour=settings.backup.hour), + max_instances=1, coalesce=True, misfire_grace_time=15, id='backup', + name='Backup database and configuration file', replace_existing=True) + elif backup == "Manually": + try: + self.aps_scheduler.remove_job(job_id='backup') + except JobLookupError: + pass + def __sonarr_full_update_task(self): if settings.general.getboolean('use_sonarr'): full_update = settings.sonarr.full_update diff --git a/frontend/src/@types/settings.d.ts b/frontend/src/@types/settings.d.ts index 16879f831..ed992bbc1 100644 --- a/frontend/src/@types/settings.d.ts +++ b/frontend/src/@types/settings.d.ts @@ -6,6 +6,7 @@ interface Settings { analytics: Settings.Analytic; sonarr: Settings.Sonarr; radarr: Settings.Radarr; + backup: Settings.Backup; // Anitcaptcha anticaptcha: Settings.Anticaptcha; deathbycaptcha: Settings.DeathByCaptche; @@ -87,6 +88,14 @@ declare namespace Settings { password?: string; } + interface Backup { + folder: string; + retention: number; + frequency: string; + day: number; + hour: number; + } + interface Auth { type?: string; username?: string; diff --git a/frontend/src/@types/system.d.ts b/frontend/src/@types/system.d.ts index b99ea5469..11cb09072 100644 --- a/frontend/src/@types/system.d.ts +++ b/frontend/src/@types/system.d.ts @@ -20,6 +20,13 @@ declare namespace System { start_time: number; } + interface Backups { + type: string; + filename: string; + date: string; + id: number; + } + interface Health { object: string; issue: string; diff --git a/frontend/src/Navigation/index.ts b/frontend/src/Navigation/index.ts index f1494e42d..bca96795a 100644 --- a/frontend/src/Navigation/index.ts +++ b/frontend/src/Navigation/index.ts @@ -36,6 +36,7 @@ import SystemTasksView from "pages/System/Tasks"; import WantedMoviesView from "pages/Wanted/Movies"; import WantedSeriesView from "pages/Wanted/Series"; import { useMemo } from "react"; +import SystemBackupsView from "../pages/System/Backups"; import { Navigation } from "./nav"; import RootRedirect from "./RootRedirect"; @@ -225,6 +226,11 @@ export function useNavigationItems() { badge: data?.providers, component: SystemProvidersView, }, + { + name: "Backup", + path: "/backups", + component: SystemBackupsView, + }, { name: "Status", path: "/status", diff --git a/frontend/src/apis/hooks/system.ts b/frontend/src/apis/hooks/system.ts index f096806b8..b4280fe92 100644 --- a/frontend/src/apis/hooks/system.ts +++ b/frontend/src/apis/hooks/system.ts @@ -105,6 +105,50 @@ export function useRunTask() { { onSuccess: () => { client.invalidateQueries([QueryKeys.System, QueryKeys.Tasks]); + client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]); + }, + } + ); +} + +export function useSystemBackups() { + return useQuery([QueryKeys.System, "backups"], () => api.system.backups()); +} + +export function useCreateBackups() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.System, QueryKeys.Backups], + () => api.system.createBackups(), + { + onSuccess: () => { + client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]); + }, + } + ); +} + +export function useRestoreBackups() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.System, QueryKeys.Backups], + (filename: string) => api.system.restoreBackups(filename), + { + onSuccess: () => { + client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]); + }, + } + ); +} + +export function useDeleteBackups() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.System, QueryKeys.Backups], + (filename: string) => api.system.deleteBackups(filename), + { + onSuccess: () => { + client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]); }, } ); diff --git a/frontend/src/apis/queries/keys.ts b/frontend/src/apis/queries/keys.ts index cfdd44133..a3b6e94a7 100644 --- a/frontend/src/apis/queries/keys.ts +++ b/frontend/src/apis/queries/keys.ts @@ -14,6 +14,7 @@ export enum QueryKeys { Search = "search", Actions = "actions", Tasks = "tasks", + Backups = "backups", Logs = "logs", Infos = "infos", History = "history", diff --git a/frontend/src/apis/raw/system.ts b/frontend/src/apis/raw/system.ts index c473f076d..ebff962ac 100644 --- a/frontend/src/apis/raw/system.ts +++ b/frontend/src/apis/raw/system.ts @@ -51,6 +51,23 @@ class SystemApi extends BaseApi { return response.data; } + async backups() { + const response = await this.get>("/backups"); + return response.data; + } + + async createBackups() { + await this.post("/backups"); + } + + async restoreBackups(filename: string) { + await this.patch("/backups", { filename }); + } + + async deleteBackups(filename: string) { + await this.delete("/backups", { filename }); + } + async health() { const response = await this.get>("/health"); return response.data; diff --git a/frontend/src/components/inputs/FileBrowser.tsx b/frontend/src/components/inputs/FileBrowser.tsx index 4dfe8c80e..f3bc1d37d 100644 --- a/frontend/src/components/inputs/FileBrowser.tsx +++ b/frontend/src/components/inputs/FileBrowser.tsx @@ -30,14 +30,14 @@ function extractPath(raw: string) { } } -interface Props { +export interface FileBrowserProps { defaultValue?: string; type: "sonarr" | "radarr" | "bazarr"; onChange?: (path: string) => void; drop?: DropdownProps["drop"]; } -export const FileBrowser: FunctionComponent = ({ +export const FileBrowser: FunctionComponent = ({ defaultValue, type, onChange, diff --git a/frontend/src/pages/Settings/General/index.tsx b/frontend/src/pages/Settings/General/index.tsx index 25ce22de3..3c48ceeee 100644 --- a/frontend/src/pages/Settings/General/index.tsx +++ b/frontend/src/pages/Settings/General/index.tsx @@ -12,6 +12,7 @@ import { Check, Chips, CollapseBox, + File, Group, Input, Message, @@ -175,6 +176,10 @@ const SettingsGeneralView: FunctionComponent = () => { Debug logging should only be enabled temporarily + + + Absolute path to the backup directory + diff --git a/frontend/src/pages/Settings/Scheduler/index.tsx b/frontend/src/pages/Settings/Scheduler/index.tsx index 728727fbd..4c1936396 100644 --- a/frontend/src/pages/Settings/Scheduler/index.tsx +++ b/frontend/src/pages/Settings/Scheduler/index.tsx @@ -9,6 +9,7 @@ import { SettingsProvider, } from "../components"; import { + backupOptions, dayOptions, diskUpdateOptions, episodesSyncOptions, @@ -143,6 +144,34 @@ const SettingsSchedulerView: FunctionComponent = () => { > + + + + + + + + k === "Weekly"}> + + + + + k === "Daily" || k === "Weekly"}> + + + + + + ); }; diff --git a/frontend/src/pages/Settings/Scheduler/options.ts b/frontend/src/pages/Settings/Scheduler/options.ts index 7d5f52e9f..aaf69cebb 100644 --- a/frontend/src/pages/Settings/Scheduler/options.ts +++ b/frontend/src/pages/Settings/Scheduler/options.ts @@ -17,6 +17,8 @@ export const diskUpdateOptions: SelectorOption[] = [ { label: "Weekly", value: "Weekly" }, ]; +export const backupOptions = diskUpdateOptions; + export const dayOptions: SelectorOption[] = [ { label: "Monday", value: 0 }, { label: "Tuesday", value: 1 }, diff --git a/frontend/src/pages/Settings/components/forms.tsx b/frontend/src/pages/Settings/components/forms.tsx index b4587f127..d85977a39 100644 --- a/frontend/src/pages/Settings/components/forms.tsx +++ b/frontend/src/pages/Settings/components/forms.tsx @@ -1,6 +1,8 @@ import { Chips as CChips, ChipsProps as CChipsProps, + FileBrowser, + FileBrowserProps, Selector as CSelector, SelectorProps as CSelectorProps, Slider as CSlider, @@ -214,3 +216,23 @@ export const Button: FunctionComponent> = ( > ); }; + +type FileProps = {} & BaseInput; + +export const File: FunctionComponent> = ( + props +) => { + const { settingKey, override, ...file } = props; + const value = useLatest(settingKey, isString); + const update = useSingleUpdate(); + + return ( + { + update(p, settingKey); + }} + {...file} + > + ); +}; diff --git a/frontend/src/pages/System/Backups/BackupDeleteModal.tsx b/frontend/src/pages/System/Backups/BackupDeleteModal.tsx new file mode 100644 index 000000000..906b2f5e1 --- /dev/null +++ b/frontend/src/pages/System/Backups/BackupDeleteModal.tsx @@ -0,0 +1,57 @@ +import { + AsyncButton, + BaseModal, + BaseModalProps, + useCloseModal, + useModalPayload, +} from "components"; +import React, { FunctionComponent } from "react"; +import { Button } from "react-bootstrap"; +import { useDeleteBackups } from "../../../apis/hooks"; + +interface Props extends BaseModalProps {} + +const SystemBackupDeleteModal: FunctionComponent = ({ ...modal }) => { + const result = useModalPayload(modal.modalKey); + + const { mutateAsync } = useDeleteBackups(); + + const closeModal = useCloseModal(); + + const footer = ( +
+
+ + { + if (result) { + return mutateAsync(result); + } else { + return null; + } + }} + onSuccess={() => closeModal(modal.modalKey)} + > + Delete + +
+
+ ); + + return ( + + Are you sure you want to delete the backup '{result}'? + + ); +}; + +export default SystemBackupDeleteModal; diff --git a/frontend/src/pages/System/Backups/BackupRestoreModal.tsx b/frontend/src/pages/System/Backups/BackupRestoreModal.tsx new file mode 100644 index 000000000..966d6409c --- /dev/null +++ b/frontend/src/pages/System/Backups/BackupRestoreModal.tsx @@ -0,0 +1,58 @@ +import { + AsyncButton, + BaseModal, + BaseModalProps, + useCloseModal, + useModalPayload, +} from "components"; +import React, { FunctionComponent } from "react"; +import { Button } from "react-bootstrap"; +import { useRestoreBackups } from "../../../apis/hooks"; + +interface Props extends BaseModalProps {} + +const SystemBackupRestoreModal: FunctionComponent = ({ ...modal }) => { + const result = useModalPayload(modal.modalKey); + + const { mutateAsync } = useRestoreBackups(); + + const closeModal = useCloseModal(); + + const footer = ( +
+
+ + { + if (result) { + return mutateAsync(result); + } else { + return null; + } + }} + onSuccess={() => closeModal(modal.modalKey)} + > + Restore + +
+
+ ); + + return ( + + Are you sure you want to restore the backup '{result}'? Bazarr will + automatically restart and reload the UI during the restore process. + + ); +}; + +export default SystemBackupRestoreModal; diff --git a/frontend/src/pages/System/Backups/index.tsx b/frontend/src/pages/System/Backups/index.tsx new file mode 100644 index 000000000..64225f1c3 --- /dev/null +++ b/frontend/src/pages/System/Backups/index.tsx @@ -0,0 +1,39 @@ +import { faFileArchive } from "@fortawesome/free-solid-svg-icons"; +import { useCreateBackups, useSystemBackups } from "apis/hooks"; +import { ContentHeader, QueryOverlay } from "components"; +import React, { FunctionComponent } from "react"; +import { Container, Row } from "react-bootstrap"; +import { Helmet } from "react-helmet"; +import Table from "./table"; + +interface Props {} + +const SystemBackupsView: FunctionComponent = () => { + const backups = useSystemBackups(); + + const { mutate: backup, isLoading: isResetting } = useCreateBackups(); + + return ( + + + + Backups - Bazarr (System) + + + backup()} + > + Backup Now + + + +
+
+
+
+ ); +}; + +export default SystemBackupsView; diff --git a/frontend/src/pages/System/Backups/table.tsx b/frontend/src/pages/System/Backups/table.tsx new file mode 100644 index 000000000..035d0d50e --- /dev/null +++ b/frontend/src/pages/System/Backups/table.tsx @@ -0,0 +1,70 @@ +import { faClock, faHistory, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ActionButton, PageTable, useShowModal } from "components"; +import React, { FunctionComponent, useMemo } from "react"; +import { ButtonGroup } from "react-bootstrap"; +import { Column } from "react-table"; +import SystemBackupDeleteModal from "./BackupDeleteModal"; +import SystemBackupRestoreModal from "./BackupRestoreModal"; + +interface Props { + backups: readonly System.Backups[]; +} + +const Table: FunctionComponent = ({ backups }) => { + const backupModal = useShowModal(); + const columns: Column[] = useMemo[]>( + () => [ + { + accessor: "type", + Cell: , + }, + { + Header: "Name", + accessor: "filename", + className: "text-nowrap", + }, + { + Header: "Time", + accessor: "date", + className: "text-nowrap", + }, + { + accessor: "id", + Cell: (row) => { + return ( + + + backupModal("restore", row.row.original.filename) + } + > + backupModal("delete", row.row.original.filename)} + > + + ); + }, + }, + ], + [backupModal] + ); + + return ( + + + + + + ); +}; + +export default Table;