mirror of https://github.com/morpheus65535/bazarr
Implemented backup and restore feature for configuration file and database
This commit is contained in:
parent
c020a9e892
commit
5bfaba9360
|
@ -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')
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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]);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -14,6 +14,7 @@ export enum QueryKeys {
|
|||
Search = "search",
|
||||
Actions = "actions",
|
||||
Tasks = "tasks",
|
||||
Backups = "backups",
|
||||
Logs = "logs",
|
||||
Infos = "infos",
|
||||
History = "history",
|
||||
|
|
|
@ -51,6 +51,23 @@ class SystemApi extends BaseApi {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
async backups() {
|
||||
const response = await this.get<DataWrapper<System.Backups[]>>("/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<DataWrapper<System.Health[]>>("/health");
|
||||
return response.data;
|
||||
|
|
|
@ -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<Props> = ({
|
||||
export const FileBrowser: FunctionComponent<FileBrowserProps> = ({
|
||||
defaultValue,
|
||||
type,
|
||||
onChange,
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
Check,
|
||||
Chips,
|
||||
CollapseBox,
|
||||
File,
|
||||
Group,
|
||||
Input,
|
||||
Message,
|
||||
|
@ -175,6 +176,10 @@ const SettingsGeneralView: FunctionComponent = () => {
|
|||
<Message>Debug logging should only be enabled temporarily</Message>
|
||||
</Input>
|
||||
</Group>
|
||||
<Group header="Backups">
|
||||
<File settingKey="settings-backup-folder" type="bazarr"></File>
|
||||
<Message>Absolute path to the backup directory</Message>
|
||||
</Group>
|
||||
<Group header="Analytics">
|
||||
<Input>
|
||||
<Check label="Enable" settingKey="settings-analytics-enabled"></Check>
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
SettingsProvider,
|
||||
} from "../components";
|
||||
import {
|
||||
backupOptions,
|
||||
dayOptions,
|
||||
diskUpdateOptions,
|
||||
episodesSyncOptions,
|
||||
|
@ -143,6 +144,34 @@ const SettingsSchedulerView: FunctionComponent = () => {
|
|||
></Selector>
|
||||
</Input>
|
||||
</Group>
|
||||
<Group header="Backup">
|
||||
<CollapseBox>
|
||||
<CollapseBox.Control>
|
||||
<Input name="Backup config and database">
|
||||
<Selector
|
||||
settingKey="settings-backup-frequency"
|
||||
options={backupOptions}
|
||||
></Selector>
|
||||
</Input>
|
||||
</CollapseBox.Control>
|
||||
<CollapseBox.Content on={(k) => k === "Weekly"}>
|
||||
<Input name="Day of The Week">
|
||||
<Selector
|
||||
settingKey="settings-backup-day"
|
||||
options={dayOptions}
|
||||
></Selector>
|
||||
</Input>
|
||||
</CollapseBox.Content>
|
||||
<CollapseBox.Content on={(k) => k === "Daily" || k === "Weekly"}>
|
||||
<Input name="Time of The Day">
|
||||
<Selector
|
||||
settingKey="settings-backup-hour"
|
||||
options={timeOptions}
|
||||
></Selector>
|
||||
</Input>
|
||||
</CollapseBox.Content>
|
||||
</CollapseBox>
|
||||
</Group>
|
||||
</SettingsProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -17,6 +17,8 @@ export const diskUpdateOptions: SelectorOption<string>[] = [
|
|||
{ label: "Weekly", value: "Weekly" },
|
||||
];
|
||||
|
||||
export const backupOptions = diskUpdateOptions;
|
||||
|
||||
export const dayOptions: SelectorOption<number>[] = [
|
||||
{ label: "Monday", value: 0 },
|
||||
{ label: "Tuesday", value: 1 },
|
||||
|
|
|
@ -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<Override<ButtonProps, BSButtonProps>> = (
|
|||
></BSButton>
|
||||
);
|
||||
};
|
||||
|
||||
type FileProps = {} & BaseInput<string>;
|
||||
|
||||
export const File: FunctionComponent<Override<FileProps, FileBrowserProps>> = (
|
||||
props
|
||||
) => {
|
||||
const { settingKey, override, ...file } = props;
|
||||
const value = useLatest<string>(settingKey, isString);
|
||||
const update = useSingleUpdate();
|
||||
|
||||
return (
|
||||
<FileBrowser
|
||||
defaultValue={value ?? undefined}
|
||||
onChange={(p) => {
|
||||
update(p, settingKey);
|
||||
}}
|
||||
{...file}
|
||||
></FileBrowser>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<Props> = ({ ...modal }) => {
|
||||
const result = useModalPayload<string>(modal.modalKey);
|
||||
|
||||
const { mutateAsync } = useDeleteBackups();
|
||||
|
||||
const closeModal = useCloseModal();
|
||||
|
||||
const footer = (
|
||||
<div className="d-flex flex-row-reverse flex-grow-1 justify-content-between">
|
||||
<div>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
className="mr-2"
|
||||
onClick={() => {
|
||||
closeModal(modal.modalKey);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<AsyncButton
|
||||
noReset
|
||||
promise={() => {
|
||||
if (result) {
|
||||
return mutateAsync(result);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}}
|
||||
onSuccess={() => closeModal(modal.modalKey)}
|
||||
>
|
||||
Delete
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal title="Delete Backup" footer={footer} {...modal}>
|
||||
Are you sure you want to delete the backup '{result}'?
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemBackupDeleteModal;
|
|
@ -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<Props> = ({ ...modal }) => {
|
||||
const result = useModalPayload<string>(modal.modalKey);
|
||||
|
||||
const { mutateAsync } = useRestoreBackups();
|
||||
|
||||
const closeModal = useCloseModal();
|
||||
|
||||
const footer = (
|
||||
<div className="d-flex flex-row-reverse flex-grow-1 justify-content-between">
|
||||
<div>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
className="mr-2"
|
||||
onClick={() => {
|
||||
closeModal(modal.modalKey);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<AsyncButton
|
||||
noReset
|
||||
promise={() => {
|
||||
if (result) {
|
||||
return mutateAsync(result);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}}
|
||||
onSuccess={() => closeModal(modal.modalKey)}
|
||||
>
|
||||
Restore
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal title="Restore Backup" footer={footer} {...modal}>
|
||||
Are you sure you want to restore the backup '{result}'? Bazarr will
|
||||
automatically restart and reload the UI during the restore process.
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemBackupRestoreModal;
|
|
@ -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<Props> = () => {
|
||||
const backups = useSystemBackups();
|
||||
|
||||
const { mutate: backup, isLoading: isResetting } = useCreateBackups();
|
||||
|
||||
return (
|
||||
<QueryOverlay result={backups}>
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>Backups - Bazarr (System)</title>
|
||||
</Helmet>
|
||||
<ContentHeader>
|
||||
<ContentHeader.Button
|
||||
icon={faFileArchive}
|
||||
updating={isResetting}
|
||||
onClick={() => backup()}
|
||||
>
|
||||
Backup Now
|
||||
</ContentHeader.Button>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
<Table backups={backups.data ?? []}></Table>
|
||||
</Row>
|
||||
</Container>
|
||||
</QueryOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemBackupsView;
|
|
@ -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<Props> = ({ backups }) => {
|
||||
const backupModal = useShowModal();
|
||||
const columns: Column<System.Backups>[] = useMemo<Column<System.Backups>[]>(
|
||||
() => [
|
||||
{
|
||||
accessor: "type",
|
||||
Cell: <FontAwesomeIcon icon={faClock}></FontAwesomeIcon>,
|
||||
},
|
||||
{
|
||||
Header: "Name",
|
||||
accessor: "filename",
|
||||
className: "text-nowrap",
|
||||
},
|
||||
{
|
||||
Header: "Time",
|
||||
accessor: "date",
|
||||
className: "text-nowrap",
|
||||
},
|
||||
{
|
||||
accessor: "id",
|
||||
Cell: (row) => {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<ActionButton
|
||||
icon={faHistory}
|
||||
onClick={() =>
|
||||
backupModal("restore", row.row.original.filename)
|
||||
}
|
||||
></ActionButton>
|
||||
<ActionButton
|
||||
icon={faTrash}
|
||||
onClick={() => backupModal("delete", row.row.original.filename)}
|
||||
></ActionButton>
|
||||
</ButtonGroup>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[backupModal]
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<PageTable columns={columns} data={backups}></PageTable>
|
||||
<SystemBackupRestoreModal
|
||||
modalKey="restore"
|
||||
size="lg"
|
||||
></SystemBackupRestoreModal>
|
||||
<SystemBackupDeleteModal
|
||||
modalKey="delete"
|
||||
size="lg"
|
||||
></SystemBackupDeleteModal>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
Loading…
Reference in New Issue