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