Add:Ability to edit backup location path on backups page #2973

- Added api endpoint PATCH /api/backups/path
- Cleanup backup page UI for mobile screens
This commit is contained in:
advplyr 2024-06-19 17:14:37 -05:00
parent 8498cab842
commit 331d7a41ab
5 changed files with 149 additions and 22 deletions

View File

@ -1,10 +1,27 @@
<template>
<div>
<app-settings-content :header-text="$strings.HeaderBackups" :description="$strings.MessageBackupsDescription">
<div v-if="backupLocation" class="flex items-center mb-4">
<span class="material-icons-outlined text-2xl text-black-50 mr-2">folder</span>
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelBackupLocation }}:</span>
<div class="text-gray-100 pl-4">{{ backupLocation }}</div>
<div v-if="backupLocation" class="mb-4 max-w-full overflow-hidden">
<div class="flex items-center mb-0.5">
<span class="material-icons-outlined text-2xl text-black-50 mr-2">folder</span>
<span class="text-white text-opacity-60 uppercase text-sm whitespace-nowrap">{{ $strings.LabelBackupLocation }}:</span>
</div>
<div v-if="!showEditBackupPath" class="inline-flex items-center w-full overflow-hidden">
<p class="text-gray-100 max-w-[calc(100%-40px)] text-sm sm:text-base break-words">{{ backupLocation }}</p>
<div class="w-10 min-w-10 flex items-center justify-center">
<button class="text-black-50 hover:text-yellow-500 inline-flex" type="button" @click="showEditBackupPath = !showEditBackupPath">
<span class="material-icons text-lg">edit</span>
</button>
</div>
</div>
<div v-else>
<form class="flex items-center w-full space-x-1" @submit.prevent="saveBackupPath">
<ui-text-input v-model="newBackupLocation" :disabled="savingBackupPath" class="w-full max-w-[calc(100%-50px)] text-sm h-8" />
<ui-btn small :loading="savingBackupPath" color="success" type="submit" class="h-8">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn small :disabled="savingBackupPath" type="button" class="h-8" @click="cancelEditBackupPath">{{ $strings.ButtonCancel }}</ui-btn>
</form>
<p class="text-sm text-warning/80 pt-1">{{ $strings.MessageBackupsLocationEditNote }}</p>
</div>
</div>
<div class="flex items-center py-2">
@ -15,21 +32,23 @@
</div>
<div v-if="enableBackups" class="mb-6">
<div class="flex items-center pl-6 mb-2">
<span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span>
<div class="w-40">
<div class="flex items-center pl-0 sm:pl-6 mb-2">
<span class="material-icons-outlined text-xl sm:text-2xl text-black-50 mr-2">schedule</span>
<div class="w-32 min-w-32 sm:w-40 sm:min-w-40">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span>
</div>
<div class="text-gray-100">{{ scheduleDescription }}</div>
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer ml-2" @click="showCronBuilder = !showCronBuilder">edit</span>
<div class="text-gray-100 text-sm sm:text-base">{{ scheduleDescription }}</div>
<button class="ml-2 text-black-50 hover:text-yellow-500 inline-flex" type="button" @click="showCronBuilder = !showCronBuilder">
<span class="material-icons text-lg">edit</span>
</button>
</div>
<div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2">
<span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span>
<div class="w-40">
<div v-if="nextBackupDate" class="flex items-center pl-0 sm:pl-6 py-0.5">
<span class="material-icons-outlined text-xl sm:text-2xl text-black-50 mr-2">event</span>
<div class="w-32 min-w-32 sm:w-40 sm:min-w-40">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span>
</div>
<div class="text-gray-100">{{ nextBackupDate }}</div>
<div class="text-gray-100 text-sm sm:text-base">{{ nextBackupDate }}</div>
</div>
</div>
@ -49,7 +68,7 @@
</ui-tooltip>
</div>
<tables-backups-table @loaded="backupsLoaded" />
<tables-backups-table ref="backupsTable" @loaded="backupsLoaded" />
<modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" />
</app-settings-content>
@ -72,7 +91,10 @@ export default {
cronExpression: '',
newServerSettings: {},
showCronBuilder: false,
backupLocation: ''
showEditBackupPath: false,
backupLocation: '',
newBackupLocation: '',
savingBackupPath: false
}
},
watch: {
@ -107,6 +129,39 @@ export default {
methods: {
backupsLoaded(backupLocation) {
this.backupLocation = backupLocation
this.newBackupLocation = backupLocation
},
cancelEditBackupPath() {
this.newBackupLocation = this.backupLocation
this.showEditBackupPath = false
},
saveBackupPath() {
if (!this.newBackupLocation?.trim()) {
this.$toast.error(this.$strings.MessageBackupsLocationPathEmpty)
return
}
this.newBackupLocation = this.newBackupLocation.trim()
if (this.newBackupLocation === this.backupLocation) {
this.showEditBackupPath = false
return
}
this.savingBackupPath = true
this.$axios
.patch('/api/backups/path', { path: this.newBackupLocation })
.then(() => {
this.backupLocation = this.newBackupLocation
this.showEditBackupPath = false
this.$refs.backupsTable.loadBackups()
})
.catch((error) => {
console.error('Failed to save backup path', error)
const errorMsg = error.response?.data || 'Failed to save backup path'
this.$toast.error(errorMsg)
})
.finally(() => {
this.savingBackupPath = false
})
},
updateBackupsSettings() {
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {

View File

@ -597,6 +597,8 @@
"MessageAddToPlayerQueue": "Add to player queue",
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
"MessageBackupsLocationPathEmpty": "Backup location path cannot be empty",
"MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups",
"MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.",
"MessageBookshelfNoCollections": "You haven't made any collections yet",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open",

View File

@ -1,5 +1,8 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const { encodeUriPath } = require('../utils/fileUtils')
const Database = require('../Database')
const fileUtils = require('../utils/fileUtils')
class BackupController {
constructor() {}
@ -31,6 +34,56 @@ class BackupController {
this.backupManager.uploadBackup(req, res)
}
/**
* PATCH: /api/backups/path
* Update the backup path
*
* @this import('../routers/ApiRouter')
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async updatePath(req, res) {
// Validate path is not empty and is a string
if (!req.body.path || !req.body.path?.trim?.()) {
Logger.error('[BackupController] Update backup path invalid')
return res.status(400).send('Invalid request body. Must include path.')
}
const newBackupPath = fileUtils.filePathToPOSIX(Path.resolve(req.body.path))
if (newBackupPath === this.backupManager.backupPath) {
Logger.debug(`[BackupController] Backup path unchanged: ${newBackupPath}`)
return res.status(200).send('Backup path unchanged')
}
Logger.info(`[BackupController] Updating backup path to "${newBackupPath}" from "${this.backupManager.backupPath}"`)
// Check if backup path is set in environment variable
if (process.env.BACKUP_PATH) {
Logger.warn(`[BackupController] Backup path is set in environment variable BACKUP_PATH. Backup path will be reverted on server restart.`)
}
// Validate backup path is writable and create folder if it does not exist
try {
const direxists = await fs.pathExists(newBackupPath)
if (!direxists) {
// If folder does not exist try to make it
await fs.mkdir(newBackupPath)
}
} catch (error) {
Logger.error(`[BackupController] updatePath: Failed to ensure backup path "${newBackupPath}"`, error)
return res.status(400).send(`Invalid backup path "${req.body.path}"`)
}
Database.serverSettings.backupPath = newBackupPath
await Database.updateServerSettings()
await this.backupManager.reload()
res.sendStatus(200)
}
/**
* api/backups/:id/download
*
@ -39,7 +92,7 @@ class BackupController {
*/
download(req, res) {
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + req.backup.fullPath)
const encodedURI = fileUtils.encodeUriPath(global.XAccel + req.backup.fullPath)
Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
}

View File

@ -51,6 +51,16 @@ class BackupManager {
this.scheduleCron()
}
/**
* Reload backups after updating backup path
*/
async reload() {
Logger.info(`[BackupManager] Reloading backups with backup path "${this.backupPath}"`)
this.backups = []
await this.loadBackups()
this.updateCronSchedule()
}
scheduleCron() {
if (!this.backupSchedule) {
Logger.info(`[BackupManager] Auto Backups are disabled`)

View File

@ -40,6 +40,7 @@ class ApiRouter {
this.auth = Server.auth
this.playbackSessionManager = Server.playbackSessionManager
this.abMergeManager = Server.abMergeManager
/** @type {import('../managers/BackupManager')} */
this.backupManager = Server.backupManager
/** @type {import('../Watcher')} */
this.watcher = Server.watcher
@ -193,6 +194,7 @@ class ApiRouter {
this.router.get('/backups/:id/download', BackupController.middleware.bind(this), BackupController.download.bind(this))
this.router.get('/backups/:id/apply', BackupController.middleware.bind(this), BackupController.apply.bind(this))
this.router.post('/backups/upload', BackupController.middleware.bind(this), BackupController.upload.bind(this))
this.router.patch('/backups/path', BackupController.middleware.bind(this), BackupController.updatePath.bind(this))
//
// File System Routes
@ -308,7 +310,6 @@ class ApiRouter {
this.router.post('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.create.bind(this))
this.router.delete('/custom-metadata-providers/:id', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.delete.bind(this))
//
// Misc Routes
//
@ -567,10 +568,13 @@ class ApiRouter {
}
}
// Remove authors without an id
mediaMetadata.authors = mediaMetadata.authors.filter(au => !!au.id)
mediaMetadata.authors = mediaMetadata.authors.filter((au) => !!au.id)
if (newAuthors.length) {
await Database.createBulkAuthors(newAuthors)
SocketAuthority.emitter('authors_added', newAuthors.map(au => au.toJSON()))
SocketAuthority.emitter(
'authors_added',
newAuthors.map((au) => au.toJSON())
)
}
}
@ -611,10 +615,13 @@ class ApiRouter {
}
}
// Remove series without an id
mediaMetadata.series = mediaMetadata.series.filter(se => se.id)
mediaMetadata.series = mediaMetadata.series.filter((se) => se.id)
if (newSeries.length) {
await Database.createBulkSeries(newSeries)
SocketAuthority.emitter('multiple_series_added', newSeries.map(se => se.toJSON()))
SocketAuthority.emitter(
'multiple_series_added',
newSeries.map((se) => se.toJSON())
)
}
}
}