Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
187 commits
Select commit Hold shift + click to select a range
9cea3d7
feat: ProcessRepository#createSpawnDuplexStream
insertish Nov 18, 2025
a6fb942
test: write tests for ProcessRepository#createSpawnDuplexStream
insertish Nov 18, 2025
cc78952
feat: StorageRepository#createGzip,createGunzip,createPlainReadStream
insertish Nov 18, 2025
f67153e
feat: backups util (args, create, restore, progress)
insertish Nov 18, 2025
0419539
feat: wait on maintenance operation lock on boot
insertish Nov 18, 2025
0ae03f6
chore: use backup util from backup.service.ts
insertish Nov 18, 2025
7e7d6af
feat: list/delete backups (maintenance services)
insertish Nov 18, 2025
b01b63b
chore: open api
insertish Nov 18, 2025
edc1333
chore: add missing repositories to MaintenanceModule
insertish Nov 19, 2025
73ae766
refactor: move logSecret into module init
insertish Nov 19, 2025
d040de2
feat: initialise StorageCore in maintenance mode
insertish Nov 19, 2025
c090a1a
feat: authenticate websocket requests in maintenance mode
insertish Nov 19, 2025
56c93a7
test: add mock for new storage fns
insertish Nov 19, 2025
7c2e8b1
feat: add MaintenanceEphemeralStateRepository
insertish Nov 19, 2025
af741a4
test: update service worker tests
insertish Nov 19, 2025
442fe6e
feat: add external maintenance mode status
insertish Nov 19, 2025
26587dd
feat: synchronised status, restore db action
insertish Nov 20, 2025
31410c3
test: backup restore service tests
insertish Nov 20, 2025
dd1cf12
refactor: DRY end maintenance
insertish Nov 20, 2025
53a74a7
feat: list and delete backup routes
insertish Nov 20, 2025
31f4665
feat: start action on boot
insertish Nov 20, 2025
f778a42
fix: should set status on restore end
insertish Nov 20, 2025
f69c49a
refactor: add maintenanceStore to hold writables
insertish Nov 20, 2025
56a4159
feat: sync status to web app
insertish Nov 20, 2025
2e15012
feat: web impl.
insertish Nov 20, 2025
b887d4f
test: various utils for testings
insertish Nov 20, 2025
9d4ad11
test: web e2e tests
insertish Nov 20, 2025
ed4a850
test: e2e maintenance spec
insertish Nov 20, 2025
d5351de
merge: remote-tracking branch 'origin/main' into feat/database-restores
insertish Nov 20, 2025
d6e3d26
test: update cli spec
insertish Nov 20, 2025
161918e
chore: e2e lint
insertish Nov 20, 2025
5be0827
chore: lint fixes
insertish Nov 20, 2025
8463968
chore: lint fixes
insertish Nov 20, 2025
270d7e3
feat: start restore flow route
insertish Nov 20, 2025
824f6e5
test: update e2e tests
insertish Nov 20, 2025
3933b23
chore: remove neon lights on maintenance action pages
insertish Nov 21, 2025
8405a9b
fix: use 'startRestoreFlow' on onboarding page
insertish Nov 21, 2025
fccb31d
chore: ignore any library folder in `docker/`
insertish Nov 21, 2025
3d2d7fa
fix: load status on boot
insertish Nov 21, 2025
19ba230
feat: upload backups
insertish Nov 21, 2025
a3c6d71
refactor: permit any .sql(.gz) to be listed/restored
insertish Nov 21, 2025
174670a
feat: download backups from list
insertish Nov 21, 2025
a724562
fix: permit uploading just .sql files
insertish Nov 21, 2025
874782e
feat: restore just .sql files
insertish Nov 21, 2025
d2a4dd6
fix: don't show backups list if logged out
insertish Nov 21, 2025
cbf3a2c
feat: system integrity check in restore flow
insertish Nov 21, 2025
fdacf0e
test: not providing failed backups in API anymore
insertish Nov 21, 2025
6cefb9c
test: util should also not try to use failedBackups
insertish Nov 21, 2025
53ef26a
fix: actually assign inputStream
insertish Nov 21, 2025
f7b59f5
test: correct test backup prep.
insertish Nov 21, 2025
ac9a587
fix: ensure task is defined to show error
insertish Nov 21, 2025
e93652a
test: fix docker cp command
insertish Nov 21, 2025
5bca880
test: update e2e web spec to select next button
insertish Nov 21, 2025
539167e
test: update e2e api tests
insertish Nov 21, 2025
f6316ca
test: refactor timeouts
insertish Nov 21, 2025
0940c31
chore: remove `showDelete` from maint. settings
insertish Nov 21, 2025
86d8e1a
chore: lint
insertish Nov 21, 2025
b46d6cd
chore: lint
insertish Nov 21, 2025
534a9f5
fix: make sure backups are correctly sorted for clean up
insertish Nov 21, 2025
3863ff7
test: update service spec
insertish Nov 24, 2025
a61f9d7
test: adjust e2e timeout
insertish Nov 24, 2025
5a6083f
test: increase web timeouts for ci
insertish Nov 24, 2025
fd6f043
chore: move gitignore changes
insertish Nov 24, 2025
f84bdc1
chore: additional filename validation
insertish Nov 24, 2025
e2ca0c6
refactor: better typings for integrity API
insertish Nov 24, 2025
3be039b
feat: higher accuracy progress tracking
insertish Nov 24, 2025
220d63e
chore: delay lock retry
insertish Nov 24, 2025
45b5752
refactor: remove old maintenance settings
insertish Nov 24, 2025
b99d929
refactor: clean up tailwind classes
insertish Nov 24, 2025
1ad2282
refactor: use while loop rather than recursive calls
insertish Nov 24, 2025
9f5f90b
test: update service specs
insertish Nov 24, 2025
481ec02
merge: remote-tracking branch 'origin/main' into feat/database-restores
insertish Nov 24, 2025
0f145a5
chore: check canParse too
insertish Nov 24, 2025
95d9bcb
chore: lint
insertish Nov 24, 2025
86b7b1c
merge: remote-tracking branch 'origin/main' into feat/database-restores
insertish Nov 25, 2025
ca116ca
fix: logic error causing infinite loop
insertish Nov 25, 2025
87f34ba
refactor: use <ProgressBar /> from ui library
insertish Nov 25, 2025
1cdffeb
fix: create or overwrite file
insertish Nov 25, 2025
390f0b2
chore: i18n pass, update progress bar
insertish Nov 25, 2025
c8fea45
fix: wrong translation string
insertish Nov 25, 2025
a091ca7
chore: update colour variables
insertish Nov 25, 2025
47f5232
test: update web test for new maint. page
insertish Nov 25, 2025
96426fe
chore: format, fix key
insertish Nov 25, 2025
52edcde
merge: remote-tracking branch 'origin/main' into feat/database-restores
insertish Nov 25, 2025
9cb9681
merge: remote-tracking branch 'origin/main' into feat/database-restores
insertish Nov 25, 2025
6ec10a5
test: update tests to be more linter complaint & use new routines
insertish Nov 25, 2025
e3f350e
merge: remote-tracking branch 'origin/main' into feat/database-restores
insertish Nov 26, 2025
8dd865d
chore: update onClick -> onAction, title -> breadcrumbs
insertish Nov 26, 2025
e355dcc
fix: use wrench icon in admin settings sidebar
insertish Nov 28, 2025
cede65f
chore: add translation strings to accordion
insertish Nov 28, 2025
db7169e
merge: remote-tracking branch 'origin/main' into feat/database-restores
insertish Nov 28, 2025
a7fd19d
merge: remote-tracking branch 'origin/main' into feat/database-restores
insertish Dec 1, 2025
8b1ba11
chore: lint
insertish Dec 1, 2025
b5ff460
refactor: move maintenance worker init into service
insertish Dec 2, 2025
94af1bb
refactor: `maintenanceStatus` -> `getMaintenanceStatus`
insertish Dec 2, 2025
a79b4bd
refactor: move status impl into service
insertish Dec 2, 2025
9b95550
refactor: split into database backup controller
insertish Dec 2, 2025
e0428b5
test: split api e2e tests and passing
insertish Dec 3, 2025
0945e18
fix: move end button into authed default maint page
insertish Dec 3, 2025
274775d
fix: also show in restore flow
insertish Dec 3, 2025
ef944c2
fix: import getMaintenanceStatus
insertish Dec 3, 2025
f9d2a97
test: split web e2e tests
insertish Dec 3, 2025
305bf60
refactor: ensure detect install is consistently named
insertish Dec 3, 2025
20d1e61
chore: ensure admin for detect install while out of maint.
insertish Dec 3, 2025
17dfced
refactor: remove state repository
insertish Dec 3, 2025
4659ceb
test: update maint. worker service spec
insertish Dec 3, 2025
fe8eb85
test: split backup service spec
insertish Dec 3, 2025
a63b418
refactor: rename db backup routes
insertish Dec 3, 2025
207a8bc
refactor: instead of param, allow bulk backup deletion
insertish Dec 3, 2025
4296211
test: update sdk use in e2e test
insertish Dec 3, 2025
3019091
test: correct deleteBackup call
insertish Dec 3, 2025
cf3686a
fix: correct type for serverinstall response dto
insertish Dec 3, 2025
02265ba
chore: validate filename for deletion
insertish Dec 3, 2025
6b9cc85
test: wip
insertish Dec 3, 2025
adc2d5d
test: backups no longer take path param
insertish Dec 3, 2025
4e2187a
refactor: scope util to database-backups instead of backups
insertish Dec 3, 2025
0d05c0d
fix: update worker controller with new route
insertish Dec 3, 2025
e958516
merge: remote-tracking branch 'immich/main' into feat/database-restores
insertish Dec 3, 2025
2ceeb58
merge: remote-tracking branch 'immich/main' into feat/database-restores
insertish Dec 17, 2025
5ddc509
chore: use new admin page actions
insertish Dec 17, 2025
4c71336
chore: remove stray comment
insertish Dec 17, 2025
21e2e94
test: rename outdated test
insertish Dec 17, 2025
cb52aa0
refactor: getter pattern for maintenance secret
insertish Jan 6, 2026
ff13ba5
refactor: `createSpawnDuplexStream` -> `spawnDuplexStream`
insertish Jan 6, 2026
82e004d
refactor: prefer `Object.assign`
insertish Jan 6, 2026
a30f84e
refactor: remove useless try {} block
insertish Jan 6, 2026
5023e20
refactor: prefer `type Props`
insertish Jan 6, 2026
1a03374
refactor: use luxon API for minutesAgo
insertish Jan 6, 2026
898e7e8
chore: remove change to gitignore
insertish Jan 6, 2026
9c9121c
refactor: prefer `type Props`
insertish Jan 6, 2026
297e2fe
refactor: remove async from onMount
insertish Jan 6, 2026
16fc089
refactor: use luxon toRelative for relative time
insertish Jan 6, 2026
31b8a73
refactor: duplicate logic check
insertish Jan 6, 2026
ecb9b76
merge: remote-tracking branch 'immich/main' into feat/database-restores
insertish Jan 6, 2026
428efa6
chore: open api
insertish Jan 6, 2026
168e355
refactor: begin moving code into web//services
insertish Jan 6, 2026
7f9999c
refactor: don't use template string with $t
insertish Jan 6, 2026
4b90afd
test: use dialog role to match prompt
insertish Jan 6, 2026
fae9d10
Merge remote-tracking branch 'immich/main' into feat/database-restores
insertish Jan 6, 2026
770bf73
refactor: split actions into flow/restore
insertish Jan 6, 2026
126b60c
test: fix action value
insertish Jan 6, 2026
d8d72d3
refactor: move more service calls into web//services
insertish Jan 6, 2026
164a9d5
chore: should void fn return
insertish Jan 7, 2026
2bdb971
chore: bump 2.4.0 to 2.5.0 in controller
insertish Jan 7, 2026
3f52831
chore: bump 2.4.0 to 2.5.0 in controller
insertish Jan 7, 2026
72d7f9b
refactor: use events for web//services
insertish Jan 7, 2026
69d510c
chore: open api
insertish Jan 7, 2026
c6a7d66
Merge branch 'main' into feat/database-restores
insertish Jan 8, 2026
fd9cba5
merge: remote-tracking branch 'origin/main' into feat/database-restores
insertish Jan 12, 2026
33b180a
chore: open api
insertish Jan 12, 2026
9a65ff7
refactor: don't await returned promise
insertish Jan 12, 2026
72673dc
refactor: remove redundant check
insertish Jan 12, 2026
f03fd43
refactor: add `type: command` to actions
insertish Jan 12, 2026
9fa8e90
refactor: split backup entries into own component
insertish Jan 12, 2026
c5d6f11
refactor: split restore flow into separate components
insertish Jan 12, 2026
466f1d6
refactor(web): split BackupDelete event
insertish Jan 13, 2026
fcc7e64
Merge branch 'main' into feat/database-restores
alextran1502 Jan 13, 2026
6b509b7
Merge branch 'main' of github.com:immich-app/immich into feat/databas…
alextran1502 Jan 14, 2026
77fa8bb
chore: stylings
alextran1502 Jan 14, 2026
94d9e70
chore: stylings
alextran1502 Jan 14, 2026
80c3ba7
fix: don't log query failure on first boot
insertish Jan 14, 2026
1b7af0a
feat: support pg_dumpall backups
insertish Jan 14, 2026
e5e8401
feat: display information about each backup
insertish Jan 14, 2026
89f07af
chore: i18n
insertish Jan 14, 2026
6701156
feat: rollback to restore point on migrations failure
insertish Jan 15, 2026
7da09a7
feat: health check after restore
insertish Jan 15, 2026
80c7609
chore: format
insertish Jan 15, 2026
495eedf
refactor: split health check into separate function
insertish Jan 15, 2026
9871dd1
refactor: split health into repository
insertish Jan 15, 2026
18c8432
fix: omit 'health' requirement from createDbBackup
insertish Jan 15, 2026
f93df08
test(e2e): rollback test
insertish Jan 15, 2026
98440e4
fix: wrap text in backup entry
insertish Jan 15, 2026
b296b2d
fix: don't shrink context menu button
insertish Jan 15, 2026
82f90e4
fix: correct CREATE DB syntax for postgres
insertish Jan 15, 2026
5d0bb82
merge: remote-tracking branch 'origin/main' into feat/database-restores
insertish Jan 16, 2026
90f7c25
test: rename backups generated by test
insertish Jan 16, 2026
5c3f882
Merge branch 'main' into feat/database-restores
alextran1502 Jan 16, 2026
4039d73
feat: add filesize to backup response dto
alextran1502 Jan 16, 2026
eb6a385
feat: restore list
alextran1502 Jan 16, 2026
2aef23c
feat: ui work
alextran1502 Jan 16, 2026
ca2337b
fix: e2e test
alextran1502 Jan 16, 2026
606a444
Merge branch 'main' of github.com:immich-app/immich into feat/databas…
alextran1502 Jan 16, 2026
b110489
chore: merge main
jrasm91 Jan 16, 2026
725827c
fix: e2e test
alextran1502 Jan 16, 2026
ff56849
merge main
alextran1502 Jan 19, 2026
4609359
pr feedback
alextran1502 Jan 19, 2026
15a257e
pr feedback
alextran1502 Jan 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
350 changes: 350 additions & 0 deletions e2e/src/api/specs/database-backups.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
import { LoginResponseDto, ManualJobName } from '@immich/sdk';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

describe('/admin/database-backups', () => {
let cookie: string | undefined;
let admin: LoginResponseDto;

beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
await utils.resetBackups(admin.accessToken);
});

describe('GET /', async () => {
it('should succeed and be empty', async () => {
const { status, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
backups: [],
});
});

it('should contain a created backup', async () => {
await utils.createJob(admin.accessToken, {
name: ManualJobName.BackupDatabase,
});

await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');

await expect
.poll(
async () => {
const { status, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);

expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 10_000,
},
)
.toEqual(
expect.objectContaining({
backups: [
expect.objectContaining({
filename: expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/),
filesize: expect.any(Number),
}),
],
}),
);
});
});

describe('DELETE /', async () => {
it('should delete backup', async () => {
const filename = await utils.createBackup(admin.accessToken);

const { status } = await request(app)
.delete(`/admin/database-backups`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ backups: [filename] });

expect(status).toBe(200);

const { status: listStatus, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);

expect(listStatus).toBe(200);
expect(body).toEqual(
expect.objectContaining({
backups: [],
}),
);
});
});

// => action: restore database flow

describe.sequential('POST /start-restore', () => {
afterAll(async () => {
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);

admin = await utils.adminSetup();
});

it.sequential('should not work when the server is configured', async () => {
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();

expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
});

it.sequential('should enter maintenance mode in "database restore mode"', async () => {
await utils.resetDatabase(); // reset database before running this test

const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();

expect(status).toBe(201);

cookie = headers['set-cookie'][0].split(';')[0];

await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();

const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status2).toBe(200);
expect(body).toEqual({
active: true,
action: 'select_database_restore',
});
});
});

// => action: restore database

describe.sequential('POST /backups/restore', () => {
beforeAll(async () => {
await utils.disconnectDatabase();
});

afterAll(async () => {
await utils.connectDatabase();
});

it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
let filename = await utils.createBackup(admin.accessToken);

// work-around until test is running on released version
await utils.move(
`/data/backups/${filename}`,
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
);
filename = 'immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz';

const { status } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: filename,
});

expect(status).toBe(201);

await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();

const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status2).toBe(200);
expect(body).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
}),
);

await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 60_000,
},
)
.toBeFalsy();
});

it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
await utils.prepareTestBackup('corrupted');

const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: 'development-corrupted.sql.gz',
});

expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];

await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();

await expect
.poll(
async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 10_000,
},
)
.toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: 'Something went wrong, see logs!',
}),
);

const { status: status2, body: body2 } = await request(app)
.get('/admin/maintenance/status')
.set('cookie', cookie!)
.send({ token: 'token' });
expect(status2).toBe(200);
expect(body2).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: expect.stringContaining('IM CORRUPTED'),
}),
);

await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});

await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);
});

it.sequential('rollback to restore point if backup is missing admin', { timeout: 60_000 }, async () => {
await utils.prepareTestBackup('empty');

const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: 'development-empty.sql.gz',
});

expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];

await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();

await expect
.poll(
async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 30_000,
},
)
.toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: 'Something went wrong, see logs!',
}),
);

const { status: status2, body: body2 } = await request(app)
.get('/admin/maintenance/status')
.set('cookie', cookie!)
.send({ token: 'token' });
expect(status2).toBe(200);
expect(body2).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: expect.stringContaining('Server health check failed, no admin exists.'),
}),
);

await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});

await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);
});
});
});
Loading
Loading