diff --git a/e2e/package.json b/e2e/package.json index 665d26e56e..a7245a3423 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -44,7 +44,7 @@ "exiftool-vendored": "^35.0.0", "globals": "^17.0.0", "luxon": "^3.4.4", - "orchestration-ui": "0.1.57", + "orchestration-ui": "0.1.61", "pg": "^8.11.3", "pngjs": "^7.0.0", "prettier": "^3.7.4", diff --git a/e2e/src/specs/maintenance/server/yucca-backups.e2e-spec.ts b/e2e/src/specs/maintenance/server/yucca-backups.e2e-spec.ts index 4b484c21ce..39104d6509 100644 --- a/e2e/src/specs/maintenance/server/yucca-backups.e2e-spec.ts +++ b/e2e/src/specs/maintenance/server/yucca-backups.e2e-spec.ts @@ -90,7 +90,7 @@ describe('/yucca', () => { beforeAll(async () => { await sdk.importRecoveryKey( { - recoveryKey: '0'.repeat(32), + recoveryKey: '0'.repeat(64), }, requestOpts, ); @@ -285,7 +285,7 @@ describe('/yucca', () => { await sdk.importRecoveryKey( { - recoveryKey: '0'.repeat(32), + recoveryKey: '0'.repeat(64), }, maintenanceRequestOpts, ); diff --git a/e2e/src/specs/maintenance/web/yucca-backups.e2e-spec.ts b/e2e/src/specs/maintenance/web/yucca-backups.e2e-spec.ts new file mode 100644 index 0000000000..c6b5c2d6e6 --- /dev/null +++ b/e2e/src/specs/maintenance/web/yucca-backups.e2e-spec.ts @@ -0,0 +1,141 @@ +import { LoginResponseDto, confirmRecoveryKey, importRecoveryKey, resetOrchestrator } 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('Yucca Backups', () => { + let admin: LoginResponseDto; + let socket: Socket; + + const waitForTaskEnd = () => + new Promise((resolve) => { + const listener = (msg: string) => { + try { + const payload = JSON.parse(msg); + if (payload.type === 'TaskEnd') { + 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 resetOrchestrator({ headers }); + await importRecoveryKey({ importRecoveryKeyRequest: { recoveryKey: '0'.repeat(64) } }, { headers }); + await confirmRecoveryKey({ headers }); + await utils.mkdir('/local-backend'); + + socket = io(baseUrl, { + path: '/api/yucca/socket.io', + transports: ['websocket'], + extraHeaders: headers, + forceNew: true, + }); + await new Promise((resolve) => socket.on('connect', () => resolve())); + }); + + test.afterAll(async () => { + socket?.close(); + }); + + test('onboarding configures a local backend', async ({ context, page }) => { + test.setTimeout(30_000); + await utils.setAuthCookies(context, admin.accessToken); + + await page.goto('/backups'); + + const dialog = page.getByRole('dialog'); + await expect(dialog.filter({ hasText: 'Backup options' })).toBeVisible(); + await dialog.getByText('Local Folder').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: 'Configure Your Immich Backup' })).toBeVisible(); + await dialog.getByRole('button', { name: 'Save' }).click(); + await expect(dialog).toHaveCount(0); + + await expect(page.getByRole('link', { name: 'Repositories' })).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('/backups/repositories'); + const backupNow = page.getByRole('button', { name: 'Backup Now' }); + await expect(backupNow).toBeVisible(); + + const taskEnd = waitForTaskEnd(); + await backupNow.click(); + await expect(page.getByRole('dialog').filter({ hasText: 'Log Output' })).toBeVisible(); + + await taskEnd; + }); + + test('resets immich and restores from the local yucca 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 resetOrchestrator({ 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: '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 Folder').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 Restore Point' })).toBeVisible(); + await dialog.getByRole('button', { name: 'Select' }).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' }).click(); + + await expect(dialog.filter({ hasText: 'Restoring' })).toBeVisible(); + await expect(dialog.filter({ hasText: 'Restoring' })).toBeHidden({ timeout: 60_000 }); + + await page.getByRole('button', { name: 'Next' }).click(); + await page.getByRole('button', { name: 'Restore', exact: true }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click(); + + await page.waitForURL('/maintenance?**'); + await page.waitForURL('/photos', { timeout: 90_000 }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fa1da178d..64ba61d09c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,8 +253,8 @@ importers: specifier: ^3.4.4 version: 3.7.2 orchestration-ui: - specifier: 0.1.57 - version: 0.1.57(svelte@5.55.1) + specifier: 0.1.61 + version: 0.1.61(svelte@5.55.1) pg: specifier: ^8.11.3 version: 8.20.0 @@ -524,8 +524,8 @@ importers: specifier: ^6.3.3 version: 6.8.2 orchestration-api: - specifier: 0.1.57 - version: 0.1.57(@nestjs/platform-express@11.1.17)(class-transformer@0.5.1)(reflect-metadata@0.2.2) + specifier: 0.1.61 + version: 0.1.61(@nestjs/platform-express@11.1.17)(class-transformer@0.5.1)(reflect-metadata@0.2.2) pg: specifier: ^8.11.3 version: 8.20.0 @@ -816,8 +816,8 @@ importers: specifier: ^5.6.2 version: 5.21.0 orchestration-ui: - specifier: 0.1.57 - version: 0.1.57(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1) + specifier: 0.1.61 + version: 0.1.61(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1) pmtiles: specifier: ^4.3.0 version: 4.4.0 @@ -9675,11 +9675,11 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} - orchestration-api@0.1.57: - resolution: {integrity: sha512-rXsiIurhkliJ/5fbLz6kTg7VBrgaq6MrNZJQwdnDvsw1FLZCANY1KUh1WwSkgI5OLmb/lAcyLE+vO6UeMkXgyg==} + orchestration-api@0.1.61: + resolution: {integrity: sha512-RClFa0Xtlyyg1VaO9bUKWGVW34xgHNbHv/UOmybz2EWl2kTA+nZA4ZzoRBN/LbAuXsnTnlevsqJqTkqK1vCkTg==} - orchestration-ui@0.1.57: - resolution: {integrity: sha512-vvpVItUdFpj5PKgIvkKMKv5gLpygc4b1rFtXBE4P0+rtFLklw6QTuVrIXzmxU/t2fMwP8J93ZPRSTP05t0MzBA==} + orchestration-ui@0.1.61: + resolution: {integrity: sha512-BvefDhMN4AwujszsXY3J0hDLmQM+x1qAbgWfb/KfKbjq+OB+hh6+8o4DMa2xCWsw80A8M6/cgKutYGvUa8FKJw==} peerDependencies: svelte: ^5.0.0 @@ -12639,8 +12639,8 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - yucca-api-client@0.1.57: - resolution: {integrity: sha512-faaYkjeO2IPvmvxYHrGefvGm7zE69sMk0fjizxd8/TOV9WztC2mWPDDwRIn9SZngg8AMekhg1xHakkWhMjttyQ==} + yucca-api-client@0.1.61: + resolution: {integrity: sha512-JNo95RWc4Zb3WslT50yN3l2BWIfXeNXnYJv6ULn40qDY1nlpZPGorYS2QlIxVLSz6ypmyf1Ia1vQVtzA75L5/w==} yup@1.7.1: resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} @@ -23009,7 +23009,7 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - orchestration-api@0.1.57(@nestjs/platform-express@11.1.17)(class-transformer@0.5.1)(reflect-metadata@0.2.2): + orchestration-api@0.1.61(@nestjs/platform-express@11.1.17)(class-transformer@0.5.1)(reflect-metadata@0.2.2): dependencies: '@futo-org/restic-wrapper': 1.1.2 '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -23030,7 +23030,7 @@ snapshots: rxjs: 7.8.2 socket.io: 4.8.3 tail: 2.2.6 - yucca-api-client: 0.1.57 + yucca-api-client: 0.1.61 transitivePeerDependencies: - '@nestjs/microservices' - '@nestjs/platform-express' @@ -23040,7 +23040,7 @@ snapshots: - supports-color - utf-8-validate - orchestration-ui@0.1.57(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1): + orchestration-ui@0.1.61(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1): dependencies: '@immich/ui': 0.59.0(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1) '@mdi/js': 7.4.47 @@ -23052,14 +23052,14 @@ snapshots: luxon: 3.7.2 socket.io-client: 4.8.3 svelte: 5.55.1 - yucca-api-client: 0.1.57 + yucca-api-client: 0.1.61 transitivePeerDependencies: - '@sveltejs/kit' - bufferutil - supports-color - utf-8-validate - orchestration-ui@0.1.57(svelte@5.55.1): + orchestration-ui@0.1.61(svelte@5.55.1): dependencies: '@immich/ui': 0.59.0(svelte@5.55.1) '@mdi/js': 7.4.47 @@ -23071,7 +23071,7 @@ snapshots: luxon: 3.7.2 socket.io-client: 4.8.3 svelte: 5.55.1 - yucca-api-client: 0.1.57 + yucca-api-client: 0.1.61 transitivePeerDependencies: - '@sveltejs/kit' - bufferutil @@ -26594,7 +26594,7 @@ snapshots: yoctocolors@2.1.2: {} - yucca-api-client@0.1.57: + yucca-api-client@0.1.61: dependencies: '@oazapfts/runtime': 1.2.0 diff --git a/server/package.json b/server/package.json index a14a2c59f5..1e4f9b08d5 100644 --- a/server/package.json +++ b/server/package.json @@ -99,7 +99,7 @@ "nestjs-zod": "^5.3.0", "nodemailer": "^8.0.0", "openid-client": "^6.3.3", - "orchestration-api": "0.1.57", + "orchestration-api": "0.1.61", "pg": "^8.11.3", "pg-connection-string": "^2.9.1", "picomatch": "^4.0.2", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index ba0a9d6824..9d33fb3f0a 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -10,7 +10,7 @@ import { OrchestrationApiModule } from 'orchestration-api/dist'; import { commandsAndQuestions } from 'src/commands'; import { IWorker } from 'src/constants'; import { controllers } from 'src/controllers'; -import { ImmichWorker } from 'src/enum'; +import { ImmichEnvironment, ImmichWorker } from 'src/enum'; import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard'; import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository'; @@ -60,7 +60,9 @@ const apiMiddleware = [ ]; const configRepository = new ConfigRepository(); -const { bull, cls, database, otel } = configRepository.getEnv(); +const { bull, cls, database, environment, otel } = configRepository.getEnv(); +const isYuccaDevelopmentMode = + environment === ImmichEnvironment.Development || environment === ImmichEnvironment.Testing; const commonImports = [ ClsModule.forRoot(cls.config), @@ -121,6 +123,7 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy { statePath: '/data/yucca', // TODO requireWsAuth: true, requireLock: true, + developmentMode: isYuccaDevelopmentMode, }), ], controllers: [...controllers], @@ -138,6 +141,7 @@ export class ApiModule extends BaseModule {} externalBaseUrl: 'https://my.immich.app', requireWsAuth: true, requireLock: true, + developmentMode: isYuccaDevelopmentMode, }), ], controllers: [MaintenanceWorkerController], diff --git a/web/package.json b/web/package.json index 719a51cb3f..84a9bb3e10 100644 --- a/web/package.json +++ b/web/package.json @@ -50,7 +50,7 @@ "lodash-es": "^4.17.21", "luxon": "^3.4.4", "maplibre-gl": "^5.6.2", - "orchestration-ui": "0.1.57", + "orchestration-ui": "0.1.61", "pmtiles": "^4.3.0", "qrcode": "^1.5.4", "simple-icons": "^16.0.0",