feat: backups (#32268)

Signed-off-by: izzy <me@insrt.uk>
Co-authored-by: Zack Pollard <zack@futo.org>
(cherry picked from commit 177b57b689be293bad96b8092e473dc03e068e9c)
This commit is contained in:
Paul Makles
2026-10-31 07:21:01 +03:00
committed by Codex
parent 132af2fc74
commit 925aaed54c
81 changed files with 7712 additions and 279 deletions
@@ -0,0 +1,384 @@
import * as sdk from '@immich/sdk';
import { LoginResponseDto, StorageFolder } from '@immich/sdk';
import { io, Socket } from 'socket.io-client';
import { createUserDto } from 'src/fixtures';
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/futo-backups', () => {
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
let requestOpts: any;
let filename: string;
let socket: Socket;
let libraryId: string;
beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
requestOpts = { headers: asBearerAuth(admin.accessToken) };
await utils.resetBackups(admin.accessToken);
await sdk.futoBackupsResetOrchestrator(requestOpts);
socket = io(baseUrl, {
path: '/api/futo-backups/socket.io',
transports: ['websocket'],
extraHeaders: asBearerAuth(admin.accessToken),
});
socket.onAny(console.info);
});
afterAll(async () => {
socket.close();
// trigger update for orchestrator module config
if (libraryId) {
await utils.deleteLibrary(admin.accessToken, libraryId);
}
});
const finishedJobStatuses = new Set(['complete', 'warn', 'failed', 'cancelled', 'incomplete']);
const waitForMessage = (type: string, matches: (payload: any) => boolean = () => true) => {
return new Promise((resolve) => {
const listener = (msg: string) => {
const payload = JSON.parse(msg);
if (payload.type !== type || !matches(payload)) {
return;
}
resolve(payload);
socket.offAny(listener);
};
socket.onAny(listener);
});
};
describe('Orchestration Module', async () => {
it('works', async () => {
await expect(sdk.futoBackupsOnboardingStatus(requestOpts)).resolves.toEqual(
expect.objectContaining({
hasOnboardedKey: false,
hasBackend: false,
hasBackup: false,
hasSchedule: false,
hasSkippedExtraConfig: false,
}),
);
});
it('is inaccessible without admin', async () => {
await expect(sdk.futoBackupsOnboardingStatus({ headers: asBearerAuth(nonAdmin.accessToken) })).rejects.toEqual(
expect.objectContaining({ data: { message: expect.any(String) } }),
);
});
it('is inaccessible without logging in', async () => {
await expect(sdk.futoBackupsOnboardingStatus()).rejects.toEqual(
expect.objectContaining({ data: { message: 'Authentication required' } }),
);
});
});
describe('Local Backup', { concurrent: false }, async () => {
beforeAll(async () => {
await sdk.futoBackupsImportRecoveryKey(
{
importRecoveryKeyRequest: {
recoveryKey: '0'.repeat(64),
},
},
requestOpts,
);
});
it('configures a local backend', { concurrent: false }, async () => {
await utils.mkFolder('/local-backend');
await sdk.futoBackupsCreateLocalBackend(
{
createLocalBackendRequestDto: {
path: '/local-backend',
},
},
requestOpts,
);
});
it('configures Immich backup', { concurrent: false }, async () => {
const event = waitForMessage('IntegrationUpdate');
await sdk.futoBackupsConfigureImmichIntegration(
{
configureImmichIntegrationRequestDto: {
name: 'Immich',
worm: false,
cron: '0 3 * * *',
backupConfiguration: true,
dataFolders: [StorageFolder.Backups, StorageFolder.Upload],
libraries: ['all'],
},
},
requestOpts,
);
await event;
await expect(sdk.futoBackupsGetIntegrations(requestOpts)).resolves.toEqual(
expect.objectContaining({
immichIntegration: expect.objectContaining({
configuration: {
backupConfiguration: true,
dataFolders: ['backups', 'upload'],
libraries: ['all'],
},
}),
immichState: {
dataFolders: expect.arrayContaining(Object.values(StorageFolder)),
dataPath: '/data',
// may have stale libraries from other test runs
libraries: expect.any(Array),
},
}),
);
});
it('updates configuration', { concurrent: false }, async () => {
await utils.mkFolder('/test');
({ id: libraryId } = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
name: 'My Library',
importPaths: ['/test'],
}));
await expect(sdk.futoBackupsGetIntegrations(requestOpts)).resolves.toEqual(
expect.objectContaining({
immichIntegration: expect.any(Object),
immichState: expect.objectContaining({
libraries: expect.arrayContaining([
expect.objectContaining({
name: 'My Library',
importPaths: ['/test'],
}),
]),
}),
}),
);
});
it('creates a snapshot', { concurrent: false }, async () => {
const event = waitForMessage('JobUpdate', ({ job }) => finishedJobStatuses.has(job?.status));
const { repositories } = await sdk.futoBackupsGetRepositories(requestOpts);
const { id } = repositories[0];
filename = await utils.createBackup(admin.accessToken);
await sdk.futoBackupsCreateBackup({ id }, requestOpts);
await event;
const { snapshots } = await sdk.futoBackupsGetSnapshots({ id }, requestOpts);
const { id: snapshotId } = snapshots[0];
await expect(sdk.futoBackupsGetSnapshotListing({ id, snapshot: snapshotId }, requestOpts)).resolves
.toMatchInlineSnapshot(`
{
"items": [
{
"isDirectory": true,
"path": "/data",
},
{
"isDirectory": true,
"path": "/test",
},
],
"parent": "/",
"path": "/",
}
`);
await expect(sdk.futoBackupsGetSnapshotListing({ id, snapshot: snapshotId, path: '/data' }, requestOpts)).resolves
.toMatchInlineSnapshot(`
{
"items": [
{
"isDirectory": true,
"path": "/data/backups",
},
{
"isDirectory": true,
"path": "/data/upload",
},
],
"parent": "/",
"path": "/data",
}
`);
await expect(
sdk.futoBackupsGetSnapshotListing({ id, snapshot: snapshotId, path: '/data/backups' }, requestOpts),
).resolves.toEqual(
expect.objectContaining({
items: expect.arrayContaining([
{ isDirectory: false, path: '/data/backups/.immich' },
{ isDirectory: false, path: `/data/backups/${filename}` },
{ isDirectory: true, path: '/data/backups/yucca' },
]),
parent: '/data',
path: '/data/backups',
}),
);
});
});
describe('Restore Local Backup', { concurrent: false }, async () => {
let cookie: string;
beforeAll(async () => {
await sdk.futoBackupsResetOrchestrator(requestOpts);
await utils.resetDatabase();
socket.disconnect();
await utils.disconnectDatabase();
});
afterAll(async () => {
await utils.connectDatabase();
});
it('restores backup', { timeout: 60_000, concurrent: false }, async () => {
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';', 1)[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 maintenanceRequestOpts = {
headers: {
cookie,
},
};
await expect(sdk.futoBackupsGetSchedules(maintenanceRequestOpts)).resolves.toEqual({ schedules: [] });
await sdk.futoBackupsImportRecoveryKey(
{
importRecoveryKeyRequest: {
recoveryKey: '0'.repeat(64),
},
},
maintenanceRequestOpts,
);
const {
backend: { id: backendId },
} = await sdk.futoBackupsCreateLocalBackend(
{
createLocalBackendRequestDto: {
path: '/local-backend',
},
},
maintenanceRequestOpts,
);
const { repositories } = await sdk.futoBackupsInspectRepositories({}, maintenanceRequestOpts);
const { id: repositoryId, snapshots } = repositories[0];
const { id: snapshotId } = (snapshots ?? [])[0];
socket = io(baseUrl, {
path: '/api/futo-backups/socket.io',
transports: ['websocket'],
extraHeaders: {
cookie,
},
});
const event = waitForMessage('JobUpdate', ({ job }) => finishedJobStatuses.has(job?.status));
await sdk.futoBackupsRestoreFromPoint(
{
id: repositoryId,
snapshot: snapshotId,
backend: backendId,
repositorySnapshotRestoreFromPointRequestDto: {
yuccaConfig: '/data/yucca',
include: ['/data'],
},
},
maintenanceRequestOpts,
);
await event;
socket.disconnect();
const { status: restoreStatus } = await request(app).post('/admin/maintenance').set('Cookie', cookie).send({
action: 'restore_database',
restoreBackupFilename: filename,
});
expect(restoreStatus).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');
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();
await expect(sdk.futoBackupsGetSchedules(requestOpts)).resolves.toEqual({
schedules: expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })]),
});
});
});
});
@@ -95,6 +95,7 @@ test.describe('Database Backups', () => {
await page.waitForURL('/maintenance**'); await page.waitForURL('/maintenance**');
} }
await page.getByRole('button', { name: 'Database Backup' }).click();
await page.getByRole('button', { name: 'Next' }).click(); await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Restore', exact: true }).click(); await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click(); await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
@@ -0,0 +1,173 @@
import * as sdk from '@immich/sdk';
import { LoginResponseDto, StorageFolder, getServerFeatures } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { io, type Socket } from 'socket.io-client';
import { asBearerAuth, baseUrl, utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe('FUTO Backups', () => {
let admin: LoginResponseDto;
let socket: Socket;
const finishedJobStatuses = new Set(['complete', 'warn', 'failed', 'cancelled', 'incomplete']);
const waitForJobEnd = () =>
new Promise<void>((resolve) => {
const listener = (msg: string) => {
try {
const payload = JSON.parse(msg);
if (payload.type === 'JobUpdate' && finishedJobStatuses.has(payload.job?.status)) {
socket.offAny(listener);
resolve();
}
} catch {
// no-op
}
};
socket.onAny(listener);
});
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
const headers = asBearerAuth(admin.accessToken);
await sdk.futoBackupsResetOrchestrator({ headers });
await sdk.futoBackupsImportRecoveryKey({ importRecoveryKeyRequest: { recoveryKey: '0'.repeat(64) } }, { headers });
await sdk.futoBackupsConfirmRecoveryKey({ headers });
await sdk.futoBackupsEnableTelemetry({ headers });
await utils.mkFolder('/local-backend');
socket = io(baseUrl, {
path: '/api/futo-backups/socket.io',
transports: ['websocket'],
extraHeaders: headers,
forceNew: true,
});
await new Promise<void>((resolve) => socket.on('connect', () => resolve()));
});
test.afterAll(async () => {
socket?.close();
});
test('the backups link opens the upsell before backups are configured', async ({ context, page }) => {
const headers = asBearerAuth(admin.accessToken);
await utils.setAuthCookies(context, admin.accessToken);
await expect(getServerFeatures({ headers })).resolves.toMatchObject({ backups: false });
await page.goto('/link?target=backups');
await page.waitForURL('/admin/backups');
await expect(page.getByRole('heading', { name: 'FUTO Backups', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Get Started' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Set up Backups' })).toBeVisible();
await expect(getServerFeatures({ headers })).resolves.toMatchObject({ backups: true });
});
test('the backups link opens the dashboard once backups are configured', async ({ context, page }) => {
test.setTimeout(60_000);
const headers = asBearerAuth(admin.accessToken);
await utils.setAuthCookies(context, admin.accessToken);
await sdk.futoBackupsCreateLocalBackend({ createLocalBackendRequestDto: { path: '/local-backend' } }, { headers });
await sdk.futoBackupsConfigureImmichIntegration(
{
configureImmichIntegrationRequestDto: {
name: 'Immich',
worm: false,
cron: '0 3 * * *',
backupConfiguration: true,
dataFolders: [StorageFolder.Backups, StorageFolder.Upload],
libraries: ['all'],
},
},
{ headers },
);
const { repositories } = await sdk.futoBackupsGetRepositories({ headers });
const backupEnd = waitForJobEnd();
await sdk.futoBackupsCreateBackup({ id: repositories[0].id }, { headers });
await backupEnd;
await page.goto('/link?target=backups');
await page.waitForURL('/admin/backups');
await expect(page.getByText('Your library backup')).toBeVisible();
await expect(page.getByText('Last backup successful')).toBeVisible();
});
test('manually triggers a backup and waits for completion', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/backups');
await page.getByRole('button', { name: 'Back up now', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: 'Backing up your library' })).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'Backup complete' })).toBeVisible({ timeout: 45_000 });
});
test('resets immich and restores from the local FUTO Backups backup', async ({ context, page }) => {
test.setTimeout(120_000);
await utils.setAuthCookies(context, admin.accessToken);
await utils.resetBackups(admin.accessToken);
await utils.createBackup(admin.accessToken);
await sdk.futoBackupsResetOrchestrator({ headers: asBearerAuth(admin.accessToken) });
await utils.resetDatabase();
await page.goto('/');
await page.getByRole('button', { name: 'Restore from backup' }).click();
try {
await page.waitForURL('/maintenance**');
} catch {
await page.goto('/maintenance');
await page.waitForURL('/maintenance**');
}
await page.getByRole('button', { name: 'FUTO Backups' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.filter({ hasText: 'Telemetry required for closed beta' })).toBeVisible();
await dialog.getByRole('button', { name: 'Continue' }).click();
await expect(dialog.filter({ hasText: 'Import recovery key' })).toBeVisible();
await dialog.getByLabel('Recovery Key').fill('0'.repeat(64));
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog.filter({ hasText: 'Where would you like to restore from?' })).toBeVisible();
await dialog.getByText('Local Storage').click();
await expect(dialog.filter({ hasText: 'Create local backend' })).toBeVisible();
await dialog.getByLabel('Path').fill('/local-backend');
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog.filter({ hasText: 'Select Backup' })).toBeVisible();
await dialog
.getByRole('button', { name: /Last backup/ })
.first()
.click();
await expect(dialog.filter({ hasText: /Restore from/ })).toBeVisible();
await dialog.getByRole('button', { name: 'Restore' }).first().click();
await expect(dialog.filter({ hasText: 'Confirm restore from snapshot' })).toBeVisible();
await dialog.getByRole('button', { name: 'Restore', exact: true }).click();
await expect(dialog.filter({ hasText: 'Restoring' })).toBeVisible();
await expect(dialog.filter({ hasText: 'Restoring' })).toBeHidden({ timeout: 60_000 });
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await page.waitForURL('/photos', { timeout: 90_000 });
});
});
@@ -98,6 +98,7 @@ describe('/server', () => {
configFile: false, configFile: false,
duplicateDetection: false, duplicateDetection: false,
facialRecognition: false, facialRecognition: false,
backups: false,
map: true, map: true,
reverseGeocoding: true, reverseGeocoding: true,
importFaces: false, importFaces: false,
+1
View File
@@ -143,6 +143,7 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
smartSearch: false, smartSearch: false,
facialRecognition: false, facialRecognition: false,
duplicateDetection: false, duplicateDetection: false,
backups: false,
map: true, map: true,
reverseGeocoding: true, reverseGeocoding: true,
importFaces: false, importFaces: false,
+3
View File
@@ -31,6 +31,7 @@ import {
createUserAdmin, createUserAdmin,
deleteAssets, deleteAssets,
deleteDatabaseBackup, deleteDatabaseBackup,
deleteLibrary,
getAssetInfo, getAssetInfo,
getConfig, getConfig,
getConfigDefaults, getConfigDefaults,
@@ -466,6 +467,8 @@ export const utils = {
updateLibrary: (accessToken: string, id: string, dto: UpdateLibraryDto) => updateLibrary: (accessToken: string, id: string, dto: UpdateLibraryDto) =>
updateLibrary({ id, updateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }), updateLibrary({ id, updateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
deleteLibrary: (accessToken: string, id: string) => deleteLibrary({ id }, { headers: asBearerAuth(accessToken) }),
createPartner: (accessToken: string, id: string) => createPartner: (accessToken: string, id: string) =>
createPartner({ partnerCreateDto: { sharedWithId: id } }, { headers: asBearerAuth(accessToken) }), createPartner({ partnerCreateDto: { sharedWithId: id } }, { headers: asBearerAuth(accessToken) }),
+18
View File
@@ -62,6 +62,12 @@
"backup_onboarding_title": "Backups", "backup_onboarding_title": "Backups",
"backup_settings": "Database Dump Settings", "backup_settings": "Database Dump Settings",
"backup_settings_description": "Manage database dump settings.", "backup_settings_description": "Manage database dump settings.",
"backups": "Backups",
"backups_attempts": "Backup attempts",
"backups_hide_reminder": "Hide backups reminder",
"backups_hide_reminder_description": "If you already manage backups outside of Immich, you can hide the backup setup reminder.",
"backups_settings": "Backup settings",
"backups_snapshots": "Snapshots",
"cleared_jobs": "Cleared jobs for: {job}", "cleared_jobs": "Cleared jobs for: {job}",
"config_set_by_file": "Config is currently set by a config file", "config_set_by_file": "Config is currently set by a config file",
"confirm_delete_library": "Are you sure you want to delete {library} library?", "confirm_delete_library": "Are you sure you want to delete {library} library?",
@@ -1023,6 +1029,7 @@
"failed_to_keep_this_delete_others": "Failed to keep this asset and delete the other assets", "failed_to_keep_this_delete_others": "Failed to keep this asset and delete the other assets",
"failed_to_load_asset": "Failed to load asset", "failed_to_load_asset": "Failed to load asset",
"failed_to_load_assets": "Failed to load assets", "failed_to_load_assets": "Failed to load assets",
"failed_to_load_database_backups": "Failed to load database backups",
"failed_to_load_notifications": "Failed to load notifications", "failed_to_load_notifications": "Failed to load notifications",
"failed_to_load_people": "Failed to load people", "failed_to_load_people": "Failed to load people",
"failed_to_remove_product_key": "Failed to remove product key", "failed_to_remove_product_key": "Failed to remove product key",
@@ -1401,10 +1408,12 @@
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!", "main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
"main_menu": "Main menu", "main_menu": "Main menu",
"maintenance_action_restore": "Restoring Database", "maintenance_action_restore": "Restoring Database",
"maintenance_action_rollback": "Restoring from Backup",
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.", "maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
"maintenance_end": "End maintenance mode", "maintenance_end": "End maintenance mode",
"maintenance_logged_in_as": "Currently logged in as {user}", "maintenance_logged_in_as": "Currently logged in as {user}",
"maintenance_restore_from_backup": "Restore From Backup", "maintenance_restore_from_backup": "Restore From Backup",
"maintenance_restore_latest_backup_description": "We'll restore your database from the most recent backup. You can also pick a different one.",
"maintenance_restore_library": "Restore Your Library", "maintenance_restore_library": "Restore Your Library",
"maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!", "maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
"maintenance_restore_library_description": "Restoring Database", "maintenance_restore_library_description": "Restoring Database",
@@ -1417,6 +1426,13 @@
"maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings", "maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
"maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files", "maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
"maintenance_restore_library_loading": "Loading integrity checks and heuristics…", "maintenance_restore_library_loading": "Loading integrity checks and heuristics…",
"maintenance_restore_loading_backups": "Loading backups…",
"maintenance_restore_no_backups": "There are no database backups.",
"maintenance_restore_select_another": "Select another backup",
"maintenance_restore_source_database_backup": "Database Backup",
"maintenance_restore_source_futo_backups": "FUTO Backups",
"maintenance_restore_source_title": "Where would you like to restore from?",
"maintenance_restore_upload_backup": "Upload a backup",
"maintenance_task_backup": "Creating a backup of the existing database…", "maintenance_task_backup": "Creating a backup of the existing database…",
"maintenance_task_migrations": "Running database migrations…", "maintenance_task_migrations": "Running database migrations…",
"maintenance_task_restore": "Restoring the chosen backup…", "maintenance_task_restore": "Restoring the chosen backup…",
@@ -1789,6 +1805,8 @@
"purchase_panel_title": "Support the project", "purchase_panel_title": "Support the project",
"purchase_per_server": "Per server", "purchase_per_server": "Per server",
"purchase_per_user": "Per user", "purchase_per_user": "Per user",
"purchase_product_key_description": "Product keys support Immich development and give you a supporter badge.",
"purchase_product_key_excludes_backups": "Product keys do not include FUTO Backups.",
"purchase_remove_product_key": "Remove Product Key", "purchase_remove_product_key": "Remove Product Key",
"purchase_remove_product_key_prompt": "Are you sure you want to remove the product key?", "purchase_remove_product_key_prompt": "Are you sure you want to remove the product key?",
"purchase_remove_server_product_key": "Remove Server product key", "purchase_remove_server_product_key": "Remove Server product key",
+39
View File
@@ -297,6 +297,45 @@ url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-win32-x64.zi
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173351" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173351"
provenance = "github-attestations" provenance = "github-attestations"
[[tools.restic]]
version = "0.19.0"
backend = "aqua:restic/restic"
[tools.restic."platforms.linux-arm64"]
checksum = "sha256:e522ce6bf748d753fee8093e8ec59359972cf5b6bc65fc7c7cf38ae952351d91"
url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_linux_arm64.bz2"
url_api = "https://api.github.com/repos/restic/restic/releases/assets/442897888"
[tools.restic."platforms.linux-arm64-musl"]
checksum = "sha256:e522ce6bf748d753fee8093e8ec59359972cf5b6bc65fc7c7cf38ae952351d91"
url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_linux_arm64.bz2"
url_api = "https://api.github.com/repos/restic/restic/releases/assets/442897888"
[tools.restic."platforms.linux-x64"]
checksum = "sha256:13176fe6d89d4357947a2cd107218ab2873a5f9d8e1ac2d4cd1c8e07e6839c21"
url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_linux_amd64.bz2"
url_api = "https://api.github.com/repos/restic/restic/releases/assets/442897993"
[tools.restic."platforms.linux-x64-musl"]
checksum = "sha256:13176fe6d89d4357947a2cd107218ab2873a5f9d8e1ac2d4cd1c8e07e6839c21"
url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_linux_amd64.bz2"
url_api = "https://api.github.com/repos/restic/restic/releases/assets/442897993"
[tools.restic."platforms.macos-arm64"]
checksum = "sha256:1475397bf759ef4be16a77b19dec650bdbfec00d2cacd82005553411cdd37997"
url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_darwin_arm64.bz2"
url_api = "https://api.github.com/repos/restic/restic/releases/assets/442898395"
[tools.restic."platforms.macos-x64"]
checksum = "sha256:c9d9a71234bc0955fdba6da93cc9375f8793ec1e1cbce77a91014d536a969148"
url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_darwin_amd64.bz2"
url_api = "https://api.github.com/repos/restic/restic/releases/assets/442898462"
[tools.restic."platforms.windows-x64"]
checksum = "sha256:6fa4219a70b1b5d1c429bb106a7f97f3d2a5aab74494db2e490b625edc486d8f"
url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_windows_amd64.zip"
url_api = "https://api.github.com/repos/restic/restic/releases/assets/442897166"
[[tools.terragrunt]] [[tools.terragrunt]]
version = "1.2.0" version = "1.2.0"
backend = "aqua:gruntwork-io/terragrunt" backend = "aqua:gruntwork-io/terragrunt"
+1
View File
@@ -24,6 +24,7 @@ opentofu = "1.13.2"
"github:extism/cli" = "1.6.3" "github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124" "github:webassembly/binaryen" = "version_124"
"github:extism/js-pdk" = "v1.7.0" "github:extism/js-pdk" = "v1.7.0"
restic = "0.19.0"
java = "21.0.2" java = "21.0.2"
[tools."github:jellyfin/jellyfin-ffmpeg"] [tools."github:jellyfin/jellyfin-ffmpeg"]
+1 -1
View File
@@ -50,7 +50,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'LoginResponseDto': {'isOnboarded': false}, 'LoginResponseDto': {'isOnboarded': false},
'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false}, 'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false},
'SyncAssetV1': {'isEdited': false}, 'SyncAssetV1': {'isEdited': false},
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false}, 'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false, 'backups': false},
'SearchAssetResponseDto': {'nextCursor': null}, 'SearchAssetResponseDto': {'nextCursor': null},
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false}, 'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
'PeopleResponse': {'updateStrategy': 'everyone'}, 'PeopleResponse': {'updateStrategy': 'everyone'},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+595 -12
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,7 @@ allowBuilds:
'@scarf/scarf': false '@scarf/scarf': false
'@swc/core': false '@swc/core': false
bcrypt: true bcrypt: true
better-sqlite3: true
canvas: false canvas: false
core-js: false core-js: false
cpu-features: false cpu-features: false
@@ -75,3 +76,4 @@ minimumReleaseAgeExclude:
- '@immich/ui' - '@immich/ui'
- '@immich/sql-tools@0.6.3 || 0.6.6' - '@immich/sql-tools@0.6.3 || 0.6.6'
- '@nestjs/cli@12.0.1' - '@nestjs/cli@12.0.1'
- '@futo-org/*'
+3 -1
View File
@@ -78,7 +78,6 @@ ENV MISE_DATA_DIR=/buildcache/mise
ENV MISE_DISABLE_TOOLS=flutter ENV MISE_DISABLE_TOOLS=flutter
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install --locked mise install --locked
COPY ./packages/plugin-core ./packages/plugin-core/ COPY ./packages/plugin-core ./packages/plugin-core/
# Build plugins # Build plugins
@@ -119,6 +118,9 @@ ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF}
ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT} ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT}
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT} ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT}
COPY --from=ghcr.io/futo-org/backups/restic-proxy:v1.11.2@sha256:a88252d884d22a55749f45dd39a2458c6391ca3e4d3b0d9e0c2246786cf923be /usr/local/bin/restic-proxy /usr/local/bin/restic-proxy
COPY --from=ghcr.io/futo-org/restic:sha-a24cc22@sha256:7b5cdba9cc8690ecc528e239e6f8b2cfb9ef3644895cafd0e0fa9003bcded1f6 /usr/bin/restic /usr/local/bin/restic
VOLUME /data VOLUME /data
EXPOSE 2283 EXPOSE 2283
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"] ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
+1
View File
@@ -3,6 +3,7 @@ FROM ghcr.io/immich-app/base-server-dev:202610291416@sha256:5f4f9f77059e1cd01829
COPY --from=ghcr.io/jdx/mise:2026.10.20@sha256:a3023a390ce9d79343741039d35a36aa41e4fe8198464bf8cc9ffd23ae081a24 /usr/local/bin/mise /usr/local/bin/mise COPY --from=ghcr.io/jdx/mise:2026.10.20@sha256:a3023a390ce9d79343741039d35a36aa41e4fe8198464bf8cc9ffd23ae081a24 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/immich-app/futo-backups-restic-proxy:commit-6297cf30a3caf4101e1ca74247bb822ab1f56ee0 /usr/local/bin/restic-proxy /usr/local/bin/restic-proxy
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \ RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \ echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
+1
View File
@@ -40,6 +40,7 @@
}, },
"dependencies": { "dependencies": {
"@extism/extism": "2.0.0-rc13", "@extism/extism": "2.0.0-rc13",
"@futo-org/backups-orchestrator-api": "1.11.5",
"@immich/plugin-sdk": "workspace:*", "@immich/plugin-sdk": "workspace:*",
"@immich/sql-tools": "^0.6.6", "@immich/sql-tools": "^0.6.6",
"@nestjs/bullmq": "^12.0.0", "@nestjs/bullmq": "^12.0.0",
+67 -6
View File
@@ -1,21 +1,26 @@
import { OrchestrationApiModule, controllers as futoBackupsControllers } from '@futo-org/backups-orchestrator-api';
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
import { Inject, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Inject, Module, OnModuleDestroy, OnModuleInit, applyDecorators, forwardRef } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { ApiExtension, ApiTags } from '@nestjs/swagger';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely'; import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel'; import { OpenTelemetryModule } from 'nestjs-otel';
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod'; import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { commandsAndQuestions } from 'src/commands/index.js'; import { commandsAndQuestions } from 'src/commands/index.js';
import { IWorker } from 'src/constants.js'; import { IWorker } from 'src/constants.js';
import { controllers } from 'src/controllers/index.js'; import { controllers } from 'src/controllers/index.js';
import { ImmichWorker } from 'src/enum.js'; import { HistoryBuilder } from 'src/decorators.js';
import { ApiTag, ImmichEnvironment, ImmichWorker } from 'src/enum.js';
import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard.js'; import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard.js';
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository.js'; import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository.js';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository.js'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository.js';
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller.js'; import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller.js';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service.js'; import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service.js';
import { AuthGuard } from 'src/middleware/auth.guard.js'; import { AuthGuard, Authenticated } from 'src/middleware/auth.guard.js';
import { ErrorInterceptor } from 'src/middleware/error.interceptor.js'; import { ErrorInterceptor } from 'src/middleware/error.interceptor.js';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor.js'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor.js';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter.js'; import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter.js';
@@ -39,6 +44,7 @@ import { services } from 'src/services/index.js';
import { QueueService } from 'src/services/queue.service.js'; import { QueueService } from 'src/services/queue.service.js';
import { getKyselyConfig } from 'src/utils/database.js'; import { getKyselyConfig } from 'src/utils/database.js';
import { configureUserAgent } from 'src/utils/fetch.js'; import { configureUserAgent } from 'src/utils/fetch.js';
import { detectMediaLocation, getBackupsStatePath } from 'src/utils/storage.js';
const common = [...repositories, ...services, GlobalExceptionFilter]; const common = [...repositories, ...services, GlobalExceptionFilter];
@@ -53,7 +59,21 @@ const commonMiddleware = [
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }]; const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
const configRepository = new ConfigRepository(); const configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv(); const { bull, cls, database, environment, otel, storage } = configRepository.getEnv();
const isFutoBackupsDevelopmentMode = environment !== ImmichEnvironment.Production;
const futoBackupsStatePath = getBackupsStatePath(storage.mediaLocation);
const futoBackupsCachePath = join(detectMediaLocation(storage.mediaLocation, existsSync), 'restic-cache');
const futoBackupsHistory = new HistoryBuilder().added('v3.4.0').internal('v3.4.0').getExtensions();
for (const controller of futoBackupsControllers) {
applyDecorators(
Authenticated({ admin: true }),
ApiTags(ApiTag.Backups),
...Object.entries(futoBackupsHistory).map(([key, value]) => ApiExtension(key, value)),
)(controller);
}
const commonImports = [ const commonImports = [
ClsModule.forRoot(cls.config), ClsModule.forRoot(cls.config),
@@ -103,14 +123,54 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
} }
@Module({ @Module({
imports: [...bullImports, ...commonImports, ScheduleModule.forRoot()], imports: [
...bullImports,
...commonImports,
ScheduleModule.forRoot(),
OrchestrationApiModule.forRootAsync({
imports: [forwardRef(() => ApiModule)],
inject: [AuthService],
useFactory: (authService: AuthService) => ({
statePath: futoBackupsStatePath,
cachePath: futoBackupsCachePath,
requireWsAuth: true,
requireLock: true,
developmentMode: isFutoBackupsDevelopmentMode,
authenticate: (client) =>
authService.authenticate({
headers: client.request.headers,
queryParams: {},
metadata: { adminRoute: true, sharedLinkRoute: false, uri: '/api/futo-backups/socket.io' },
}),
}),
}),
],
controllers: [...controllers], controllers: [...controllers],
providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }], providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }],
exports: [AuthService, WebsocketRepository],
}) })
export class ApiModule extends BaseModule {} export class ApiModule extends BaseModule {}
@Module({ @Module({
imports: [...commonImports], imports: [
...commonImports,
OrchestrationApiModule.forRootAsync({
imports: [forwardRef(() => MaintenanceModule)],
inject: [MaintenanceWorkerService],
useFactory: (maintenanceWorkerService: MaintenanceWorkerService) => ({
statePath: futoBackupsStatePath,
cachePath: futoBackupsCachePath,
externalBaseUrl: 'https://my.immich.app',
requireWsAuth: true,
requireLock: true,
developmentMode: isFutoBackupsDevelopmentMode,
authenticate: async (client) => {
await maintenanceWorkerService.authenticate(client.request.headers);
return { user: { isAdmin: true } };
},
}),
}),
],
controllers: [MaintenanceWorkerController], controllers: [MaintenanceWorkerController],
providers: [ providers: [
ConfigRepository, ConfigRepository,
@@ -129,6 +189,7 @@ export class ApiModule extends BaseModule {}
{ provide: APP_GUARD, useClass: MaintenanceAuthGuard }, { provide: APP_GUARD, useClass: MaintenanceAuthGuard },
{ provide: IWorker, useValue: ImmichWorker.Maintenance }, { provide: IWorker, useValue: ImmichWorker.Maintenance },
], ],
exports: [MaintenanceWorkerService, MaintenanceWebsocketRepository],
}) })
export class MaintenanceModule { export class MaintenanceModule {
constructor( constructor(
+1
View File
@@ -153,6 +153,7 @@ export const endpointTags: Record<ApiTag, string> = {
[ApiTag.AssetFiles]: 'An asset file is a file associated with an asset, including edited versions, thumbnails, etc.', [ApiTag.AssetFiles]: 'An asset file is a file associated with an asset, including edited versions, thumbnails, etc.',
[ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.', [ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.',
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.', [ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
[ApiTag.Backups]: 'FUTO Backups.',
[ApiTag.ClusterGroups]: [ApiTag.ClusterGroups]:
'A cluster group is a set of users whose faces are clustered together, so that a person can be shared between them.', 'A cluster group is a set of users whose faces are clustered together, so that a person can be shared between them.',
[ApiTag.ConfigUser]: 'The system configuration properties that are visible to logged in users.', [ApiTag.ConfigUser]: 'The system configuration properties that are visible to logged in users.',
+1 -1
View File
@@ -92,7 +92,7 @@ const getRoutes = () => {
id: `${method} ${path}`, id: `${method} ${path}`,
label: `${Controller.name}.${name} (${method} /${path})`, label: `${Controller.name}.${name} (${method} /${path})`,
path, path,
auth: getAuthenticatedOptions(reflector, handler), auth: getAuthenticatedOptions(reflector, [handler]),
}; };
}); });
}); });
+2
View File
@@ -163,6 +163,7 @@ const AdminConfigSchemaWithVisibility = z
.object({ .object({
backup: z backup: z
.object({ .object({
beta: configBool.default(false).optional().describe('Whether the backups feature is enabled'),
database: z database: z
.object({ .object({
enabled: configBool.describe('Enabled'), enabled: configBool.describe('Enabled'),
@@ -584,6 +585,7 @@ export function mapPublicConfig(config: SystemConfig): PublicConfigDto {
export const defaults = Object.freeze<SystemConfig>({ export const defaults = Object.freeze<SystemConfig>({
backup: { backup: {
beta: false,
database: { database: {
enabled: true, enabled: true,
cronExpression: CronExpression.EVERY_DAY_AT_2AM, cronExpression: CronExpression.EVERY_DAY_AT_2AM,
+3
View File
@@ -6,6 +6,8 @@ const SetMaintenanceModeSchema = z
.object({ .object({
action: MaintenanceActionSchema, action: MaintenanceActionSchema,
restoreBackupFilename: z.string().optional().describe('Restore backup filename'), restoreBackupFilename: z.string().optional().describe('Restore backup filename'),
rollbackRepositoryId: z.string().optional().describe('Rollback repository ID'),
rollbackSnapshotId: z.string().optional().describe('Rollback snapshot ID'),
}) })
.refine( .refine(
(data) => data.action !== MaintenanceAction.RestoreDatabase || (data.restoreBackupFilename?.length ?? 0) > 0, (data) => data.action !== MaintenanceAction.RestoreDatabase || (data.restoreBackupFilename?.length ?? 0) > 0,
@@ -32,6 +34,7 @@ const MaintenanceStatusResponseSchema = z
progress: z.int().optional(), progress: z.int().optional(),
task: z.string().optional(), task: z.string().optional(),
error: z.string().optional(), error: z.string().optional(),
futoBackupsJobId: z.string().optional().describe('FUTO Backups job ID'),
}) })
.meta({ id: 'MaintenanceStatusResponseDto' }); .meta({ id: 'MaintenanceStatusResponseDto' });
+1
View File
@@ -142,6 +142,7 @@ const ServerFeaturesSchema = z
configFile: z.boolean().describe('Whether config file is available'), configFile: z.boolean().describe('Whether config file is available'),
facialRecognition: z.boolean().describe('Whether facial recognition is enabled'), facialRecognition: z.boolean().describe('Whether facial recognition is enabled'),
map: z.boolean().describe('Whether map feature is enabled'), map: z.boolean().describe('Whether map feature is enabled'),
backups: z.boolean().describe('Whether the backups feature is enabled'),
trash: z.boolean().describe('Whether trash feature is enabled'), trash: z.boolean().describe('Whether trash feature is enabled'),
reverseGeocoding: z.boolean().describe('Whether reverse geocoding is enabled'), reverseGeocoding: z.boolean().describe('Whether reverse geocoding is enabled'),
importFaces: z.boolean().describe('Whether face import is enabled'), importFaces: z.boolean().describe('Whether face import is enabled'),
+3
View File
@@ -1031,6 +1031,7 @@ export enum DatabaseLock {
VersionCheck = 800, VersionCheck = 800,
HlsSessionCleanup = 850, HlsSessionCleanup = 850,
FacialRecognition = 900, FacialRecognition = 900,
FutoBackupsModuleConfig = 926,
DuplicateDetection = 1000, DuplicateDetection = 1000,
} }
@@ -1039,6 +1040,7 @@ export enum MaintenanceAction {
End = 'end', End = 'end',
SelectDatabaseRestore = 'select_database_restore', SelectDatabaseRestore = 'select_database_restore',
RestoreDatabase = 'restore_database', RestoreDatabase = 'restore_database',
Rollback = 'rollback',
} }
export const MaintenanceActionSchema = z export const MaintenanceActionSchema = z
@@ -1263,6 +1265,7 @@ export enum ApiTag {
AuthenticationAdmin = 'Authentication (admin)', AuthenticationAdmin = 'Authentication (admin)',
Assets = 'Assets', Assets = 'Assets',
AssetFiles = 'Asset files', AssetFiles = 'Asset files',
Backups = 'Backups',
ConfigUser = 'Config (user)', ConfigUser = 'Config (user)',
ConfigAdmin = 'Config (admin)', ConfigAdmin = 'Config (admin)',
ConfigPublic = 'Config (public)', ConfigPublic = 'Config (public)',
@@ -1,4 +1,5 @@
import { CanActivate, ExecutionContext, Injectable, SetMetadata, applyDecorators } from '@nestjs/common'; import { controllers as futoBackupsControllers } from '@futo-org/backups-orchestrator-api';
import { CanActivate, ExecutionContext, Injectable, SetMetadata, Type, applyDecorators } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { Request } from 'express'; import { Request } from 'express';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto.js'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto.js';
@@ -26,16 +27,19 @@ export class MaintenanceAuthGuard implements CanActivate {
} }
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
const targets = [context.getHandler()]; const targets = [context.getHandler()];
const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>( const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>(
MetadataKey.AuthRoute, MetadataKey.AuthRoute,
targets, targets,
); );
if (!options) {
const isFutoBackupsRoute = (futoBackupsControllers as Type[]).includes(context.getClass());
if (!options && !isFutoBackupsRoute) {
return true; return true;
} }
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
request.auth = await this.service.authenticate(request.headers); request.auth = await this.service.authenticate(request.headers);
return true; return true;
@@ -0,0 +1,110 @@
import { ChildProcess, fork } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository.js';
vi.mock('node:child_process', () => ({ fork: vi.fn() }));
const createWorker = () => {
const worker = Object.assign(new EventEmitter(), { kill: vi.fn() });
worker.kill.mockImplementation((signal: NodeJS.Signals) => {
worker.emit('exit', null, signal);
return true;
});
return worker;
};
const refused = () => Promise.reject(new TypeError('fetch failed'));
const respond = (ok: boolean) => Promise.resolve({ ok } as Response);
describe(MaintenanceHealthRepository.name, () => {
let sut: MaintenanceHealthRepository;
let worker: ReturnType<typeof createWorker>;
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
beforeEach(() => {
vi.useFakeTimers();
sut = new MaintenanceHealthRepository();
worker = createWorker();
vi.mocked(fork).mockReturnValue(worker as unknown as ChildProcess);
fetchMock = vi.fn<typeof fetch>();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe('checkApiHealth', () => {
it('should start the api worker on the health check address', async () => {
fetchMock.mockImplementation(() => respond(true));
const promise = sut.checkApiHealth();
await vi.advanceTimersByTimeAsync(1000);
await promise;
expect(fork).toHaveBeenCalledWith(
expect.stringMatching(/workers\/api\.js$/),
[],
expect.objectContaining({
env: expect.objectContaining({ IMMICH_HOST: '127.0.0.1', IMMICH_PORT: '33001' }),
}),
);
});
it('should resolve once the api worker answers the ping endpoint', async () => {
fetchMock
.mockImplementationOnce(refused)
.mockImplementationOnce(refused)
.mockImplementation(() => respond(true));
const promise = sut.checkApiHealth();
await vi.advanceTimersByTimeAsync(3000);
await expect(promise).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:33001/api/server/ping', expect.anything());
expect(worker.kill).toHaveBeenCalledWith('SIGTERM');
});
it('should keep polling while the ping endpoint returns an error', async () => {
fetchMock.mockImplementationOnce(() => respond(false)).mockImplementation(() => respond(true));
const promise = sut.checkApiHealth();
await vi.advanceTimersByTimeAsync(2000);
await expect(promise).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('should reject when the api worker exits before answering', async () => {
fetchMock.mockImplementation(refused);
const promise = sut.checkApiHealth();
worker.emit('exit', 1, null);
await expect(promise).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Server health check failed, server exited with 1]`,
);
await vi.advanceTimersByTimeAsync(5000);
expect(fetchMock).not.toHaveBeenCalled();
});
it('should reject and stop the api worker when it does not answer in time', async () => {
fetchMock.mockImplementation(refused);
const promise = sut.checkApiHealth();
const assertion = expect(promise).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Server health check failed, took too long to start.]`,
);
await vi.advanceTimersByTimeAsync(180_000);
await assertion;
expect(worker.kill).toHaveBeenCalledWith('SIGTERM');
const calls = fetchMock.mock.calls.length;
await vi.advanceTimersByTimeAsync(5000);
expect(fetchMock).toHaveBeenCalledTimes(calls);
});
});
});
@@ -1,7 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { fork } from 'node:child_process'; import { fork } from 'node:child_process';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { IMMICH_SERVER_START } from 'src/constants.js';
const HEALTH_CHECK_HOST = '127.0.0.1';
const HEALTH_CHECK_PORT = '33001';
const HEALTH_CHECK_TIMEOUT = 180_000;
const PING_INTERVAL = 1000;
@Injectable() @Injectable()
export class MaintenanceHealthRepository { export class MaintenanceHealthRepository {
@@ -14,42 +18,54 @@ export class MaintenanceHealthRepository {
execArgv: process.execArgv.filter((arg) => !arg.startsWith('--inspect')), execArgv: process.execArgv.filter((arg) => !arg.startsWith('--inspect')),
env: { env: {
...process.env, ...process.env,
IMMICH_HOST: '127.0.0.1', IMMICH_HOST: HEALTH_CHECK_HOST,
IMMICH_PORT: '33001', IMMICH_PORT: HEALTH_CHECK_PORT,
}, },
stdio: ['ignore', 'pipe', 'ignore', 'ipc'], stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
}); });
let output = ''; let settled = false;
let pingTimer: NodeJS.Timeout | undefined;
worker.stdout?.on('data', (data) => { function settle(error?: Error) {
if (worker.exitCode !== null) { if (settled) {
return; return;
} }
output += data; settled = true;
clearTimeout(pingTimer);
if (!output.includes(IMMICH_SERVER_START)) { clearTimeout(deadline);
return;
}
resolve();
worker.kill('SIGTERM'); worker.kill('SIGTERM');
});
if (error) {
reject(error);
} else {
resolve();
}
}
async function ping() {
const response = await fetch(`http://${HEALTH_CHECK_HOST}:${HEALTH_CHECK_PORT}/api/server/ping`, {
signal: AbortSignal.timeout(PING_INTERVAL * 5),
}).catch(() => null);
if (response?.ok) {
settle();
} else if (!settled) {
pingTimer = setTimeout(() => void ping(), PING_INTERVAL);
}
}
worker.on('exit', (code, signal) => worker.on('exit', (code, signal) =>
reject(new Error(`Server health check failed, server exited with ${signal ?? code}`)), settle(new Error(`Server health check failed, server exited with ${signal ?? code}`)),
); );
worker.on('error', (error) => reject(new Error(`Server health check failed, process threw: ${error}`))); worker.on('error', (error) => settle(new Error(`Server health check failed, process threw: ${error}`)));
setTimeout(() => { const deadline = setTimeout(
if (worker.exitCode !== null) { () => settle(new Error('Server health check failed, took too long to start.')),
return; HEALTH_CHECK_TIMEOUT,
} );
pingTimer = setTimeout(() => void ping(), PING_INTERVAL);
reject(new Error('Server health check failed, took too long to start.'));
worker.kill('SIGTERM');
}, 180_000);
}); });
} }
} }
@@ -1,6 +1,6 @@
import { UnauthorizedException } from '@nestjs/common'; import { UnauthorizedException } from '@nestjs/common';
import { SignJWT } from 'jose'; import { SignJWT } from 'jose';
import { MaintenanceAction, SystemMetadataKey } from 'src/enum.js'; import { DatabaseLock, MaintenanceAction, SystemMetadataKey } from 'src/enum.js';
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository.js'; import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository.js';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository.js'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository.js';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service.js'; import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service.js';
@@ -10,12 +10,14 @@ import { AutoMocked, ServiceMocks, automock, getMocks } from 'test/utils.js';
describe(MaintenanceWorkerService.name, () => { describe(MaintenanceWorkerService.name, () => {
let sut: MaintenanceWorkerService; let sut: MaintenanceWorkerService;
let mocks: ServiceMocks; let mocks: ServiceMocks;
let moduleRefMock: { get: ReturnType<typeof vitest.fn> };
let maintenanceWebsocketRepositoryMock: AutoMocked<MaintenanceWebsocketRepository>; let maintenanceWebsocketRepositoryMock: AutoMocked<MaintenanceWebsocketRepository>;
let maintenanceHealthRepositoryMock: AutoMocked<MaintenanceHealthRepository>; let maintenanceHealthRepositoryMock: AutoMocked<MaintenanceHealthRepository>;
let databaseBackupServiceMock: AutoMocked<DatabaseBackupService>; let databaseBackupServiceMock: AutoMocked<DatabaseBackupService>;
beforeEach(() => { beforeEach(() => {
mocks = getMocks(); mocks = getMocks();
moduleRefMock = { get: vitest.fn() };
maintenanceWebsocketRepositoryMock = automock(MaintenanceWebsocketRepository, { maintenanceWebsocketRepositoryMock = automock(MaintenanceWebsocketRepository, {
args: [mocks.logger], args: [mocks.logger],
strict: false, strict: false,
@@ -50,6 +52,7 @@ describe(MaintenanceWorkerService.name, () => {
mocks.process, mocks.process,
mocks.database as never, mocks.database as never,
databaseBackupServiceMock, databaseBackupServiceMock,
moduleRefMock as never,
); );
sut.mock({ sut.mock({
@@ -58,6 +61,33 @@ describe(MaintenanceWorkerService.name, () => {
}); });
}); });
describe('init', () => {
beforeEach(() => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
});
it('takes the backups SDK lock so its jobs run during maintenance', async () => {
const futoBackups = { acquireLock: vitest.fn() };
moduleRefMock.get.mockReturnValue(futoBackups);
mocks.database.tryLock.mockResolvedValue(true);
await sut.init();
expect(mocks.database.tryLock).toHaveBeenCalledWith(DatabaseLock.FutoBackupsModuleConfig);
expect(futoBackups.acquireLock).toHaveBeenCalled();
});
it('leaves the backups SDK lock to whoever already holds it', async () => {
const futoBackups = { acquireLock: vitest.fn() };
moduleRefMock.get.mockReturnValue(futoBackups);
mocks.database.tryLock.mockResolvedValue(false);
await sut.init();
expect(futoBackups.acquireLock).not.toHaveBeenCalled();
});
});
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
@@ -1,9 +1,12 @@
import { YuccaService } from '@futo-org/backups-orchestrator-api';
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { parseCookie } from 'cookie'; import { parseCookie } from 'cookie';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { jwtVerify } from 'jose'; import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http'; import { IncomingHttpHeaders } from 'node:http';
import { basename } from 'node:path';
import type { MaintenanceModeState } from 'src/types.js'; import type { MaintenanceModeState } from 'src/types.js';
import { serverVersion } from 'src/constants.js'; import { serverVersion } from 'src/constants.js';
import { StorageCore } from 'src/cores/storage.core.js'; import { StorageCore } from 'src/cores/storage.core.js';
@@ -32,6 +35,7 @@ import { type VersionService as _VersionService } from 'src/services/version.ser
import { getConfig } from 'src/utils/config.js'; import { getConfig } from 'src/utils/config.js';
import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance.js'; import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance.js';
import { getExternalDomain } from 'src/utils/misc.js'; import { getExternalDomain } from 'src/utils/misc.js';
import { detectMediaLocation } from 'src/utils/storage.js';
/** This service is available inside of maintenance mode to manage maintenance mode */ /** This service is available inside of maintenance mode to manage maintenance mode */
@Injectable() @Injectable()
@@ -53,6 +57,7 @@ export class MaintenanceWorkerService {
private processRepository: ProcessRepository, private processRepository: ProcessRepository,
private databaseRepository: DatabaseRepository, private databaseRepository: DatabaseRepository,
private databaseBackupService: DatabaseBackupService, private databaseBackupService: DatabaseBackupService,
private moduleRef: ModuleRef,
) { ) {
this.logger.setContext(this.constructor.name); this.logger.setContext(this.constructor.name);
} }
@@ -80,6 +85,10 @@ export class MaintenanceWorkerService {
await this.logSecret(); await this.logSecret();
if (await this.databaseRepository.tryLock(DatabaseLock.FutoBackupsModuleConfig)) {
this.moduleRef.get(YuccaService, { strict: false }).acquireLock();
}
if (state.action) { if (state.action) {
void this.runAction(state.action); void this.runAction(state.action);
} }
@@ -146,28 +155,9 @@ export class MaintenanceWorkerService {
}; };
} }
/** {@link _StorageService.detectMediaLocation} */
detectMediaLocation(): string { detectMediaLocation(): string {
const envData = this.configRepository.getEnv(); const envData = this.configRepository.getEnv();
if (envData.storage.mediaLocation) { return detectMediaLocation(envData.storage.mediaLocation, (path) => this.storageRepository.existsSync(path));
return envData.storage.mediaLocation;
}
const targets: string[] = [];
const candidates = ['/data', '/usr/src/app/upload'];
for (const candidate of candidates) {
const isExists = this.storageRepository.existsSync(candidate);
if (isExists) {
targets.push(candidate);
}
}
if (targets.length === 1) {
return targets[0];
}
return '/usr/src/app/upload';
} }
private get secret() { private get secret() {
@@ -277,6 +267,9 @@ export class MaintenanceWorkerService {
case MaintenanceAction.RestoreDatabase: { case MaintenanceAction.RestoreDatabase: {
return this.runRestoreDatabase(action); return this.runRestoreDatabase(action);
} }
case MaintenanceAction.Rollback: {
return this.runRollback(action);
}
} }
} }
@@ -335,6 +328,67 @@ export class MaintenanceWorkerService {
}); });
} }
private async runRollback(action: SetMaintenanceModeDto) {
const isLock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
if (!isLock) {
return;
}
this.logger.log(`Running maintenance action ${action.action}`);
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret: this.secret,
action: {
action: MaintenanceAction.Start,
},
});
try {
if (!action.rollbackRepositoryId || !action.rollbackSnapshotId) {
throw new Error("Expected rollbackRepositoryId and rollbackSnapshotId but they're missing!");
}
await this.rollback(action.rollbackRepositoryId, action.rollbackSnapshotId);
} catch (error) {
this.logger.error(`Encountered error running action: ${error}`);
this.setStatus({
active: true,
action: action.action,
task: 'error',
error: String(error),
futoBackupsJobId: this.#status.futoBackupsJobId,
});
}
}
private async rollback(repositoryId: string, snapshotId: string): Promise<void> {
this.setStatus({
active: true,
action: MaintenanceAction.Rollback,
});
const futoBackups = this.moduleRef.get(YuccaService, { strict: false });
const { jobId, task, immichBackupFileName } = await futoBackups.restoreSnapshotInplace(repositoryId, snapshotId);
this.setStatus({
active: true,
action: MaintenanceAction.Rollback,
futoBackupsJobId: jobId,
});
await task;
if (!immichBackupFileName) {
return this.setAction({
action: MaintenanceAction.SelectDatabaseRestore,
});
}
const backupFileName = basename(immichBackupFileName);
await this.restoreBackup(backupFileName);
}
private async endMaintenance(): Promise<void> { private async endMaintenance(): Promise<void> {
const state: MaintenanceModeState = { isMaintenanceMode: false as const }; const state: MaintenanceModeState = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state); await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
+21 -1
View File
@@ -16,9 +16,15 @@ class TestController {
undecoratedRoute() {} undecoratedRoute() {}
} }
const contextFor = (handler: () => void) => @Authenticated({ admin: true })
class AdminTestController {
route() {}
}
const contextFor = (handler: () => void, controller: object = TestController) =>
({ ({
getHandler: () => handler, getHandler: () => handler,
getClass: () => controller,
switchToHttp: () => ({ getRequest: () => ({ headers: {}, query: {}, path: '/' }) }), switchToHttp: () => ({ getRequest: () => ({ headers: {}, query: {}, path: '/' }) }),
}) as unknown as ExecutionContext; }) as unknown as ExecutionContext;
@@ -79,4 +85,18 @@ describe(AuthGuard.name, () => {
expect(authenticate).not.toHaveBeenCalled(); expect(authenticate).not.toHaveBeenCalled();
}); });
}); });
describe('class-level @Authenticated()', () => {
it('should apply the controller options to its undecorated routes', async () => {
const authenticate = vitest.spyOn(authService, 'authenticate').mockResolvedValue({} as never);
await expect(sut.canActivate(contextFor(AdminTestController.prototype.route, AdminTestController))).resolves.toBe(
true,
);
expect(authenticate).toHaveBeenCalledWith(
expect.objectContaining({ metadata: expect.objectContaining({ adminRoute: true }) }),
);
});
});
}); });
+4 -4
View File
@@ -26,12 +26,12 @@ export type AuthenticatedOptions = AuthorizedRoute | PublicRoute;
type ReflectorTarget = Parameters<Reflector['get']>[1]; type ReflectorTarget = Parameters<Reflector['get']>[1];
/** Resolves the `@Authenticated()` options of a route handler, with the defaults applied. */ /** Resolves the `@Authenticated()` options of a route handler, with the defaults applied. */
export const getAuthenticatedOptions = (reflector: Reflector, target: ReflectorTarget) => { export const getAuthenticatedOptions = (reflector: Reflector, targets: ReflectorTarget[]) => {
const options = reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, [target]); const options = reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, targets);
return options && { sharedLink: false, admin: false, public: false, setup: false, ...options }; return options && { sharedLink: false, admin: false, public: false, setup: false, ...options };
}; };
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => { export const Authenticated = (options: AuthenticatedOptions = {}): ReturnType<typeof applyDecorators> => {
const decorators: MethodDecorator[] = [SetMetadata(MetadataKey.AuthRoute, options)]; const decorators: MethodDecorator[] = [SetMetadata(MetadataKey.AuthRoute, options)];
if (!options.public) { if (!options.public) {
@@ -97,7 +97,7 @@ export class AuthGuard implements CanActivate {
} }
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const options = getAuthenticatedOptions(this.reflector, context.getHandler()); const options = getAuthenticatedOptions(this.reflector, [context.getHandler(), context.getClass()]);
if (!options) { if (!options) {
throw new Error(`Route ${context.getHandler().name} does not declare @Authenticated()`); throw new Error(`Route ${context.getHandler().name} does not declare @Authenticated()`);
} }
+5 -1
View File
@@ -15,7 +15,11 @@ export class WebSocketAdapter extends IoAdapter {
const server = super.createIOServer(port, options); const server = super.createIOServer(port, options);
const pubClient = new Redis(redis); const pubClient = new Redis(redis);
const subClient = pubClient.duplicate(); const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient)); const isImmichSocket = !options?.path || options.path === '/api/socket.io';
const adapterOpts = {
key: isImmichSocket ? undefined : `socket.io` + options.path,
};
server.adapter(createAdapter(pubClient, subClient, adapterOpts));
return server; return server;
} }
} }
@@ -71,6 +71,10 @@ type EventMap = {
/** job finishes with error */ /** job finishes with error */
JobError: [JobErrorEvent]; JobError: [JobErrorEvent];
LibraryCreate: [];
LibraryUpdate: [];
LibraryDelete: [];
// queue events // queue events
QueueStart: [QueueStartEvent]; QueueStart: [QueueStartEvent];
@@ -19,6 +19,9 @@ import { handlePromiseError } from 'src/utils/misc.js';
export const serverEvents = [ export const serverEvents = [
'ConfigUpdate', 'ConfigUpdate',
'AppRestart', 'AppRestart',
'LibraryCreate',
'LibraryUpdate',
'LibraryDelete',
'HlsSegmentRequest', 'HlsSegmentRequest',
'HlsSegmentResult', 'HlsSegmentResult',
'HlsHeartbeat', 'HlsHeartbeat',
@@ -217,6 +217,12 @@ describe(DatabaseBackupService.name, () => {
`); `);
}); });
it('should abort the backup and remove the temporary file when the signal is aborted', async () => {
await expect(sut.createDatabaseBackup('', AbortSignal.abort())).rejects.toThrow('The operation was aborted');
expect(mocks.storage.unlink).toHaveBeenCalled();
expect(mocks.storage.rename).not.toHaveBeenCalled();
});
it('should run a database backup successfully', async () => { it('should run a database backup successfully', async () => {
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.Success); expect(result).toBe(JobStatus.Success);
@@ -224,7 +224,7 @@ export class DatabaseBackupService {
}; };
} }
async createDatabaseBackup(filenamePrefix: string = ''): Promise<string> { async createDatabaseBackup(filenamePrefix: string = '', signal?: AbortSignal): Promise<string> {
this.logger.debug(`Database Backup Started`); this.logger.debug(`Database Backup Started`);
const { bin, args, databasePassword, databaseVersion, databaseMajorVersion } = const { bin, args, databasePassword, databaseVersion, databaseMajorVersion } =
@@ -252,7 +252,7 @@ export class DatabaseBackupService {
gzip = this.processRepository.spawnDuplexStream(compression.command, compression.args); gzip = this.processRepository.spawnDuplexStream(compression.command, compression.args);
const fileStream = this.storageRepository.createWriteStream(temporaryFilePath); const fileStream = this.storageRepository.createWriteStream(temporaryFilePath);
await pipeline(pgdump, gzip, fileStream); await pipeline(pgdump, gzip, fileStream, { signal });
await this.storageRepository.rename(temporaryFilePath, backupFilePath); await this.storageRepository.rename(temporaryFilePath, backupFilePath);
} catch (error) { } catch (error) {
this.logger.error(`Database Backup Failure: ${error}`); this.logger.error(`Database Backup Failure: ${error}`);
+102
View File
@@ -0,0 +1,102 @@
import { type ImmichDatabaseDumpConfig, YuccaService as OrchestratorService } from '@futo-org/backups-orchestrator-api';
import { Injectable, Optional } from '@nestjs/common';
import type { SystemConfig } from 'src/dtos/config.dto.js';
import type { ArgOf } from 'src/repositories/event.repository.js';
import { StorageCore } from 'src/cores/storage.core.js';
import { OnEvent } from 'src/decorators.js';
import { DatabaseLock, ImmichWorker, MaintenanceAction, StorageFolder } from 'src/enum.js';
import { DatabaseRepository } from 'src/repositories/database.repository.js';
import { LibraryRepository } from 'src/repositories/library.repository.js';
import { DatabaseBackupService } from 'src/services/database-backup.service.js';
import { MaintenanceService } from 'src/services/maintenance.service.js';
import { SystemConfigService } from 'src/services/system-config.service.js';
import { getExternalDomain } from 'src/utils/misc.js';
@Injectable()
export class FutoBackupsService {
constructor(
private readonly databaseRepository: DatabaseRepository,
private readonly libraryRepository: LibraryRepository,
private readonly databaseBackupService: DatabaseBackupService,
private readonly systemConfigService: SystemConfigService,
@Optional() private readonly maintenanceService: MaintenanceService,
@Optional() private readonly orchestratorService: OrchestratorService,
) {}
private updateSystemConfig({ server }: SystemConfig) {
this.orchestratorService.setExternalBaseUrl(getExternalDomain(server));
}
private async updateLibraryConfig() {
const libraries = await this.libraryRepository.getAll();
this.orchestratorService.setImmichIntegration({
dataPath: StorageCore.getMediaLocation(),
dataFolders: Object.values(StorageFolder),
libraries: libraries
.filter((library) => !library.deletedAt)
.map(({ id, name, importPaths, exclusionPatterns }) => ({ id, name, importPaths, exclusionPatterns })),
hooks: {
createDatabaseBackup: (signal) => this.databaseBackupService.createDatabaseBackup('', signal),
cleanupDatabaseBackups: () => this.databaseBackupService.cleanupDatabaseBackups(),
getImmichDatabaseDumpConfig: () => this.getImmichDatabaseDumpConfig(),
configureImmichDatabaseDump: (databaseDump) => this.configureImmichDatabaseDump(databaseDump),
enterMaintenanceRollback: (repositoryId, snapshotId) => this.enterMaintenanceRollback(repositoryId, snapshotId),
},
});
}
private async getImmichDatabaseDumpConfig(): Promise<ImmichDatabaseDumpConfig> {
const { backup } = await this.systemConfigService.getAdminConfig();
return { enabled: backup.database.enabled, keepLastAmount: backup.database.keepLastAmount };
}
private async configureImmichDatabaseDump(databaseDump: Partial<ImmichDatabaseDumpConfig>) {
const config = await this.systemConfigService.getAdminConfig();
await this.systemConfigService.updateAdminConfig({
...config,
backup: { ...config.backup, database: { ...config.backup.database, ...databaseDump } },
});
}
private enterMaintenanceRollback(repositoryId: string, snapshotId: string) {
return this.maintenanceService.startMaintenance(
{
action: MaintenanceAction.Rollback,
rollbackRepositoryId: repositoryId,
rollbackSnapshotId: snapshotId,
},
'futo-backups-rollback',
);
}
@OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.Api] })
async onConfigInit({ newConfig }: ArgOf<'ConfigInit'>) {
this.updateSystemConfig(newConfig);
void this.updateLibraryConfig();
if (await this.databaseRepository.tryLock(DatabaseLock.FutoBackupsModuleConfig)) {
this.orchestratorService.acquireLock();
}
}
@OnEvent({ name: 'ConfigUpdate', workers: [ImmichWorker.Api], server: true })
onConfigUpdate({ newConfig }: ArgOf<'ConfigUpdate'>) {
this.updateSystemConfig(newConfig);
}
@OnEvent({ name: 'LibraryCreate', workers: [ImmichWorker.Api], server: true })
onLibraryCreate() {
void this.updateLibraryConfig();
}
@OnEvent({ name: 'LibraryUpdate', workers: [ImmichWorker.Api], server: true })
onLibraryUpdate() {
void this.updateLibraryConfig();
}
@OnEvent({ name: 'LibraryDelete', workers: [ImmichWorker.Api], server: true })
onLibraryDelete() {
void this.updateLibraryConfig();
}
}
+2
View File
@@ -13,6 +13,7 @@ import { DatabaseBackupService } from 'src/services/database-backup.service.js';
import { DatabaseService } from 'src/services/database.service.js'; import { DatabaseService } from 'src/services/database.service.js';
import { DownloadService } from 'src/services/download.service.js'; import { DownloadService } from 'src/services/download.service.js';
import { DuplicateService } from 'src/services/duplicate.service.js'; import { DuplicateService } from 'src/services/duplicate.service.js';
import { FutoBackupsService } from 'src/services/futo-backups.service.js';
import { HlsService } from 'src/services/hls.service.js'; import { HlsService } from 'src/services/hls.service.js';
import { IntegrityService } from 'src/services/integrity.service.js'; import { IntegrityService } from 'src/services/integrity.service.js';
import { JobService } from 'src/services/job.service.js'; import { JobService } from 'src/services/job.service.js';
@@ -68,6 +69,7 @@ export const services = [
DatabaseService, DatabaseService,
DownloadService, DownloadService,
DuplicateService, DuplicateService,
FutoBackupsService,
IntegrityService, IntegrityService,
HlsService, HlsService,
JobService, JobService,
@@ -619,6 +619,7 @@ describe(LibraryService.name, () => {
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LibraryDelete, data: { id: library.id } }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LibraryDelete, data: { id: library.id } });
expect(mocks.library.softDelete).toHaveBeenCalledWith(library.id); expect(mocks.library.softDelete).toHaveBeenCalledWith(library.id);
expect(mocks.event.emit).toHaveBeenCalledWith('LibraryDelete');
}); });
it('should allow an external library to be deleted', async () => { it('should allow an external library to be deleted', async () => {
@@ -724,6 +725,7 @@ describe(LibraryService.name, () => {
exclusionPatterns: expect.any(Array), exclusionPatterns: expect.any(Array),
}), }),
); );
expect(mocks.event.emit).toHaveBeenCalledWith('LibraryCreate');
}); });
it('should create with name', async () => { it('should create with name', async () => {
@@ -891,6 +893,7 @@ describe(LibraryService.name, () => {
'library-id', 'library-id',
expect.objectContaining({ importPaths: [`${cwd}/foo/bar`] }), expect.objectContaining({ importPaths: [`${cwd}/foo/bar`] }),
); );
expect(mocks.event.emit).toHaveBeenCalledWith('LibraryUpdate');
}); });
}); });
+5
View File
@@ -247,6 +247,8 @@ export class LibraryService extends BaseService {
'**/.stfolder/**', '**/.stfolder/**',
], ],
}); });
await this.eventRepository.emit('LibraryCreate');
return mapLibrary(library); return mapLibrary(library);
} }
@@ -358,6 +360,7 @@ export class LibraryService extends BaseService {
} }
const library = await this.libraryRepository.update(id, dto); const library = await this.libraryRepository.update(id, dto);
await this.eventRepository.emit('LibraryUpdate');
return mapLibrary(library); return mapLibrary(library);
} }
@@ -370,6 +373,8 @@ export class LibraryService extends BaseService {
await this.libraryRepository.softDelete(id); await this.libraryRepository.softDelete(id);
await this.jobRepository.queue({ name: JobName.LibraryDelete, data: { id } }); await this.jobRepository.queue({ name: JobName.LibraryDelete, data: { id } });
await this.eventRepository.emit('LibraryDelete');
} }
@OnJob({ name: JobName.LibraryDelete, queue: QueueName.Library }) @OnJob({ name: JobName.LibraryDelete, queue: QueueName.Library })
@@ -70,6 +70,27 @@ describe(NotificationService.name, () => {
}); });
}); });
describe('onLibraryCreate', () => {
it('should relay the event to other servers', () => {
sut.onLibraryCreate();
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('LibraryCreate');
});
});
describe('onLibraryUpdate', () => {
it('should relay the event to other servers', () => {
sut.onLibraryUpdate();
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('LibraryUpdate');
});
});
describe('onLibraryDelete', () => {
it('should relay the event to other servers', () => {
sut.onLibraryDelete();
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('LibraryDelete');
});
});
describe('onConfigValidateEvent', () => { describe('onConfigValidateEvent', () => {
it('validates smtp config when enabling smtp', async () => { it('validates smtp config when enabling smtp', async () => {
const oldConfig = configs.smtpDisabled; const oldConfig = configs.smtpDisabled;
@@ -123,6 +123,21 @@ export class NotificationService extends BaseService {
this.websocketRepository.serverSend('AppRestart', state); this.websocketRepository.serverSend('AppRestart', state);
} }
@OnEvent({ name: 'LibraryCreate' })
onLibraryCreate() {
this.websocketRepository.serverSend('LibraryCreate');
}
@OnEvent({ name: 'LibraryUpdate' })
onLibraryUpdate() {
this.websocketRepository.serverSend('LibraryUpdate');
}
@OnEvent({ name: 'LibraryDelete' })
onLibraryDelete() {
this.websocketRepository.serverSend('LibraryDelete');
}
@OnEvent({ name: 'ConfigValidate', priority: -100 }) @OnEvent({ name: 'ConfigValidate', priority: -100 })
async onConfigValidate({ oldConfig, newConfig }: ArgOf<'ConfigValidate'>) { async onConfigValidate({ oldConfig, newConfig }: ArgOf<'ConfigValidate'>) {
try { try {
@@ -138,6 +138,7 @@ describe(ServerService.name, () => {
duplicateDetection: true, duplicateDetection: true,
facialRecognition: true, facialRecognition: true,
importFaces: false, importFaces: false,
backups: false,
map: true, map: true,
reverseGeocoding: true, reverseGeocoding: true,
oauth: false, oauth: false,
+13 -2
View File
@@ -86,8 +86,18 @@ export class ServerService extends BaseService {
} }
async getFeatures(): Promise<ServerFeaturesDto> { async getFeatures(): Promise<ServerFeaturesDto> {
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications, ffmpeg } = const {
await this.getConfig({ withCache: false }); reverseGeocoding,
metadata,
map,
backup,
machineLearning,
trash,
oauth,
passwordLogin,
notifications,
ffmpeg,
} = await this.getConfig({ withCache: false });
const { configFile } = this.configRepository.getEnv(); const { configFile } = this.configRepository.getEnv();
return { return {
@@ -95,6 +105,7 @@ export class ServerService extends BaseService {
facialRecognition: isFacialRecognitionEnabled(machineLearning), facialRecognition: isFacialRecognitionEnabled(machineLearning),
duplicateDetection: isDuplicateDetectionEnabled(machineLearning), duplicateDetection: isDuplicateDetectionEnabled(machineLearning),
map: map.enabled, map: map.enabled,
backups: !!backup.beta,
reverseGeocoding: reverseGeocoding.enabled, reverseGeocoding: reverseGeocoding.enabled,
importFaces: metadata.faces.import, importFaces: metadata.faces.import,
sidecar: true, sidecar: true,
+2 -19
View File
@@ -15,6 +15,7 @@ import {
} from 'src/enum.js'; } from 'src/enum.js';
import { BaseService } from 'src/services/base.service.js'; import { BaseService } from 'src/services/base.service.js';
import { ImmichStartupError } from 'src/utils/misc.js'; import { ImmichStartupError } from 'src/utils/misc.js';
import { detectMediaLocation } from 'src/utils/storage.js';
const docsMessage = `Please see https://docs.immich.app/administration/system-integrity#folder-checks for more information.`; const docsMessage = `Please see https://docs.immich.app/administration/system-integrity#folder-checks for more information.`;
@@ -22,25 +23,7 @@ const docsMessage = `Please see https://docs.immich.app/administration/system-in
export class StorageService extends BaseService { export class StorageService extends BaseService {
private detectMediaLocation(): string { private detectMediaLocation(): string {
const envData = this.configRepository.getEnv(); const envData = this.configRepository.getEnv();
if (envData.storage.mediaLocation) { return detectMediaLocation(envData.storage.mediaLocation, (path) => this.storageRepository.existsSync(path));
return envData.storage.mediaLocation;
}
const targets: string[] = [];
const candidates = ['/data', '/usr/src/app/upload'];
for (const candidate of candidates) {
const isExists = this.storageRepository.existsSync(candidate);
if (isExists) {
targets.push(candidate);
}
}
if (targets.length === 1) {
return targets[0];
}
return '/usr/src/app/upload';
} }
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.StorageService }) @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.StorageService })
@@ -47,6 +47,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
[QueueName.Editor]: { concurrency: 2 }, [QueueName.Editor]: { concurrency: 2 },
}, },
backup: { backup: {
beta: false,
database: { database: {
enabled: true, enabled: true,
cronExpression: '0 02 * * *', cronExpression: '0 02 * * *',
+49
View File
@@ -0,0 +1,49 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
const MEDIA_LOCATION_CANDIDATES = ['/data', '/usr/src/app/upload'];
const MEDIA_LOCATION_FALLBACK = '/usr/src/app/upload';
export const detectMediaLocation = (mediaLocation: string | undefined, exists: (path: string) => boolean): string => {
if (mediaLocation) {
return mediaLocation;
}
const targets: string[] = [];
for (const candidate of MEDIA_LOCATION_CANDIDATES) {
if (exists(candidate)) {
targets.push(candidate);
}
}
if (targets.length === 1) {
return targets[0];
}
return MEDIA_LOCATION_FALLBACK;
};
export const getBackupsStatePath = (mediaLocation: string | undefined): string => {
if (mediaLocation) {
return join(mediaLocation, 'backups', 'yucca');
}
const candidates = MEDIA_LOCATION_CANDIDATES.filter((candidate) => existsSync(candidate));
let warning: string | undefined;
if (candidates.length === 0) {
warning = `None of ${MEDIA_LOCATION_CANDIDATES.join(', ')} exist and media location is not otherwise set`;
} else if (candidates.length > 1) {
warning = `Multiple paths (${candidates.join(', ')}) exist at the same time`;
}
const statePath = join(candidates.length === 1 ? candidates[0] : MEDIA_LOCATION_FALLBACK, 'backups', 'yucca');
if (warning) {
console.warn(
`${warning}, your backup configuration and encryption keys will be read from ${statePath}, which may not be where they were originally written.`,
);
}
return statePath;
};
+4
View File
@@ -13,6 +13,10 @@ async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { const app = await NestFactory.create<NestExpressApplication>(ApiModule, {
bufferLogs: true, bufferLogs: true,
// default module id algorithm can cause
// the ApiModule to be instatiated more than
// once when injected into OrchestrationApiModule
moduleIdGeneratorAlgorithm: 'deep-hash',
routeConflictPolicy: { duplicate: 'error' }, routeConflictPolicy: { duplicate: 'error' },
routeResolutionStrategy: 'specificity', routeResolutionStrategy: 'specificity',
}); });
+2
View File
@@ -12,6 +12,8 @@ async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, {
bufferLogs: true, bufferLogs: true,
// see comment in api.ts
moduleIdGeneratorAlgorithm: 'deep-hash',
routeConflictPolicy: { duplicate: 'error' }, routeConflictPolicy: { duplicate: 'error' },
routeResolutionStrategy: 'specificity', routeResolutionStrategy: 'specificity',
}); });
@@ -16,6 +16,7 @@ import { JobRepository } from 'src/repositories/job.repository.js';
import { LibraryRepository } from 'src/repositories/library.repository.js'; import { LibraryRepository } from 'src/repositories/library.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { StorageRepository } from 'src/repositories/storage.repository.js'; import { StorageRepository } from 'src/repositories/storage.repository.js';
import { WebsocketRepository } from 'src/repositories/websocket.repository.js';
import { DB } from 'src/schema/index.js'; import { DB } from 'src/schema/index.js';
import { LibraryService } from 'src/services/library.service.js'; import { LibraryService } from 'src/services/library.service.js';
import { systemConfigStub } from 'test/fixtures/system-config.stub.js'; import { systemConfigStub } from 'test/fixtures/system-config.stub.js';
@@ -49,7 +50,14 @@ class LibraryTestContext extends MediumTestContext<typeof LibraryService> {
super(LibraryService, { super(LibraryService, {
database, database,
real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository], real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository],
mock: [CronRepository, DatabaseRepository, EventRepository, JobRepository, LoggingRepository], mock: [
CronRepository,
DatabaseRepository,
EventRepository,
JobRepository,
LoggingRepository,
WebsocketRepository,
],
}); });
const jobs = this.getMock(JobRepository); const jobs = this.getMock(JobRepository);
@@ -57,6 +65,7 @@ class LibraryTestContext extends MediumTestContext<typeof LibraryService> {
jobs.queueAll.mockResolvedValue(); jobs.queueAll.mockResolvedValue();
this.getMock(EventRepository).emit.mockResolvedValue(); this.getMock(EventRepository).emit.mockResolvedValue();
this.getMock(WebsocketRepository).serverSend.mockReturnValue();
} }
async createLibrary(options: { importPaths?: string[]; exclusionPatterns?: string[] } = {}) { async createLibrary(options: { importPaths?: string[]; exclusionPatterns?: string[] } = {}) {
+1
View File
@@ -33,6 +33,7 @@
}, },
"dependencies": { "dependencies": {
"@formatjs/icu-messageformat-parser": "^3.0.0", "@formatjs/icu-messageformat-parser": "^3.0.0",
"@futo-org/backups-orchestrator-ui": "1.11.5",
"@immich/justified-layout-wasm": "^0.4.3", "@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*", "@immich/sdk": "workspace:*",
"@immich/ui": "^0.91.0", "@immich/ui": "^0.91.0",
@@ -0,0 +1,9 @@
<script lang="ts">
import { hideBackupsReminder } from '#lib/stores/preferences.store.ts';
import { Field, Switch } from '@immich/ui';
import { t } from 'svelte-i18n';
</script>
<Field label={$t('admin.backups_hide_reminder')} description={$t('admin.backups_hide_reminder_description')}>
<Switch bind:checked={$hideBackupsReminder} />
</Field>
@@ -2,9 +2,11 @@
import BreadcrumbActionPage from '#lib/components/BreadcrumbActionPage.svelte'; import BreadcrumbActionPage from '#lib/components/BreadcrumbActionPage.svelte';
import NavigationBar from '#lib/components/shared-components/navigation-bar/NavigationBar.svelte'; import NavigationBar from '#lib/components/shared-components/navigation-bar/NavigationBar.svelte';
import BottomInfo from '#lib/components/shared-components/side-bar/BottomInfo.svelte'; import BottomInfo from '#lib/components/shared-components/side-bar/BottomInfo.svelte';
import { featureFlagsManager } from '#lib/managers/feature-flags-manager.svelte.ts';
import { Route } from '#lib/route.ts'; import { Route } from '#lib/route.ts';
import { sidebarStore } from '#lib/stores/sidebar.svelte.ts'; import { sidebarStore } from '#lib/stores/sidebar.svelte.ts';
import type { HeaderButtonActionItem } from '#lib/types.ts'; import type { HeaderButtonActionItem } from '#lib/types.ts';
import { ImmichBackupsAdminNavButton } from '@futo-org/backups-orchestrator-ui';
import { AppShell, AppShellHeader, AppShellSidebar, MenuItemType, NavbarItem, type BreadcrumbItem } from '@immich/ui'; import { AppShell, AppShellHeader, AppShellSidebar, MenuItemType, NavbarItem, type BreadcrumbItem } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull, mdiWrench } from '@mdi/js'; import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull, mdiWrench } from '@mdi/js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -28,6 +30,10 @@
class="flex h-full flex-col justify-between gap-2 border-none shadow-none" class="flex h-full flex-col justify-between gap-2 border-none shadow-none"
> >
<div class="flex flex-col gap-1 pe-4 pt-8"> <div class="flex flex-col gap-1 pe-4 pt-8">
{#if featureFlagsManager.value.backups}
<ImmichBackupsAdminNavButton href={Route.backups()} />
{/if}
<NavbarItem title={$t('users')} href={Route.users()} icon={mdiAccountMultipleOutline} /> <NavbarItem title={$t('users')} href={Route.users()} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('external_libraries')} href={Route.libraries()} icon={mdiBookshelf} /> <NavbarItem title={$t('external_libraries')} href={Route.libraries()} icon={mdiBookshelf} />
<NavbarItem title={$t('admin.queues')} href={Route.queues()} icon={mdiTrayFull} /> <NavbarItem title={$t('admin.queues')} href={Route.queues()} icon={mdiTrayFull} />
@@ -0,0 +1,114 @@
<script lang="ts">
import { BackupFileStatus } from '#lib/constants.ts';
import { getDatabaseBackupDateTime } from '#lib/services/database-backups.service.ts';
import { locale } from '#lib/stores/preferences.store.ts';
import { getBytesWithUnit } from '#lib/utils/byte-units.ts';
import { Card, CardBody, Code, HStack, Icon, Stack, Text } from '@immich/ui';
import { mdiAlertCircle, mdiCheckCircle, mdiDatabaseRefreshOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
filename: string;
filesize: number;
expectedVersion: string;
timezone?: string;
showFullDate?: boolean;
class?: string;
actions?: Snippet;
};
const {
filename,
filesize,
expectedVersion,
timezone,
showFullDate = false,
class: className = '',
actions,
}: Props = $props();
const filesizeText = $derived(getBytesWithUnit(filesize, 1));
const backupDateTime = $derived(getDatabaseBackupDateTime(filename, timezone)?.toLocal());
const dateDisplay = $derived(
backupDateTime?.toLocaleString(showFullDate ? DateTime.DATETIME_MED : DateTime.TIME_SIMPLE),
);
const relativeTime = $derived(backupDateTime?.toRelative({ locale: $locale }));
const version = $derived(filename.match(/-v(.*)-/)?.[1]);
const status = $derived.by(() => {
if (!version) {
return BackupFileStatus.UnknownVersion;
}
if (version !== expectedVersion) {
return BackupFileStatus.DifferentVersion;
}
return BackupFileStatus.OK;
});
</script>
<Card class="dark:bg-dark-900 {className}">
<CardBody class="px-6 pt-3 pb-4">
<Stack gap={3} class="min-w-0 grow">
<div class={actions ? 'flex items-center justify-between gap-3' : ''}>
<HStack gap={2} class="min-w-0">
{#if status === BackupFileStatus.OK}
<Icon icon={mdiCheckCircle} size="18" class="text-success" />
{:else if status === BackupFileStatus.DifferentVersion}
<Icon icon={mdiAlertCircle} size="18" class="text-warning" />
{:else}
<Icon icon={mdiAlertCircle} size="18" class="text-danger" />
{/if}
{#if dateDisplay}
<Text class="font-medium" size="small">{dateDisplay}</Text>
{:else}
<Text class="font-medium" size="small">{$t('unknown_date')}</Text>
{/if}
{#if relativeTime}
<div class="flex items-center gap-2">
<div class="size-1 bg-light-500"></div>
<Text size="tiny" color="muted">{relativeTime}</Text>
</div>
{/if}
</HStack>
{#if actions}
<HStack gap={1}>
{@render actions()}
</HStack>
{/if}
</div>
<HStack>
<Icon icon={mdiDatabaseRefreshOutline} size="16" color="gray" />
<Code size="small" class="break-all">{filename}</Code>
</HStack>
{#if status === BackupFileStatus.UnknownVersion}
<Text size="small" color="danger">
{$t('admin.maintenance_restore_backup_unknown_version')}
</Text>
{:else if status === BackupFileStatus.DifferentVersion}
<Text size="small" color="warning">
{$t('admin.maintenance_restore_backup_different_version')}
</Text>
{/if}
<HStack gap={8}>
<div class="flex gap-1">
<Text size="tiny" color="muted">{$t('version')}:</Text>
<Text size="tiny" fontWeight="medium">{version ? `v${version}` : $t('unknown')}</Text>
</div>
<div class="flex gap-1">
<Text size="tiny" color="muted">{$t('size')}:</Text>
<Text size="tiny" fontWeight="medium">{filesizeText[0]} {filesizeText[1]}</Text>
</div>
</HStack>
</Stack>
</CardBody>
</Card>
@@ -5,13 +5,19 @@ import { DateTime } from 'luxon';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MaintenanceBackupEntry from './MaintenanceBackupEntry.svelte'; import MaintenanceBackupEntry from './MaintenanceBackupEntry.svelte';
vi.mock('#lib/services/database-backups.service.ts', () => ({ vi.mock('#lib/services/database-backups.service.ts', async (importOriginal) => {
getDatabaseBackupActions: () => ({ const original = await importOriginal<typeof import('#lib/services/database-backups.service.ts')>();
Download: { type: 'command', title: 'Download', onAction: vi.fn() },
Delete: { type: 'command', title: 'Delete', onAction: vi.fn() }, return {
}), ...original,
handleRestoreDatabaseBackup: vi.fn(), getDatabaseBackupActions: () => ({
})); Download: { type: 'command', title: 'Download', onAction: vi.fn() },
Delete: { type: 'command', title: 'Delete', onAction: vi.fn() },
}),
handleRestoreDatabaseBackup: vi.fn(),
};
});
describe('MaintenanceBackupEntry', () => { describe('MaintenanceBackupEntry', () => {
beforeEach(() => { beforeEach(() => {
@@ -1,12 +1,8 @@
<script lang="ts"> <script lang="ts">
import MaintenanceBackupCard from '#lib/components/maintenance/MaintenanceBackupCard.svelte';
import OnEvents from '#lib/components/OnEvents.svelte'; import OnEvents from '#lib/components/OnEvents.svelte';
import { BackupFileStatus } from '#lib/constants.ts';
import { getDatabaseBackupActions, handleRestoreDatabaseBackup } from '#lib/services/database-backups.service.ts'; import { getDatabaseBackupActions, handleRestoreDatabaseBackup } from '#lib/services/database-backups.service.ts';
import { locale } from '#lib/stores/preferences.store.ts'; import { Button, ContextMenuButton } from '@immich/ui';
import { getBytesWithUnit } from '#lib/utils/byte-units.ts';
import { Button, Card, CardBody, ContextMenuButton, HStack, Icon, Stack, Text } from '@immich/ui';
import { mdiAlertCircle, mdiCheckCircle, mdiDatabaseRefreshOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
@@ -18,31 +14,6 @@
const { filename, filesize, expectedVersion, timezone }: Props = $props(); const { filename, filesize, expectedVersion, timezone }: Props = $props();
const filesizeText = $derived(getBytesWithUnit(filesize, 1));
const backupDateTime = $derived.by(() => {
const dateMatch = filename.match(/\d+T\d+/);
if (dateMatch) {
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }).toLocal();
}
return null;
});
const timeDisplay = $derived(backupDateTime?.toLocaleString(DateTime.TIME_SIMPLE));
const relativeTime = $derived(backupDateTime?.toRelative({ locale: $locale }));
const version = $derived(filename.match(/-v(.*)-/)?.[1]);
const status = $derived.by(() => {
if (!version) {
return BackupFileStatus.UnknownVersion;
}
if (version !== expectedVersion) {
return BackupFileStatus.DifferentVersion;
}
return BackupFileStatus.OK;
});
const { Download, Delete } = $derived(getDatabaseBackupActions($t, filename)); const { Download, Delete } = $derived(getDatabaseBackupActions($t, filename));
let isDeleting = $state(false); let isDeleting = $state(false);
@@ -56,71 +27,11 @@
<OnEvents {onBackupDeleteStatus} /> <OnEvents {onBackupDeleteStatus} />
<Card class="dark:bg-dark-900"> <MaintenanceBackupCard {filename} {filesize} {expectedVersion} {timezone}>
<CardBody class="px-6 pt-3 pb-4"> {#snippet actions()}
<Stack gap={3} class="min-w-0 grow"> <Button size="small" onclick={() => handleRestoreDatabaseBackup(filename)} disabled={isDeleting}>
<div class="flex items-center justify-between gap-3"> {$t('restore')}
<HStack gap={2} class="min-w-0"> </Button>
{#if status === BackupFileStatus.OK} <ContextMenuButton disabled={isDeleting} position="top-right" aria-label={$t('open')} items={[Download, Delete]} />
<Icon icon={mdiCheckCircle} size="18" class="text-success" /> {/snippet}
{:else if status === BackupFileStatus.DifferentVersion} </MaintenanceBackupCard>
<Icon icon={mdiAlertCircle} size="18" class="text-warning" />
{:else}
<Icon icon={mdiAlertCircle} size="18" class="text-danger" />
{/if}
{#if timeDisplay}
<Text class="font-medium" size="small">{timeDisplay}</Text>
{:else}
<Text class="font-medium" size="small">{$t('unknown_date')}</Text>
{/if}
{#if relativeTime}
<div class="flex items-center gap-2">
<div class="size-1 bg-light-500"></div>
<Text size="tiny" color="muted">{relativeTime}</Text>
</div>
{/if}
</HStack>
<HStack gap={1}>
<Button size="small" onclick={() => handleRestoreDatabaseBackup(filename)} disabled={isDeleting}
>{$t('restore')}</Button
>
<ContextMenuButton
disabled={isDeleting}
position="top-right"
aria-label={$t('open')}
items={[Download, Delete]}
/>
</HStack>
</div>
<HStack>
<Icon icon={mdiDatabaseRefreshOutline} size="16" color="gray" />
<Text size="small" class="font-mono break-all">{filename}</Text>
</HStack>
{#if status === BackupFileStatus.UnknownVersion}
<Text size="small" color="danger">
{$t('admin.maintenance_restore_backup_unknown_version')}
</Text>
{:else if status === BackupFileStatus.DifferentVersion}
<Text size="small" color="warning">
{$t('admin.maintenance_restore_backup_different_version')}
</Text>
{/if}
<HStack gap={8}>
<div class="flex gap-1">
<Text size="tiny" color="muted">{$t('version')}:</Text>
<Text size="tiny" fontWeight="medium">{version ? `v${version}` : $t('unknown')}</Text>
</div>
<div class="flex gap-1">
<Text size="tiny" color="muted">{$t('size')}:</Text>
<Text size="tiny" fontWeight="medium">{filesizeText[0]} {filesizeText[1]}</Text>
</div>
</HStack>
</Stack>
</CardBody>
</Card>
@@ -2,7 +2,7 @@
import HeaderActionButton from '#lib/components/HeaderActionButton.svelte'; import HeaderActionButton from '#lib/components/HeaderActionButton.svelte';
import MaintenanceBackupEntry from '#lib/components/maintenance/MaintenanceBackupEntry.svelte'; import MaintenanceBackupEntry from '#lib/components/maintenance/MaintenanceBackupEntry.svelte';
import OnEvents from '#lib/components/OnEvents.svelte'; import OnEvents from '#lib/components/OnEvents.svelte';
import { handleUploadDatabaseBackup } from '#lib/services/database-backups.service.ts'; import { getDatabaseBackupDateTime, handleUploadDatabaseBackup } from '#lib/services/database-backups.service.ts';
import type { DatabaseBackupDto } from '@immich/sdk'; import type { DatabaseBackupDto } from '@immich/sdk';
import { listDatabaseBackups } from '@immich/sdk'; import { listDatabaseBackups } from '@immich/sdk';
import { Card, CardBody, HStack, Icon, ProgressBar, Stack, Text } from '@immich/ui'; import { Card, CardBody, HStack, Icon, ProgressBar, Stack, Text } from '@immich/ui';
@@ -51,13 +51,12 @@
const unknownDateKey = $t('unknown_date'); const unknownDateKey = $t('unknown_date');
for (const backup of backups) { for (const backup of backups) {
const timezone = backup.timezone; const backupDateTime = getDatabaseBackupDateTime(backup.filename, backup.timezone);
const dateMatch = backup.filename.match(/\d+T\d+/);
let dateKey: string; let dateKey: string;
let dt: DateTime; let dt: DateTime;
if (dateMatch) { if (backupDateTime) {
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }); dt = backupDateTime;
dateKey = dt.toFormat('LLLL d, yyyy'); dateKey = dt.toFormat('LLLL d, yyyy');
} else { } else {
dt = DateTime.fromMillis(0); dt = DateTime.fromMillis(0);
@@ -2,7 +2,7 @@
import { authManager } from '#lib/managers/auth-manager.svelte.ts'; import { authManager } from '#lib/managers/auth-manager.svelte.ts';
import { handleError } from '#lib/utils/handle-error.ts'; import { handleError } from '#lib/utils/handle-error.ts';
import { activateProduct, getActivationKey } from '#lib/utils/license-utils.ts'; import { activateProduct, getActivationKey } from '#lib/utils/license-utils.ts';
import { Button, Heading, LoadingSpinner } from '@immich/ui'; import { Button, Heading, LoadingSpinner, Text } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import UserPurchaseOptionCard from './IndividualPurchaseOptionCard.svelte'; import UserPurchaseOptionCard from './IndividualPurchaseOptionCard.svelte';
import ServerPurchaseOptionCard from './ServerPurchaseOptionCard.svelte'; import ServerPurchaseOptionCard from './ServerPurchaseOptionCard.svelte';
@@ -42,6 +42,9 @@
</Heading> </Heading>
{/if} {/if}
<Text>{$t('purchase_product_key_description')}</Text>
<Text fontWeight="bold">{$t('purchase_product_key_excludes_backups')}</Text>
{#if showMessage} {#if showMessage}
<div class="mt-2"> <div class="mt-2">
<p> <p>
@@ -1,11 +1,20 @@
<script lang="ts"> <script lang="ts">
import { ImmichBackupsSidebarItem } from '@futo-org/backups-orchestrator-ui';
import { authManager } from '#lib/managers/auth-manager.svelte.ts';
import { featureFlagsManager } from '#lib/managers/feature-flags-manager.svelte.ts';
import PurchaseInfo from './PurchaseInfo.svelte'; import PurchaseInfo from './PurchaseInfo.svelte';
import ServerStatus from './ServerStatus.svelte'; import ServerStatus from './ServerStatus.svelte';
import StorageSpace from './StorageSpace.svelte'; import StorageSpace from './StorageSpace.svelte';
import { Route } from '#lib/route.ts';
import { hideBackupsReminder } from '#lib/stores/preferences.store.ts';
</script> </script>
<div class="mt-auto"> <div class="ms-4 mt-auto overflow-hidden rounded-lg">
<StorageSpace /> <StorageSpace />
{#if authManager.user.isAdmin && featureFlagsManager.value.backups && !$hideBackupsReminder}
<ImmichBackupsSidebarItem href={Route.backups()} />
{/if}
</div> </div>
<PurchaseInfo /> <PurchaseInfo />
@@ -25,7 +25,7 @@
</script> </script>
<div <div
class="ms-4 min-w-52 rounded-lg bg-light-100 p-4 text-sm" class="min-w-52 bg-light-100 p-4 text-sm"
title={$t('storage_usage', { title={$t('storage_usage', {
values: { values: {
used: getByteUnitString(usedBytes, $locale, 3), used: getByteUnitString(usedBytes, $locale, 3),
@@ -1,6 +1,7 @@
import { eventManager } from '#lib/managers/event-manager.svelte.ts'; import { eventManager } from '#lib/managers/event-manager.svelte.ts';
import { lang } from '#lib/stores/preferences.store.ts'; import { lang } from '#lib/stores/preferences.store.ts';
import { langs } from '#lib/utils/i18n.ts'; import { convertBCP47, langs } from '#lib/utils/i18n.ts';
import { setYuccaLanguage } from '@futo-org/backups-orchestrator-ui';
class LanguageManager { class LanguageManager {
constructor() { constructor() {
@@ -17,10 +18,12 @@ class LanguageManager {
return; return;
} }
this.initialized = true; this.initialized = true;
lang.subscribe((lang) => this.setLanguage(lang)); lang.subscribe((lang) => void this.setLanguage(lang));
} }
setLanguage(code: string) { async setLanguage(code: string) {
await setYuccaLanguage(convertBCP47(code));
const item = langs.find((item) => item.code === code); const item = langs.find((item) => item.code === code);
if (!item) { if (!item) {
return; return;
+6
View File
@@ -142,6 +142,12 @@ export const Route = {
workflows: () => '/workflows', workflows: () => '/workflows',
viewWorkflow: ({ id }: { id: string }) => `/workflows/${id}`, viewWorkflow: ({ id }: { id: string }) => `/workflows/${id}`,
// backups
backups: () => '/admin/backups',
backupSettings: () => '/admin/backups/settings',
backupAttempts: () => '/admin/backups/attempts',
backupSnapshots: () => '/admin/backups/snapshots',
// queues // queues
queues: () => '/admin/queues', queues: () => '/admin/queues',
viewQueue: ({ name }: { name: QueueName }) => `/admin/queues/${asQueueSlug(name)}`, viewQueue: ({ name }: { name: QueueName }) => `/admin/queues/${asQueueSlug(name)}`,
@@ -6,14 +6,41 @@ import { getFormatter } from '#lib/utils/i18n.ts';
import { import {
deleteDatabaseBackup, deleteDatabaseBackup,
getBaseUrl, getBaseUrl,
listDatabaseBackups,
MaintenanceAction, MaintenanceAction,
setMaintenanceMode, setMaintenanceMode,
type DatabaseBackupDto,
type DatabaseBackupUploadDto, type DatabaseBackupUploadDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui'; import { modalManager, type ActionItem } from '@immich/ui';
import { mdiDownload, mdiTrashCanOutline } from '@mdi/js'; import { mdiDownload, mdiTrashCanOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import type { MessageFormatter } from 'svelte-i18n'; import type { MessageFormatter } from 'svelte-i18n';
export const loadDatabaseBackups = async (): Promise<DatabaseBackupDto[]> => {
const { backups } = await listDatabaseBackups();
return backups;
};
export const getDatabaseBackupDateTime = (filename: string, zone?: string): DateTime | undefined => {
const dateMatch = filename.match(/\d+T\d+/);
if (!dateMatch) {
return;
}
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone });
};
const getBackupTimestamp = (backup: DatabaseBackupDto): number =>
getDatabaseBackupDateTime(backup.filename, backup.timezone)?.toMillis() ?? 0;
export const getLatestBackup = (backups: DatabaseBackupDto[]): DatabaseBackupDto | undefined => {
if (backups.length === 0) {
return undefined;
}
return [...backups].sort((a, b) => getBackupTimestamp(b) - getBackupTimestamp(a))[0];
};
export const getDatabaseBackupActions = ($t: MessageFormatter, filename: string) => { export const getDatabaseBackupActions = ($t: MessageFormatter, filename: string) => {
const Download: ActionItem = { const Download: ActionItem = {
title: $t('download'), title: $t('download'),
+2
View File
@@ -136,3 +136,5 @@ export const autoPlayVideo = persisted<boolean>('auto-play-video', true, {});
export const alwaysLoadOriginalVideo = persisted<boolean>('always-load-original-video', false, {}); export const alwaysLoadOriginalVideo = persisted<boolean>('always-load-original-video', false, {});
export const recentAlbumsDropdown = persisted<boolean>('recent-albums-open', true, {}); export const recentAlbumsDropdown = persisted<boolean>('recent-albums-open', true, {});
export const hideBackupsReminder = persisted<boolean>('hide-backups-reminder', false, {});
+6
View File
@@ -1,9 +1,13 @@
import { languageManager } from '#lib/managers/language-manager.svelte.ts'; import { languageManager } from '#lib/managers/language-manager.svelte.ts';
import { readPreloadedQuery } from '#lib/queries/client.ts'; import { readPreloadedQuery } from '#lib/queries/client.ts';
import { getServerConfigQuery } from '#lib/queries/server-info.query.ts'; import { getServerConfigQuery } from '#lib/queries/server-info.query.ts';
import { lang } from '#lib/stores/preferences.store.ts';
import { init } from '#lib/utils/client.ts'; import { init } from '#lib/utils/client.ts';
import { convertBCP47 } from '#lib/utils/i18n.ts';
import { maintenanceCreateUrl, maintenanceReturnUrl, maintenanceShouldRedirect } from '#lib/utils/maintenance.ts'; import { maintenanceCreateUrl, maintenanceReturnUrl, maintenanceShouldRedirect } from '#lib/utils/maintenance.ts';
import { configureYucca } from '@futo-org/backups-orchestrator-ui';
import { commandPaletteManager } from '@immich/ui'; import { commandPaletteManager } from '@immich/ui';
import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
@@ -11,6 +15,8 @@ export const ssr = false;
export const csr = true; export const csr = true;
export const load = (async ({ fetch, url }) => { export const load = (async ({ fetch, url }) => {
configureYucca({ api: 'orchestrator', language: convertBCP47(get(lang)) });
let error; let error;
try { try {
await init(fetch); await init(fetch);
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ImmichBackupsPage, YuccaContext } from '@futo-org/backups-orchestrator-ui';
import HideBackupsReminder from '#lib/components/backups/HideBackupsReminder.svelte';
import AdminPageLayout from '#lib/components/layouts/AdminPageLayout.svelte';
import { Route } from '#lib/route.ts';
import type { ComponentProps } from 'svelte';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
const questions: ComponentProps<typeof ImmichBackupsPage>['questions'] = [
// {
// title: 'Where can I store my backups?',
// answer: 'TODO',
// },
// {
// title: 'Does my Immich product key include FUTO Backups?',
// answer: 'TODO',
// },
// {
// title: 'Do I need FUTO Backups to use Immich?',
// answer: 'TODO',
// },
// {
// title: 'How is FUTO Cloud priced?',
// answer: 'TODO',
// },
// {
// title: 'What does FUTO Backups protect?',
// answer: 'TODO',
// },
];
</script>
{#snippet hideReminder()}
<HideBackupsReminder />
{/snippet}
<YuccaContext>
<ImmichBackupsPage
questions={[{ title: 'Already back up your library elsewhere?', answer: hideReminder }, ...questions]}
onConfigure={() => goto(Route.backupSettings())}
onViewAttempts={() => goto(Route.backupAttempts())}
onViewSnapshots={() => goto(Route.backupSnapshots())}
>
{#snippet layout({ actions, children })}
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} {actions}>
{@render children()}
</AdminPageLayout>
{/snippet}
</ImmichBackupsPage>
</YuccaContext>
+29
View File
@@ -0,0 +1,29 @@
import { eventManager } from '#lib/managers/event-manager.svelte.ts';
import { authenticate } from '#lib/utils/auth.ts';
import { getFormatter } from '#lib/utils/i18n.ts';
import { getConfig, updateConfig } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
try {
const config = await getConfig();
if (!config.backup.beta) {
const newConfig = await updateConfig({
adminConfigDto: { ...config, backup: { ...config.backup, beta: true } },
});
eventManager.emit('SystemConfigUpdate', newConfig);
}
} catch {
// no-op
}
return {
meta: {
title: $t('admin.backups'),
},
};
}) satisfies PageLoad;
@@ -0,0 +1,20 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ImmichBackupAttemptsPage, YuccaContext } from '@futo-org/backups-orchestrator-ui';
import AdminPageLayout from '#lib/components/layouts/AdminPageLayout.svelte';
import { Route } from '#lib/route.ts';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
</script>
<YuccaContext>
<AdminPageLayout breadcrumbs={[{ title: $t('admin.backups'), href: Route.backups() }, { title: data.meta.title }]}>
<ImmichBackupAttemptsPage onUnconfigured={() => goto(Route.backups())} />
</AdminPageLayout>
</YuccaContext>
@@ -0,0 +1,14 @@
import { authenticate } from '#lib/utils/auth.ts';
import { getFormatter } from '#lib/utils/i18n.ts';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
return {
meta: {
title: $t('admin.backups_attempts'),
},
};
}) satisfies PageLoad;
@@ -0,0 +1,20 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ImmichBackupSettingsPage, YuccaContext } from '@futo-org/backups-orchestrator-ui';
import AdminPageLayout from '#lib/components/layouts/AdminPageLayout.svelte';
import { Route } from '#lib/route.ts';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
</script>
<YuccaContext>
<AdminPageLayout breadcrumbs={[{ title: $t('admin.backups'), href: Route.backups() }, { title: data.meta.title }]}>
<ImmichBackupSettingsPage onUnconfigured={() => goto(Route.backups())} />
</AdminPageLayout>
</YuccaContext>
@@ -0,0 +1,14 @@
import { authenticate } from '#lib/utils/auth.ts';
import { getFormatter } from '#lib/utils/i18n.ts';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
return {
meta: {
title: $t('admin.backups_settings'),
},
};
}) satisfies PageLoad;
@@ -0,0 +1,20 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ImmichSnapshotsPage, YuccaContext } from '@futo-org/backups-orchestrator-ui';
import AdminPageLayout from '#lib/components/layouts/AdminPageLayout.svelte';
import { Route } from '#lib/route.ts';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
</script>
<YuccaContext>
<AdminPageLayout breadcrumbs={[{ title: $t('admin.backups'), href: Route.backups() }, { title: data.meta.title }]}>
<ImmichSnapshotsPage onUnconfigured={() => goto(Route.backups())} />
</AdminPageLayout>
</YuccaContext>
@@ -0,0 +1,14 @@
import { authenticate } from '#lib/utils/auth.ts';
import { getFormatter } from '#lib/utils/i18n.ts';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
return {
meta: {
title: $t('admin.backups_snapshots'),
},
};
}) satisfies PageLoad;
@@ -71,7 +71,7 @@
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
required={true} required={true}
label={$t('admin.backup_keep_last_amount')} label={$t('admin.backup_keep_last_amount')}
disabled={disabled || !configToEdit.backup.database.enabled} {disabled}
bind:value={configToEdit.backup.database.keepLastAmount} bind:value={configToEdit.backup.database.keepLastAmount}
isEdited={configToEdit.backup.database.keepLastAmount !== config.backup.database.keepLastAmount} isEdited={configToEdit.backup.database.keepLastAmount !== config.backup.database.keepLastAmount}
/> />
+5
View File
@@ -8,6 +8,7 @@ enum LinkTarget {
UNSUBSCRIBE = 'unsubscribe', UNSUBSCRIBE = 'unsubscribe',
VIEW_ASSET = 'view_asset', VIEW_ASSET = 'view_asset',
ACTIVATE_LICENSE = 'activate_license', ACTIVATE_LICENSE = 'activate_license',
BACKUPS = 'backups',
} }
export const load = (({ url }) => { export const load = (({ url }) => {
@@ -22,6 +23,10 @@ export const load = (({ url }) => {
return redirect(307, Route.userSettings({ isOpen: OpenQueryParam.NOTIFICATIONS })); return redirect(307, Route.userSettings({ isOpen: OpenQueryParam.NOTIFICATIONS }));
} }
case LinkTarget.BACKUPS: {
return redirect(307, Route.backups());
}
case LinkTarget.VIEW_ASSET: { case LinkTarget.VIEW_ASSET: {
const id = queryParams.get('id'); const id = queryParams.get('id');
if (id) { if (id) {
+78 -51
View File
@@ -7,6 +7,7 @@
import { maintenanceStore } from '#lib/stores/maintenance.store.ts'; import { maintenanceStore } from '#lib/stores/maintenance.store.ts';
import { MaintenanceAction } from '@immich/sdk'; import { MaintenanceAction } from '@immich/sdk';
import { Button, Heading, Link, ProgressBar, Scrollable, Text } from '@immich/ui'; import { Button, Heading, Link, ProgressBar, Scrollable, Text } from '@immich/ui';
import { YuccaContext, ViewStatusModal } from '@futo-org/backups-orchestrator-ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -30,6 +31,11 @@
action: MaintenanceAction.End, action: MaintenanceAction.End,
}); });
const startRestore = () =>
handleSetMaintenanceMode({
action: MaintenanceAction.SelectDatabaseRestore,
});
const error = $derived( const error = $derived(
$status?.error $status?.error
?.split('\n') ?.split('\n')
@@ -38,58 +44,79 @@
); );
</script> </script>
<AuthPageLayout {#snippet errorLog()}
withHeader={$status?.action === MaintenanceAction.Start || $status?.action === MaintenanceAction.End} <Scrollable class="max-h-80">
withBackdrop={$status?.action === MaintenanceAction.Start} <pre class="text-left text-sm"><code>{error}</code></pre>
> </Scrollable>
<div class="flex flex-col place-items-center gap-8 text-center"> <Button onclick={end}>{$t('maintenance_end')}</Button>
{#if $status?.action === MaintenanceAction.RestoreDatabase} {/snippet}
<Heading size="large" color="primary" tag="h1">{$t('maintenance_action_restore')}</Heading>
{#if $status.error} <YuccaContext>
<Scrollable class="max-h-80"> <AuthPageLayout
<pre class="text-left text-sm"><code>{error}</code></pre> withHeader={$status?.action === MaintenanceAction.Start || $status?.action === MaintenanceAction.End}
</Scrollable> withBackdrop={$status?.action === MaintenanceAction.Start}
<Button onclick={end}>{$t('maintenance_end')}</Button> >
<div class="flex flex-col place-items-center gap-8 text-center">
{#if $status?.action === MaintenanceAction.RestoreDatabase}
<Heading size="large" color="primary" tag="h1">{$t('maintenance_action_restore')}</Heading>
{#if $status.error}
{@render errorLog()}
{:else}
<ProgressBar progress={$status.progress || 0} />
{#if $status.task === 'backup'}
<Text>{$t('maintenance_task_backup')}</Text>
{/if}
{#if $status.task === 'restore'}
<Text>{$t('maintenance_task_restore')}</Text>
{/if}
{#if $status.task === 'migrations'}
<Text>{$t('maintenance_task_migrations')}</Text>
{/if}
{#if $status.task === 'rollback'}
<Text>{$t('maintenance_task_rollback')}</Text>
{/if}
{/if}
{:else if $status?.action === MaintenanceAction.Rollback && $status.futoBackupsJobId}
<ViewStatusModal
jobId={$status.futoBackupsJobId}
onClose={() => {
if ($status?.error) {
$status.futoBackupsJobId = '';
}
}}
/>
{:else if $status?.action === MaintenanceAction.Rollback && $status.error}
<Heading size="large" color="primary" tag="h1">{$t('maintenance_action_rollback')}</Heading>
{@render errorLog()}
{:else if $status?.action === MaintenanceAction.SelectDatabaseRestore && $auth}
<MaintenanceRestoreFlow {end} expectedVersion={data.expectedVersion} />
{:else} {:else}
<ProgressBar progress={$status.progress || 0} /> <Heading size="large" color="primary" tag="h1">{$t('maintenance_title')}</Heading>
{#if $status.task === 'backup'}
<Text>{$t('maintenance_task_backup')}</Text>
{/if}
{#if $status.task === 'restore'}
<Text>{$t('maintenance_task_restore')}</Text>
{/if}
{#if $status.task === 'migrations'}
<Text>{$t('maintenance_task_migrations')}</Text>
{/if}
{#if $status.task === 'rollback'}
<Text>{$t('maintenance_task_rollback')}</Text>
{/if}
{/if}
{:else if $status?.action === MaintenanceAction.SelectDatabaseRestore && $auth}
<MaintenanceRestoreFlow {end} expectedVersion={data.expectedVersion} />
{:else}
<Heading size="large" color="primary" tag="h1">{$t('maintenance_title')}</Heading>
<p>
<FormatMessage key="maintenance_description">
{#snippet children({ tag, message })}
{#if tag === 'link'}
<Link href="https://docs.immich.app/administration/maintenance-mode">
{message}
</Link>
{/if}
{/snippet}
</FormatMessage>
</p>
{#if $auth}
<p> <p>
{$t('maintenance_logged_in_as', { <FormatMessage key="maintenance_description">
values: { {#snippet children({ tag, message })}
user: $auth.username, {#if tag === 'link'}
}, <Link href="https://docs.immich.app/administration/maintenance-mode">
})} {message}
</Link>
{/if}
{/snippet}
</FormatMessage>
</p> </p>
<Button onclick={end}>{$t('maintenance_end')}</Button> {#if $auth}
<p>
{$t('maintenance_logged_in_as', {
values: {
user: $auth.username,
},
})}
</p>
<div class="flex gap-2">
<Button color="secondary" onclick={startRestore}>{$t('maintenance_restore_from_backup')}</Button>
<Button onclick={end}>{$t('maintenance_end')}</Button>
</div>
{/if}
{/if} {/if}
{/if} </div>
</div> </AuthPageLayout>
</AuthPageLayout> </YuccaContext>
@@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import RestoreFlowAutoSelectBackup from './RestoreFlowAutoSelectBackup.svelte';
import RestoreFlowDetectInstall from './RestoreFlowDetectInstall.svelte'; import RestoreFlowDetectInstall from './RestoreFlowDetectInstall.svelte';
import RestoreFlowIntro from './RestoreFlowIntro.svelte';
import RestoreFlowSelectBackup from './RestoreFlowSelectBackup.svelte'; import RestoreFlowSelectBackup from './RestoreFlowSelectBackup.svelte';
import { ImmichOnboardingRestoreFlow } from '@futo-org/backups-orchestrator-ui';
type Props = { type Props = {
end: () => void; end: () => void;
@@ -9,11 +12,31 @@
const { end, expectedVersion }: Props = $props(); const { end, expectedVersion }: Props = $props();
let stage = $state(0); let stage: 'overview' | 'futo-backups' | 'detect-install' | 'auto-select-backup' | 'select-backup' = $state(
localStorage.getItem('restoring-futo-backups') ? 'futo-backups' : 'overview',
);
$effect(() => {
if (stage === 'futo-backups') {
localStorage.setItem('restoring-futo-backups', '1');
} else {
localStorage.removeItem('restoring-futo-backups');
}
});
</script> </script>
{#if stage === 0} {#if stage === 'overview'}
<RestoreFlowDetectInstall next={() => stage++} {end} /> <RestoreFlowIntro
{:else} flowToFutoBackups={() => (stage = 'futo-backups')}
<RestoreFlowSelectBackup previous={() => stage--} {end} {expectedVersion} /> flowToDatabase={() => (stage = 'detect-install')}
{end}
/>
{:else if stage === 'futo-backups'}
<ImmichOnboardingRestoreFlow onExit={() => (stage = 'overview')} onFinish={() => (stage = 'auto-select-backup')} />
{:else if stage === 'detect-install'}
<RestoreFlowDetectInstall next={() => (stage = 'select-backup')} previous={() => (stage = 'overview')} />
{:else if stage === 'select-backup'}
<RestoreFlowSelectBackup previous={() => (stage = 'detect-install')} {end} {expectedVersion} />
{:else if stage === 'auto-select-backup'}
<RestoreFlowAutoSelectBackup selectAnother={() => (stage = 'select-backup')} {end} {expectedVersion} />
{/if} {/if}
@@ -0,0 +1,68 @@
<script lang="ts">
import MaintenanceBackupCard from '#lib/components/maintenance/MaintenanceBackupCard.svelte';
import {
getLatestBackup,
handleRestoreDatabaseBackup,
loadDatabaseBackups,
} from '#lib/services/database-backups.service.ts';
import type { DatabaseBackupDto } from '@immich/sdk';
import { Alert, Button, Heading, HStack, Icon, Text } from '@immich/ui';
import { mdiArrowRight, mdiRefresh } from '@mdi/js';
import { handleError } from '#lib/utils/handle-error.ts';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
selectAnother: () => void;
end: () => void;
expectedVersion: string;
};
const { selectAnother, end, expectedVersion }: Props = $props();
let backups: DatabaseBackupDto[] | undefined = $state();
onMount(async () => {
try {
backups = await loadDatabaseBackups();
} catch (error) {
handleError(error, $t('errors.failed_to_load_database_backups'));
backups = [];
}
});
const latest = $derived(backups ? getLatestBackup(backups) : undefined);
</script>
<Heading size="large" color="primary" tag="h1">{$t('maintenance_restore_from_backup')}</Heading>
{#if backups === undefined}
<HStack>
<Icon icon={mdiRefresh} color="rgb(var(--immich-ui-primary))" />
<Text>{$t('maintenance_restore_loading_backups')}</Text>
</HStack>
{:else if latest === undefined}
<Alert color="warning" title={$t('maintenance_restore_no_backups')} />
<HStack>
<Button onclick={end} variant="ghost">{$t('cancel')}</Button>
<Button onclick={selectAnother}>{$t('maintenance_restore_upload_backup')}</Button>
</HStack>
{:else}
<Text>{$t('maintenance_restore_latest_backup_description')}</Text>
<MaintenanceBackupCard
filename={latest.filename}
filesize={latest.filesize}
timezone={latest.timezone}
{expectedVersion}
showFullDate
class="text-left"
/>
<HStack>
<Button onclick={end} variant="ghost">{$t('cancel')}</Button>
<Button onclick={selectAnother} variant="ghost">{$t('maintenance_restore_select_another')}</Button>
<Button onclick={() => handleRestoreDatabaseBackup(latest.filename)} trailingIcon={mdiArrowRight}>
{$t('continue')}
</Button>
</HStack>
{/if}
@@ -7,10 +7,10 @@
type Props = { type Props = {
next: () => void; next: () => void;
end: () => void; previous: () => void;
}; };
const { next, end }: Props = $props(); const { next, previous }: Props = $props();
let detectedInstall: MaintenanceDetectInstallResponseDto | undefined = $state(); let detectedInstall: MaintenanceDetectInstallResponseDto | undefined = $state();
@@ -92,6 +92,6 @@
</div> </div>
<Text>{$t('maintenance_restore_library_confirm')}</Text> <Text>{$t('maintenance_restore_library_confirm')}</Text>
<HStack> <HStack>
<Button onclick={end} variant="ghost">{$t('cancel')}</Button> <Button onclick={previous} variant="ghost">{$t('back')}</Button>
<Button onclick={next} trailingIcon={mdiArrowRight}>{$t('next')}</Button> <Button onclick={next} trailingIcon={mdiArrowRight}>{$t('next')}</Button>
</HStack> </HStack>
@@ -0,0 +1,24 @@
<script lang="ts">
import { Button, Heading, HStack } from '@immich/ui';
import { mdiArrowRight } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
flowToFutoBackups: () => void;
flowToDatabase: () => void;
end: () => void;
};
const { flowToFutoBackups, flowToDatabase, end }: Props = $props();
</script>
<Heading size="large" color="primary" tag="h1">{$t('maintenance_restore_source_title')}</Heading>
<HStack>
<Button onclick={end} variant="ghost">{$t('cancel')}</Button>
<Button onclick={flowToFutoBackups} trailingIcon={mdiArrowRight}
>{$t('maintenance_restore_source_futo_backups')}</Button
>
<Button onclick={flowToDatabase} trailingIcon={mdiArrowRight}
>{$t('maintenance_restore_source_database_backup')}</Button
>
</HStack>
+5
View File
@@ -133,5 +133,10 @@ export default defineConfig({
env: { env: {
TZ: 'UTC', TZ: 'UTC',
}, },
server: {
deps: {
inline: ['@futo-org/backups-orchestrator-ui', '@material-symbols/svg-400'],
},
},
}, },
} as UserConfig); } as UserConfig);