feat(web): exposed a job to manually trigger database backup procedures (#16622)

* feat(web): exposed a new job to create a manual database backup

* chore(server): added a new test case

* chore(server): moved job to backup db into the create job popup

* remove irrelevant change

* openapi

* chore: formatting

* docs: trigger backup documentation

---------

Co-authored-by: Lorenzo Montanari <13736036+l0ll098@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Zack Pollard <zack@futo.org>
This commit is contained in:
Lorenzo Montanari 2025-03-11 12:30:43 +01:00 committed by GitHub
parent decc878267
commit d7e0f0e70e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 28 additions and 2 deletions

View File

@ -30,6 +30,13 @@ As mentioned above, you should make your own backup of these together with the a
You can adjust the schedule and amount of kept backups in the [admin settings](http://my.immich.app/admin/system-settings?isOpen=backup). You can adjust the schedule and amount of kept backups in the [admin settings](http://my.immich.app/admin/system-settings?isOpen=backup).
By default, Immich will keep the last 14 backups and create a new backup every day at 2:00 AM. By default, Immich will keep the last 14 backups and create a new backup every day at 2:00 AM.
#### Trigger Backup
You are able to trigger a backup in the [admin job status page](http://my.immich.app/admin/jobs-status).
Visit the page, open the "Create job" modal from the top right, select "Backup Database" and click "Confirm".
A job will run and trigger a backup, you can verify this worked correctly by checking the logs or the backup folder.
This backup will count towards the last X backups that will be kept based on your settings.
#### Restoring #### Restoring
We hope to make restoring simpler in future versions, for now you can find the backups in the `UPLOAD_LOCATION/backups` folder on your host. We hope to make restoring simpler in future versions, for now you can find the backups in the `UPLOAD_LOCATION/backups` folder on your host.

View File

@ -28,6 +28,7 @@ class ManualJobName {
static const userCleanup = ManualJobName._(r'user-cleanup'); static const userCleanup = ManualJobName._(r'user-cleanup');
static const memoryCleanup = ManualJobName._(r'memory-cleanup'); static const memoryCleanup = ManualJobName._(r'memory-cleanup');
static const memoryCreate = ManualJobName._(r'memory-create'); static const memoryCreate = ManualJobName._(r'memory-create');
static const backupDatabase = ManualJobName._(r'backup-database');
/// List of all possible values in this [enum][ManualJobName]. /// List of all possible values in this [enum][ManualJobName].
static const values = <ManualJobName>[ static const values = <ManualJobName>[
@ -36,6 +37,7 @@ class ManualJobName {
userCleanup, userCleanup,
memoryCleanup, memoryCleanup,
memoryCreate, memoryCreate,
backupDatabase,
]; ];
static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value);
@ -79,6 +81,7 @@ class ManualJobNameTypeTransformer {
case r'user-cleanup': return ManualJobName.userCleanup; case r'user-cleanup': return ManualJobName.userCleanup;
case r'memory-cleanup': return ManualJobName.memoryCleanup; case r'memory-cleanup': return ManualJobName.memoryCleanup;
case r'memory-create': return ManualJobName.memoryCreate; case r'memory-create': return ManualJobName.memoryCreate;
case r'backup-database': return ManualJobName.backupDatabase;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -9918,7 +9918,8 @@
"tag-cleanup", "tag-cleanup",
"user-cleanup", "user-cleanup",
"memory-cleanup", "memory-cleanup",
"memory-create" "memory-create",
"backup-database"
], ],
"type": "string" "type": "string"
}, },

View File

@ -3580,7 +3580,8 @@ export enum ManualJobName {
TagCleanup = "tag-cleanup", TagCleanup = "tag-cleanup",
UserCleanup = "user-cleanup", UserCleanup = "user-cleanup",
MemoryCleanup = "memory-cleanup", MemoryCleanup = "memory-cleanup",
MemoryCreate = "memory-create" MemoryCreate = "memory-create",
BackupDatabase = "backup-database"
} }
export enum JobName { export enum JobName {
ThumbnailGeneration = "thumbnailGeneration", ThumbnailGeneration = "thumbnailGeneration",

View File

@ -237,6 +237,7 @@ export enum ManualJobName {
USER_CLEANUP = 'user-cleanup', USER_CLEANUP = 'user-cleanup',
MEMORY_CLEANUP = 'memory-cleanup', MEMORY_CLEANUP = 'memory-cleanup',
MEMORY_CREATE = 'memory-create', MEMORY_CREATE = 'memory-create',
BACKUP_DATABASE = 'backup-database',
} }
export enum AssetPathType { export enum AssetPathType {

View File

@ -195,6 +195,14 @@ describe(JobService.name, () => {
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } });
}); });
it('should handle a start backup database command', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.BACKUP_DATABASE, { command: JobCommand.START, force: false });
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.BACKUP_DATABASE, data: { force: false } });
});
it('should throw a bad request when an invalid queue is used', async () => { it('should throw a bad request when an invalid queue is used', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });

View File

@ -39,6 +39,10 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
return { name: JobName.MEMORIES_CREATE }; return { name: JobName.MEMORIES_CREATE };
} }
case ManualJobName.BACKUP_DATABASE: {
return { name: JobName.BACKUP_DATABASE };
}
default: { default: {
throw new BadRequestException('Invalid job name'); throw new BadRequestException('Invalid job name');
} }

View File

@ -46,6 +46,7 @@
{ title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup }, { title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup },
{ title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup }, { title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup },
{ title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate }, { title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate },
{ title: $t('admin.backup_database'), value: ManualJobName.BackupDatabase },
].map(({ value, title }) => ({ id: value, label: title, value })); ].map(({ value, title }) => ({ id: value, label: title, value }));
const handleCancel = () => (isOpen = false); const handleCancel = () => (isOpen = false);