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:
@@ -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.getByRole('button', { name: 'Database Backup' }).click();
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
duplicateDetection: false,
|
||||
facialRecognition: false,
|
||||
backups: false,
|
||||
map: true,
|
||||
reverseGeocoding: true,
|
||||
importFaces: false,
|
||||
|
||||
@@ -143,6 +143,7 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
||||
smartSearch: false,
|
||||
facialRecognition: false,
|
||||
duplicateDetection: false,
|
||||
backups: false,
|
||||
map: true,
|
||||
reverseGeocoding: true,
|
||||
importFaces: false,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
createUserAdmin,
|
||||
deleteAssets,
|
||||
deleteDatabaseBackup,
|
||||
deleteLibrary,
|
||||
getAssetInfo,
|
||||
getConfig,
|
||||
getConfigDefaults,
|
||||
@@ -466,6 +467,8 @@ export const utils = {
|
||||
updateLibrary: (accessToken: string, id: string, dto: UpdateLibraryDto) =>
|
||||
updateLibrary({ id, updateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
deleteLibrary: (accessToken: string, id: string) => deleteLibrary({ id }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
createPartner: (accessToken: string, id: string) =>
|
||||
createPartner({ partnerCreateDto: { sharedWithId: id } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
"backup_onboarding_title": "Backups",
|
||||
"backup_settings": "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}",
|
||||
"config_set_by_file": "Config is currently set by a config file",
|
||||
"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_load_asset": "Failed to load asset",
|
||||
"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_people": "Failed to load people",
|
||||
"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_menu": "Main menu",
|
||||
"maintenance_action_restore": "Restoring Database",
|
||||
"maintenance_action_rollback": "Restoring from Backup",
|
||||
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
|
||||
"maintenance_end": "End maintenance mode",
|
||||
"maintenance_logged_in_as": "Currently logged in as {user}",
|
||||
"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_confirm": "If this looks correct, continue to restoring a backup!",
|
||||
"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_storage_template_missing_files": "Using storage template? You may be missing files",
|
||||
"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_migrations": "Running database migrations…",
|
||||
"maintenance_task_restore": "Restoring the chosen backup…",
|
||||
@@ -1789,6 +1805,8 @@
|
||||
"purchase_panel_title": "Support the project",
|
||||
"purchase_per_server": "Per server",
|
||||
"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_prompt": "Are you sure you want to remove the product key?",
|
||||
"purchase_remove_server_product_key": "Remove Server product key",
|
||||
|
||||
@@ -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"
|
||||
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]]
|
||||
version = "1.2.0"
|
||||
backend = "aqua:gruntwork-io/terragrunt"
|
||||
|
||||
@@ -24,6 +24,7 @@ opentofu = "1.13.2"
|
||||
"github:extism/cli" = "1.6.3"
|
||||
"github:webassembly/binaryen" = "version_124"
|
||||
"github:extism/js-pdk" = "v1.7.0"
|
||||
restic = "0.19.0"
|
||||
java = "21.0.2"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"]
|
||||
|
||||
@@ -50,7 +50,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'LoginResponseDto': {'isOnboarded': false},
|
||||
'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false},
|
||||
'SyncAssetV1': {'isEdited': false},
|
||||
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
|
||||
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false, 'backups': false},
|
||||
'SearchAssetResponseDto': {'nextCursor': null},
|
||||
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
|
||||
'PeopleResponse': {'updateStrategy': 'everyone'},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+595
-12
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ allowBuilds:
|
||||
'@scarf/scarf': false
|
||||
'@swc/core': false
|
||||
bcrypt: true
|
||||
better-sqlite3: true
|
||||
canvas: false
|
||||
core-js: false
|
||||
cpu-features: false
|
||||
@@ -75,3 +76,4 @@ minimumReleaseAgeExclude:
|
||||
- '@immich/ui'
|
||||
- '@immich/sql-tools@0.6.3 || 0.6.6'
|
||||
- '@nestjs/cli@12.0.1'
|
||||
- '@futo-org/*'
|
||||
|
||||
+3
-1
@@ -78,7 +78,6 @@ ENV MISE_DATA_DIR=/buildcache/mise
|
||||
ENV MISE_DISABLE_TOOLS=flutter
|
||||
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
mise install --locked
|
||||
|
||||
COPY ./packages/plugin-core ./packages/plugin-core/
|
||||
|
||||
# Build plugins
|
||||
@@ -119,6 +118,9 @@ ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF}
|
||||
ENV IMMICH_SOURCE_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
|
||||
EXPOSE 2283
|
||||
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
|
||||
|
||||
@@ -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/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 && \
|
||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@extism/extism": "2.0.0-rc13",
|
||||
"@futo-org/backups-orchestrator-api": "1.11.5",
|
||||
"@immich/plugin-sdk": "workspace:*",
|
||||
"@immich/sql-tools": "^0.6.6",
|
||||
"@nestjs/bullmq": "^12.0.0",
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { OrchestrationApiModule, controllers as futoBackupsControllers } from '@futo-org/backups-orchestrator-api';
|
||||
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 { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { ApiExtension, ApiTags } from '@nestjs/swagger';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { KyselyModule } from 'nestjs-kysely';
|
||||
import { OpenTelemetryModule } from 'nestjs-otel';
|
||||
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { commandsAndQuestions } from 'src/commands/index.js';
|
||||
import { IWorker } from 'src/constants.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 { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository.js';
|
||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository.js';
|
||||
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller.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 { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor.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 { getKyselyConfig } from 'src/utils/database.js';
|
||||
import { configureUserAgent } from 'src/utils/fetch.js';
|
||||
import { detectMediaLocation, getBackupsStatePath } from 'src/utils/storage.js';
|
||||
|
||||
const common = [...repositories, ...services, GlobalExceptionFilter];
|
||||
|
||||
@@ -53,7 +59,21 @@ const commonMiddleware = [
|
||||
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
|
||||
|
||||
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 = [
|
||||
ClsModule.forRoot(cls.config),
|
||||
@@ -103,14 +123,54 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
@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],
|
||||
providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }],
|
||||
exports: [AuthService, WebsocketRepository],
|
||||
})
|
||||
export class ApiModule extends BaseModule {}
|
||||
|
||||
@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],
|
||||
providers: [
|
||||
ConfigRepository,
|
||||
@@ -129,6 +189,7 @@ export class ApiModule extends BaseModule {}
|
||||
{ provide: APP_GUARD, useClass: MaintenanceAuthGuard },
|
||||
{ provide: IWorker, useValue: ImmichWorker.Maintenance },
|
||||
],
|
||||
exports: [MaintenanceWorkerService, MaintenanceWebsocketRepository],
|
||||
})
|
||||
export class MaintenanceModule {
|
||||
constructor(
|
||||
|
||||
@@ -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.Authentication]: 'Endpoints related to user authentication, including OAuth.',
|
||||
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
|
||||
[ApiTag.Backups]: 'FUTO Backups.',
|
||||
[ApiTag.ClusterGroups]:
|
||||
'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.',
|
||||
|
||||
@@ -92,7 +92,7 @@ const getRoutes = () => {
|
||||
id: `${method} ${path}`,
|
||||
label: `${Controller.name}.${name} (${method} /${path})`,
|
||||
path,
|
||||
auth: getAuthenticatedOptions(reflector, handler),
|
||||
auth: getAuthenticatedOptions(reflector, [handler]),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,6 +163,7 @@ const AdminConfigSchemaWithVisibility = z
|
||||
.object({
|
||||
backup: z
|
||||
.object({
|
||||
beta: configBool.default(false).optional().describe('Whether the backups feature is enabled'),
|
||||
database: z
|
||||
.object({
|
||||
enabled: configBool.describe('Enabled'),
|
||||
@@ -584,6 +585,7 @@ export function mapPublicConfig(config: SystemConfig): PublicConfigDto {
|
||||
|
||||
export const defaults = Object.freeze<SystemConfig>({
|
||||
backup: {
|
||||
beta: false,
|
||||
database: {
|
||||
enabled: true,
|
||||
cronExpression: CronExpression.EVERY_DAY_AT_2AM,
|
||||
|
||||
@@ -6,6 +6,8 @@ const SetMaintenanceModeSchema = z
|
||||
.object({
|
||||
action: MaintenanceActionSchema,
|
||||
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(
|
||||
(data) => data.action !== MaintenanceAction.RestoreDatabase || (data.restoreBackupFilename?.length ?? 0) > 0,
|
||||
@@ -32,6 +34,7 @@ const MaintenanceStatusResponseSchema = z
|
||||
progress: z.int().optional(),
|
||||
task: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
futoBackupsJobId: z.string().optional().describe('FUTO Backups job ID'),
|
||||
})
|
||||
.meta({ id: 'MaintenanceStatusResponseDto' });
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ const ServerFeaturesSchema = z
|
||||
configFile: z.boolean().describe('Whether config file is available'),
|
||||
facialRecognition: z.boolean().describe('Whether facial recognition 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'),
|
||||
reverseGeocoding: z.boolean().describe('Whether reverse geocoding is enabled'),
|
||||
importFaces: z.boolean().describe('Whether face import is enabled'),
|
||||
|
||||
@@ -1031,6 +1031,7 @@ export enum DatabaseLock {
|
||||
VersionCheck = 800,
|
||||
HlsSessionCleanup = 850,
|
||||
FacialRecognition = 900,
|
||||
FutoBackupsModuleConfig = 926,
|
||||
DuplicateDetection = 1000,
|
||||
}
|
||||
|
||||
@@ -1039,6 +1040,7 @@ export enum MaintenanceAction {
|
||||
End = 'end',
|
||||
SelectDatabaseRestore = 'select_database_restore',
|
||||
RestoreDatabase = 'restore_database',
|
||||
Rollback = 'rollback',
|
||||
}
|
||||
|
||||
export const MaintenanceActionSchema = z
|
||||
@@ -1263,6 +1265,7 @@ export enum ApiTag {
|
||||
AuthenticationAdmin = 'Authentication (admin)',
|
||||
Assets = 'Assets',
|
||||
AssetFiles = 'Asset files',
|
||||
Backups = 'Backups',
|
||||
ConfigUser = 'Config (user)',
|
||||
ConfigAdmin = 'Config (admin)',
|
||||
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 { Request } from 'express';
|
||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto.js';
|
||||
@@ -26,16 +27,19 @@ export class MaintenanceAuthGuard implements CanActivate {
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
|
||||
|
||||
const targets = [context.getHandler()];
|
||||
const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>(
|
||||
MetadataKey.AuthRoute,
|
||||
targets,
|
||||
);
|
||||
if (!options) {
|
||||
|
||||
const isFutoBackupsRoute = (futoBackupsControllers as Type[]).includes(context.getClass());
|
||||
if (!options && !isFutoBackupsRoute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
|
||||
request.auth = await this.service.authenticate(request.headers);
|
||||
|
||||
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 { fork } from 'node:child_process';
|
||||
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()
|
||||
export class MaintenanceHealthRepository {
|
||||
@@ -14,42 +18,54 @@ export class MaintenanceHealthRepository {
|
||||
execArgv: process.execArgv.filter((arg) => !arg.startsWith('--inspect')),
|
||||
env: {
|
||||
...process.env,
|
||||
IMMICH_HOST: '127.0.0.1',
|
||||
IMMICH_PORT: '33001',
|
||||
IMMICH_HOST: HEALTH_CHECK_HOST,
|
||||
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) => {
|
||||
if (worker.exitCode !== null) {
|
||||
function settle(error?: Error) {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
output += data;
|
||||
|
||||
if (!output.includes(IMMICH_SERVER_START)) {
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
settled = true;
|
||||
clearTimeout(pingTimer);
|
||||
clearTimeout(deadline);
|
||||
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) =>
|
||||
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(() => {
|
||||
if (worker.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Server health check failed, took too long to start.'));
|
||||
worker.kill('SIGTERM');
|
||||
}, 180_000);
|
||||
const deadline = setTimeout(
|
||||
() => settle(new Error('Server health check failed, took too long to start.')),
|
||||
HEALTH_CHECK_TIMEOUT,
|
||||
);
|
||||
pingTimer = setTimeout(() => void ping(), PING_INTERVAL);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
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 { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository.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, () => {
|
||||
let sut: MaintenanceWorkerService;
|
||||
let mocks: ServiceMocks;
|
||||
let moduleRefMock: { get: ReturnType<typeof vitest.fn> };
|
||||
let maintenanceWebsocketRepositoryMock: AutoMocked<MaintenanceWebsocketRepository>;
|
||||
let maintenanceHealthRepositoryMock: AutoMocked<MaintenanceHealthRepository>;
|
||||
let databaseBackupServiceMock: AutoMocked<DatabaseBackupService>;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = getMocks();
|
||||
moduleRefMock = { get: vitest.fn() };
|
||||
maintenanceWebsocketRepositoryMock = automock(MaintenanceWebsocketRepository, {
|
||||
args: [mocks.logger],
|
||||
strict: false,
|
||||
@@ -50,6 +52,7 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
mocks.process,
|
||||
mocks.database as never,
|
||||
databaseBackupServiceMock,
|
||||
moduleRefMock as never,
|
||||
);
|
||||
|
||||
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', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { YuccaService } from '@futo-org/backups-orchestrator-api';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { parseCookie } from 'cookie';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { basename } from 'node:path';
|
||||
import type { MaintenanceModeState } from 'src/types.js';
|
||||
import { serverVersion } from 'src/constants.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 { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance.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 */
|
||||
@Injectable()
|
||||
@@ -53,6 +57,7 @@ export class MaintenanceWorkerService {
|
||||
private processRepository: ProcessRepository,
|
||||
private databaseRepository: DatabaseRepository,
|
||||
private databaseBackupService: DatabaseBackupService,
|
||||
private moduleRef: ModuleRef,
|
||||
) {
|
||||
this.logger.setContext(this.constructor.name);
|
||||
}
|
||||
@@ -80,6 +85,10 @@ export class MaintenanceWorkerService {
|
||||
|
||||
await this.logSecret();
|
||||
|
||||
if (await this.databaseRepository.tryLock(DatabaseLock.FutoBackupsModuleConfig)) {
|
||||
this.moduleRef.get(YuccaService, { strict: false }).acquireLock();
|
||||
}
|
||||
|
||||
if (state.action) {
|
||||
void this.runAction(state.action);
|
||||
}
|
||||
@@ -146,28 +155,9 @@ export class MaintenanceWorkerService {
|
||||
};
|
||||
}
|
||||
|
||||
/** {@link _StorageService.detectMediaLocation} */
|
||||
detectMediaLocation(): string {
|
||||
const envData = this.configRepository.getEnv();
|
||||
if (envData.storage.mediaLocation) {
|
||||
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';
|
||||
return detectMediaLocation(envData.storage.mediaLocation, (path) => this.storageRepository.existsSync(path));
|
||||
}
|
||||
|
||||
private get secret() {
|
||||
@@ -277,6 +267,9 @@ export class MaintenanceWorkerService {
|
||||
case MaintenanceAction.RestoreDatabase: {
|
||||
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> {
|
||||
const state: MaintenanceModeState = { isMaintenanceMode: false as const };
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
|
||||
|
||||
@@ -16,9 +16,15 @@ class TestController {
|
||||
undecoratedRoute() {}
|
||||
}
|
||||
|
||||
const contextFor = (handler: () => void) =>
|
||||
@Authenticated({ admin: true })
|
||||
class AdminTestController {
|
||||
route() {}
|
||||
}
|
||||
|
||||
const contextFor = (handler: () => void, controller: object = TestController) =>
|
||||
({
|
||||
getHandler: () => handler,
|
||||
getClass: () => controller,
|
||||
switchToHttp: () => ({ getRequest: () => ({ headers: {}, query: {}, path: '/' }) }),
|
||||
}) as unknown as ExecutionContext;
|
||||
|
||||
@@ -79,4 +85,18 @@ describe(AuthGuard.name, () => {
|
||||
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 }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,12 +26,12 @@ export type AuthenticatedOptions = AuthorizedRoute | PublicRoute;
|
||||
type ReflectorTarget = Parameters<Reflector['get']>[1];
|
||||
|
||||
/** Resolves the `@Authenticated()` options of a route handler, with the defaults applied. */
|
||||
export const getAuthenticatedOptions = (reflector: Reflector, target: ReflectorTarget) => {
|
||||
const options = reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, [target]);
|
||||
export const getAuthenticatedOptions = (reflector: Reflector, targets: ReflectorTarget[]) => {
|
||||
const options = reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, targets);
|
||||
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)];
|
||||
|
||||
if (!options.public) {
|
||||
@@ -97,7 +97,7 @@ export class AuthGuard implements CanActivate {
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const options = getAuthenticatedOptions(this.reflector, context.getHandler());
|
||||
const options = getAuthenticatedOptions(this.reflector, [context.getHandler(), context.getClass()]);
|
||||
if (!options) {
|
||||
throw new Error(`Route ${context.getHandler().name} does not declare @Authenticated()`);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ export class WebSocketAdapter extends IoAdapter {
|
||||
const server = super.createIOServer(port, options);
|
||||
const pubClient = new Redis(redis);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ type EventMap = {
|
||||
/** job finishes with error */
|
||||
JobError: [JobErrorEvent];
|
||||
|
||||
LibraryCreate: [];
|
||||
LibraryUpdate: [];
|
||||
LibraryDelete: [];
|
||||
|
||||
// queue events
|
||||
QueueStart: [QueueStartEvent];
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ import { handlePromiseError } from 'src/utils/misc.js';
|
||||
export const serverEvents = [
|
||||
'ConfigUpdate',
|
||||
'AppRestart',
|
||||
'LibraryCreate',
|
||||
'LibraryUpdate',
|
||||
'LibraryDelete',
|
||||
'HlsSegmentRequest',
|
||||
'HlsSegmentResult',
|
||||
'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 () => {
|
||||
const result = await sut.handleBackupDatabase();
|
||||
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`);
|
||||
|
||||
const { bin, args, databasePassword, databaseVersion, databaseMajorVersion } =
|
||||
@@ -252,7 +252,7 @@ export class DatabaseBackupService {
|
||||
gzip = this.processRepository.spawnDuplexStream(compression.command, compression.args);
|
||||
const fileStream = this.storageRepository.createWriteStream(temporaryFilePath);
|
||||
|
||||
await pipeline(pgdump, gzip, fileStream);
|
||||
await pipeline(pgdump, gzip, fileStream, { signal });
|
||||
await this.storageRepository.rename(temporaryFilePath, backupFilePath);
|
||||
} catch (error) {
|
||||
this.logger.error(`Database Backup Failure: ${error}`);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { DatabaseBackupService } from 'src/services/database-backup.service.js';
|
||||
import { DatabaseService } from 'src/services/database.service.js';
|
||||
import { DownloadService } from 'src/services/download.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 { IntegrityService } from 'src/services/integrity.service.js';
|
||||
import { JobService } from 'src/services/job.service.js';
|
||||
@@ -68,6 +69,7 @@ export const services = [
|
||||
DatabaseService,
|
||||
DownloadService,
|
||||
DuplicateService,
|
||||
FutoBackupsService,
|
||||
IntegrityService,
|
||||
HlsService,
|
||||
JobService,
|
||||
|
||||
@@ -619,6 +619,7 @@ describe(LibraryService.name, () => {
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LibraryDelete, data: { id: 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 () => {
|
||||
@@ -724,6 +725,7 @@ describe(LibraryService.name, () => {
|
||||
exclusionPatterns: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('LibraryCreate');
|
||||
});
|
||||
|
||||
it('should create with name', async () => {
|
||||
@@ -891,6 +893,7 @@ describe(LibraryService.name, () => {
|
||||
'library-id',
|
||||
expect.objectContaining({ importPaths: [`${cwd}/foo/bar`] }),
|
||||
);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('LibraryUpdate');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -247,6 +247,8 @@ export class LibraryService extends BaseService {
|
||||
'**/.stfolder/**',
|
||||
],
|
||||
});
|
||||
|
||||
await this.eventRepository.emit('LibraryCreate');
|
||||
return mapLibrary(library);
|
||||
}
|
||||
|
||||
@@ -358,6 +360,7 @@ export class LibraryService extends BaseService {
|
||||
}
|
||||
|
||||
const library = await this.libraryRepository.update(id, dto);
|
||||
await this.eventRepository.emit('LibraryUpdate');
|
||||
return mapLibrary(library);
|
||||
}
|
||||
|
||||
@@ -370,6 +373,8 @@ export class LibraryService extends BaseService {
|
||||
|
||||
await this.libraryRepository.softDelete(id);
|
||||
await this.jobRepository.queue({ name: JobName.LibraryDelete, data: { id } });
|
||||
|
||||
await this.eventRepository.emit('LibraryDelete');
|
||||
}
|
||||
|
||||
@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', () => {
|
||||
it('validates smtp config when enabling smtp', async () => {
|
||||
const oldConfig = configs.smtpDisabled;
|
||||
|
||||
@@ -123,6 +123,21 @@ export class NotificationService extends BaseService {
|
||||
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 })
|
||||
async onConfigValidate({ oldConfig, newConfig }: ArgOf<'ConfigValidate'>) {
|
||||
try {
|
||||
|
||||
@@ -138,6 +138,7 @@ describe(ServerService.name, () => {
|
||||
duplicateDetection: true,
|
||||
facialRecognition: true,
|
||||
importFaces: false,
|
||||
backups: false,
|
||||
map: true,
|
||||
reverseGeocoding: true,
|
||||
oauth: false,
|
||||
|
||||
@@ -86,8 +86,18 @@ export class ServerService extends BaseService {
|
||||
}
|
||||
|
||||
async getFeatures(): Promise<ServerFeaturesDto> {
|
||||
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications, ffmpeg } =
|
||||
await this.getConfig({ withCache: false });
|
||||
const {
|
||||
reverseGeocoding,
|
||||
metadata,
|
||||
map,
|
||||
backup,
|
||||
machineLearning,
|
||||
trash,
|
||||
oauth,
|
||||
passwordLogin,
|
||||
notifications,
|
||||
ffmpeg,
|
||||
} = await this.getConfig({ withCache: false });
|
||||
const { configFile } = this.configRepository.getEnv();
|
||||
|
||||
return {
|
||||
@@ -95,6 +105,7 @@ export class ServerService extends BaseService {
|
||||
facialRecognition: isFacialRecognitionEnabled(machineLearning),
|
||||
duplicateDetection: isDuplicateDetectionEnabled(machineLearning),
|
||||
map: map.enabled,
|
||||
backups: !!backup.beta,
|
||||
reverseGeocoding: reverseGeocoding.enabled,
|
||||
importFaces: metadata.faces.import,
|
||||
sidecar: true,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'src/enum.js';
|
||||
import { BaseService } from 'src/services/base.service.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.`;
|
||||
|
||||
@@ -22,25 +23,7 @@ const docsMessage = `Please see https://docs.immich.app/administration/system-in
|
||||
export class StorageService extends BaseService {
|
||||
private detectMediaLocation(): string {
|
||||
const envData = this.configRepository.getEnv();
|
||||
if (envData.storage.mediaLocation) {
|
||||
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';
|
||||
return detectMediaLocation(envData.storage.mediaLocation, (path) => this.storageRepository.existsSync(path));
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.StorageService })
|
||||
|
||||
@@ -47,6 +47,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
[QueueName.Editor]: { concurrency: 2 },
|
||||
},
|
||||
backup: {
|
||||
beta: false,
|
||||
database: {
|
||||
enabled: true,
|
||||
cronExpression: '0 02 * * *',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -13,6 +13,10 @@ async function bootstrap() {
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(ApiModule, {
|
||||
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' },
|
||||
routeResolutionStrategy: 'specificity',
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ async function bootstrap() {
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, {
|
||||
bufferLogs: true,
|
||||
// see comment in api.ts
|
||||
moduleIdGeneratorAlgorithm: 'deep-hash',
|
||||
routeConflictPolicy: { duplicate: 'error' },
|
||||
routeResolutionStrategy: 'specificity',
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { JobRepository } from 'src/repositories/job.repository.js';
|
||||
import { LibraryRepository } from 'src/repositories/library.repository.js';
|
||||
import { LoggingRepository } from 'src/repositories/logging.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 { LibraryService } from 'src/services/library.service.js';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub.js';
|
||||
@@ -49,7 +50,14 @@ class LibraryTestContext extends MediumTestContext<typeof LibraryService> {
|
||||
super(LibraryService, {
|
||||
database,
|
||||
real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository],
|
||||
mock: [CronRepository, DatabaseRepository, EventRepository, JobRepository, LoggingRepository],
|
||||
mock: [
|
||||
CronRepository,
|
||||
DatabaseRepository,
|
||||
EventRepository,
|
||||
JobRepository,
|
||||
LoggingRepository,
|
||||
WebsocketRepository,
|
||||
],
|
||||
});
|
||||
|
||||
const jobs = this.getMock(JobRepository);
|
||||
@@ -57,6 +65,7 @@ class LibraryTestContext extends MediumTestContext<typeof LibraryService> {
|
||||
jobs.queueAll.mockResolvedValue();
|
||||
|
||||
this.getMock(EventRepository).emit.mockResolvedValue();
|
||||
this.getMock(WebsocketRepository).serverSend.mockReturnValue();
|
||||
}
|
||||
|
||||
async createLibrary(options: { importPaths?: string[]; exclusionPatterns?: string[] } = {}) {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@futo-org/backups-orchestrator-ui": "1.11.5",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@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 NavigationBar from '#lib/components/shared-components/navigation-bar/NavigationBar.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 { sidebarStore } from '#lib/stores/sidebar.svelte.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 { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull, mdiWrench } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
@@ -28,6 +30,10 @@
|
||||
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">
|
||||
{#if featureFlagsManager.value.backups}
|
||||
<ImmichBackupsAdminNavButton href={Route.backups()} />
|
||||
{/if}
|
||||
|
||||
<NavbarItem title={$t('users')} href={Route.users()} icon={mdiAccountMultipleOutline} />
|
||||
<NavbarItem title={$t('external_libraries')} href={Route.libraries()} icon={mdiBookshelf} />
|
||||
<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 MaintenanceBackupEntry from './MaintenanceBackupEntry.svelte';
|
||||
|
||||
vi.mock('#lib/services/database-backups.service.ts', () => ({
|
||||
getDatabaseBackupActions: () => ({
|
||||
Download: { type: 'command', title: 'Download', onAction: vi.fn() },
|
||||
Delete: { type: 'command', title: 'Delete', onAction: vi.fn() },
|
||||
}),
|
||||
handleRestoreDatabaseBackup: vi.fn(),
|
||||
}));
|
||||
vi.mock('#lib/services/database-backups.service.ts', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('#lib/services/database-backups.service.ts')>();
|
||||
|
||||
return {
|
||||
...original,
|
||||
getDatabaseBackupActions: () => ({
|
||||
Download: { type: 'command', title: 'Download', onAction: vi.fn() },
|
||||
Delete: { type: 'command', title: 'Delete', onAction: vi.fn() },
|
||||
}),
|
||||
handleRestoreDatabaseBackup: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
describe('MaintenanceBackupEntry', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<script lang="ts">
|
||||
import MaintenanceBackupCard from '#lib/components/maintenance/MaintenanceBackupCard.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 { locale } from '#lib/stores/preferences.store.ts';
|
||||
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 { Button, ContextMenuButton } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
@@ -18,31 +14,6 @@
|
||||
|
||||
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));
|
||||
|
||||
let isDeleting = $state(false);
|
||||
@@ -56,71 +27,11 @@
|
||||
|
||||
<OnEvents {onBackupDeleteStatus} />
|
||||
|
||||
<Card class="dark:bg-dark-900">
|
||||
<CardBody class="px-6 pt-3 pb-4">
|
||||
<Stack gap={3} class="min-w-0 grow">
|
||||
<div class="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 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>
|
||||
<MaintenanceBackupCard {filename} {filesize} {expectedVersion} {timezone}>
|
||||
{#snippet actions()}
|
||||
<Button size="small" onclick={() => handleRestoreDatabaseBackup(filename)} disabled={isDeleting}>
|
||||
{$t('restore')}
|
||||
</Button>
|
||||
<ContextMenuButton disabled={isDeleting} position="top-right" aria-label={$t('open')} items={[Download, Delete]} />
|
||||
{/snippet}
|
||||
</MaintenanceBackupCard>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import HeaderActionButton from '#lib/components/HeaderActionButton.svelte';
|
||||
import MaintenanceBackupEntry from '#lib/components/maintenance/MaintenanceBackupEntry.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 { listDatabaseBackups } from '@immich/sdk';
|
||||
import { Card, CardBody, HStack, Icon, ProgressBar, Stack, Text } from '@immich/ui';
|
||||
@@ -51,13 +51,12 @@
|
||||
const unknownDateKey = $t('unknown_date');
|
||||
|
||||
for (const backup of backups) {
|
||||
const timezone = backup.timezone;
|
||||
const dateMatch = backup.filename.match(/\d+T\d+/);
|
||||
const backupDateTime = getDatabaseBackupDateTime(backup.filename, backup.timezone);
|
||||
let dateKey: string;
|
||||
let dt: DateTime;
|
||||
|
||||
if (dateMatch) {
|
||||
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone });
|
||||
if (backupDateTime) {
|
||||
dt = backupDateTime;
|
||||
dateKey = dt.toFormat('LLLL d, yyyy');
|
||||
} else {
|
||||
dt = DateTime.fromMillis(0);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { authManager } from '#lib/managers/auth-manager.svelte.ts';
|
||||
import { handleError } from '#lib/utils/handle-error.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 UserPurchaseOptionCard from './IndividualPurchaseOptionCard.svelte';
|
||||
import ServerPurchaseOptionCard from './ServerPurchaseOptionCard.svelte';
|
||||
@@ -42,6 +42,9 @@
|
||||
</Heading>
|
||||
{/if}
|
||||
|
||||
<Text>{$t('purchase_product_key_description')}</Text>
|
||||
<Text fontWeight="bold">{$t('purchase_product_key_excludes_backups')}</Text>
|
||||
|
||||
{#if showMessage}
|
||||
<div class="mt-2">
|
||||
<p>
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
<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 ServerStatus from './ServerStatus.svelte';
|
||||
import StorageSpace from './StorageSpace.svelte';
|
||||
import { Route } from '#lib/route.ts';
|
||||
import { hideBackupsReminder } from '#lib/stores/preferences.store.ts';
|
||||
</script>
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="ms-4 mt-auto overflow-hidden rounded-lg">
|
||||
<StorageSpace />
|
||||
|
||||
{#if authManager.user.isAdmin && featureFlagsManager.value.backups && !$hideBackupsReminder}
|
||||
<ImmichBackupsSidebarItem href={Route.backups()} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PurchaseInfo />
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</script>
|
||||
|
||||
<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', {
|
||||
values: {
|
||||
used: getByteUnitString(usedBytes, $locale, 3),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eventManager } from '#lib/managers/event-manager.svelte.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 {
|
||||
constructor() {
|
||||
@@ -17,10 +18,12 @@ class LanguageManager {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
if (!item) {
|
||||
return;
|
||||
|
||||
@@ -142,6 +142,12 @@ export const Route = {
|
||||
workflows: () => '/workflows',
|
||||
viewWorkflow: ({ id }: { id: string }) => `/workflows/${id}`,
|
||||
|
||||
// backups
|
||||
backups: () => '/admin/backups',
|
||||
backupSettings: () => '/admin/backups/settings',
|
||||
backupAttempts: () => '/admin/backups/attempts',
|
||||
backupSnapshots: () => '/admin/backups/snapshots',
|
||||
|
||||
// queues
|
||||
queues: () => '/admin/queues',
|
||||
viewQueue: ({ name }: { name: QueueName }) => `/admin/queues/${asQueueSlug(name)}`,
|
||||
|
||||
@@ -6,14 +6,41 @@ import { getFormatter } from '#lib/utils/i18n.ts';
|
||||
import {
|
||||
deleteDatabaseBackup,
|
||||
getBaseUrl,
|
||||
listDatabaseBackups,
|
||||
MaintenanceAction,
|
||||
setMaintenanceMode,
|
||||
type DatabaseBackupDto,
|
||||
type DatabaseBackupUploadDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiDownload, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
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) => {
|
||||
const Download: ActionItem = {
|
||||
title: $t('download'),
|
||||
|
||||
@@ -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 recentAlbumsDropdown = persisted<boolean>('recent-albums-open', true, {});
|
||||
|
||||
export const hideBackupsReminder = persisted<boolean>('hide-backups-reminder', false, {});
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { languageManager } from '#lib/managers/language-manager.svelte.ts';
|
||||
import { readPreloadedQuery } from '#lib/queries/client.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 { convertBCP47 } from '#lib/utils/i18n.ts';
|
||||
import { maintenanceCreateUrl, maintenanceReturnUrl, maintenanceShouldRedirect } from '#lib/utils/maintenance.ts';
|
||||
import { configureYucca } from '@futo-org/backups-orchestrator-ui';
|
||||
import { commandPaletteManager } from '@immich/ui';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
@@ -11,6 +15,8 @@ export const ssr = false;
|
||||
export const csr = true;
|
||||
|
||||
export const load = (async ({ fetch, url }) => {
|
||||
configureYucca({ api: 'orchestrator', language: convertBCP47(get(lang)) });
|
||||
|
||||
let error;
|
||||
try {
|
||||
await init(fetch);
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
required={true}
|
||||
label={$t('admin.backup_keep_last_amount')}
|
||||
disabled={disabled || !configToEdit.backup.database.enabled}
|
||||
{disabled}
|
||||
bind:value={configToEdit.backup.database.keepLastAmount}
|
||||
isEdited={configToEdit.backup.database.keepLastAmount !== config.backup.database.keepLastAmount}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ enum LinkTarget {
|
||||
UNSUBSCRIBE = 'unsubscribe',
|
||||
VIEW_ASSET = 'view_asset',
|
||||
ACTIVATE_LICENSE = 'activate_license',
|
||||
BACKUPS = 'backups',
|
||||
}
|
||||
|
||||
export const load = (({ url }) => {
|
||||
@@ -22,6 +23,10 @@ export const load = (({ url }) => {
|
||||
return redirect(307, Route.userSettings({ isOpen: OpenQueryParam.NOTIFICATIONS }));
|
||||
}
|
||||
|
||||
case LinkTarget.BACKUPS: {
|
||||
return redirect(307, Route.backups());
|
||||
}
|
||||
|
||||
case LinkTarget.VIEW_ASSET: {
|
||||
const id = queryParams.get('id');
|
||||
if (id) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { maintenanceStore } from '#lib/stores/maintenance.store.ts';
|
||||
import { MaintenanceAction } from '@immich/sdk';
|
||||
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 type { PageData } from './$types';
|
||||
|
||||
@@ -30,6 +31,11 @@
|
||||
action: MaintenanceAction.End,
|
||||
});
|
||||
|
||||
const startRestore = () =>
|
||||
handleSetMaintenanceMode({
|
||||
action: MaintenanceAction.SelectDatabaseRestore,
|
||||
});
|
||||
|
||||
const error = $derived(
|
||||
$status?.error
|
||||
?.split('\n')
|
||||
@@ -38,58 +44,79 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<AuthPageLayout
|
||||
withHeader={$status?.action === MaintenanceAction.Start || $status?.action === MaintenanceAction.End}
|
||||
withBackdrop={$status?.action === MaintenanceAction.Start}
|
||||
>
|
||||
<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}
|
||||
<Scrollable class="max-h-80">
|
||||
<pre class="text-left text-sm"><code>{error}</code></pre>
|
||||
</Scrollable>
|
||||
<Button onclick={end}>{$t('maintenance_end')}</Button>
|
||||
{#snippet errorLog()}
|
||||
<Scrollable class="max-h-80">
|
||||
<pre class="text-left text-sm"><code>{error}</code></pre>
|
||||
</Scrollable>
|
||||
<Button onclick={end}>{$t('maintenance_end')}</Button>
|
||||
{/snippet}
|
||||
|
||||
<YuccaContext>
|
||||
<AuthPageLayout
|
||||
withHeader={$status?.action === MaintenanceAction.Start || $status?.action === MaintenanceAction.End}
|
||||
withBackdrop={$status?.action === MaintenanceAction.Start}
|
||||
>
|
||||
<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}
|
||||
<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.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}
|
||||
<Heading size="large" color="primary" tag="h1">{$t('maintenance_title')}</Heading>
|
||||
<p>
|
||||
{$t('maintenance_logged_in_as', {
|
||||
values: {
|
||||
user: $auth.username,
|
||||
},
|
||||
})}
|
||||
<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>
|
||||
<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}
|
||||
</div>
|
||||
</AuthPageLayout>
|
||||
</div>
|
||||
</AuthPageLayout>
|
||||
</YuccaContext>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import RestoreFlowAutoSelectBackup from './RestoreFlowAutoSelectBackup.svelte';
|
||||
import RestoreFlowDetectInstall from './RestoreFlowDetectInstall.svelte';
|
||||
import RestoreFlowIntro from './RestoreFlowIntro.svelte';
|
||||
import RestoreFlowSelectBackup from './RestoreFlowSelectBackup.svelte';
|
||||
import { ImmichOnboardingRestoreFlow } from '@futo-org/backups-orchestrator-ui';
|
||||
|
||||
type Props = {
|
||||
end: () => void;
|
||||
@@ -9,11 +12,31 @@
|
||||
|
||||
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>
|
||||
|
||||
{#if stage === 0}
|
||||
<RestoreFlowDetectInstall next={() => stage++} {end} />
|
||||
{:else}
|
||||
<RestoreFlowSelectBackup previous={() => stage--} {end} {expectedVersion} />
|
||||
{#if stage === 'overview'}
|
||||
<RestoreFlowIntro
|
||||
flowToFutoBackups={() => (stage = 'futo-backups')}
|
||||
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}
|
||||
|
||||
@@ -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 = {
|
||||
next: () => void;
|
||||
end: () => void;
|
||||
previous: () => void;
|
||||
};
|
||||
|
||||
const { next, end }: Props = $props();
|
||||
const { next, previous }: Props = $props();
|
||||
|
||||
let detectedInstall: MaintenanceDetectInstallResponseDto | undefined = $state();
|
||||
|
||||
@@ -92,6 +92,6 @@
|
||||
</div>
|
||||
<Text>{$t('maintenance_restore_library_confirm')}</Text>
|
||||
<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>
|
||||
</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>
|
||||
@@ -133,5 +133,10 @@ export default defineConfig({
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
server: {
|
||||
deps: {
|
||||
inline: ['@futo-org/backups-orchestrator-ui', '@material-symbols/svg-400'],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as UserConfig);
|
||||
|
||||
Reference in New Issue
Block a user