feat(yucca sdk): warn users if immich db dumps enabled (#703)

* feat(yucca sdk): add hooks for Immich database dump config

* feat(yucca sdk): warn users when immich db dump in use
This commit is contained in:
Paul Makles
2026-09-23 11:34:24 +01:00
committed by GitHub
parent d88d888c01
commit 5efc3664a4
15 changed files with 293 additions and 9 deletions
@@ -24,6 +24,10 @@ async function bootstrap() {
return 'dev-database-backup.sql';
},
async cleanupDatabaseBackups() {},
async getImmichDatabaseDumpConfig() {
return { enabled: true, keepLastAmount: 14 };
},
async configureImmichDatabaseDump() {},
async enterMaintenanceRollback() {
return {
jwt: 'abc',
@@ -317,6 +317,44 @@
]
}
},
"/api/yucca/integrations/immich/database-dump/ignore-warning": {
"post": {
"operationId": "ignoreImmichDatabaseDumpWarning",
"parameters": [],
"responses": {
"201": {
"description": ""
}
},
"tags": [
"Integrations"
]
}
},
"/api/yucca/integrations/immich/database-dump": {
"post": {
"operationId": "configureImmichDatabaseDump",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigureImmichDatabaseDumpRequestDto"
}
}
}
},
"responses": {
"201": {
"description": ""
}
},
"tags": [
"Integrations"
]
}
},
"/api/yucca/integrations/immich/rollback": {
"post": {
"operationId": "startImmichRollback",
@@ -1921,6 +1959,23 @@
"forget"
]
},
"ImmichDatabaseDumpConfigDto": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"keepLastAmount": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
}
},
"required": [
"enabled",
"keepLastAmount"
]
},
"ImmichBackupStatusDto": {
"type": "object",
"properties": {
@@ -1938,6 +1993,12 @@
},
"latestBackupRun": {
"$ref": "#/components/schemas/RunDto"
},
"databaseDump": {
"$ref": "#/components/schemas/ImmichDatabaseDumpConfigDto"
},
"databaseDumpWarningIgnored": {
"type": "boolean"
}
}
},
@@ -2013,6 +2074,19 @@
"repositoryId"
]
},
"ConfigureImmichDatabaseDumpRequestDto": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"keepLastAmount": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
}
}
},
"ImmichRollbackRequestDto": {
"type": "object",
"properties": {
@@ -1,6 +1,7 @@
import { Body, Controller, Get, Post, Res } from '@nestjs/common';
import { ApiCreatedResponse, ApiOkResponse } from '@nestjs/swagger';
import {
ConfigureImmichDatabaseDumpRequestDto,
ConfigureImmichIntegrationRequestDto,
ConfigureImmichIntegrationResponseDto,
ImmichBackupStatusDto,
@@ -36,6 +37,16 @@ export class IntegrationsController {
return this.service.configureImmichIntegration(dto);
}
@Post('immich/database-dump/ignore-warning')
ignoreImmichDatabaseDumpWarning(): Promise<void> {
return this.service.ignoreImmichDatabaseDumpWarning();
}
@Post('immich/database-dump')
configureImmichDatabaseDump(@Body() dto: ConfigureImmichDatabaseDumpRequestDto): Promise<void> {
return this.service.configureImmichDatabaseDump(dto);
}
@Post('immich/rollback')
async startImmichRollback(@Body() dto: ImmichRollbackRequestDto, @Res({ passthrough: true }) response: Response) {
const { jwt } = await this.service.enterImmichMaintenanceRollback(dto);
@@ -46,6 +46,13 @@ const IntegrationsResponseSchema = z
})
.meta({ id: 'IntegrationsResponseDto' });
const ImmichDatabaseDumpConfigSchema = z
.object({
enabled: z.boolean(),
keepLastAmount: z.int().min(1),
})
.meta({ id: 'ImmichDatabaseDumpConfigDto' });
const ImmichBackupStatusSchema = z
.object({
integration: ImmichIntegrationSchema.optional(),
@@ -53,6 +60,8 @@ const ImmichBackupStatusSchema = z
backend: BackendSchema.optional(),
schedule: ScheduleSchema.optional(),
latestBackupRun: RunSchema.optional(),
databaseDump: ImmichDatabaseDumpConfigSchema.optional(),
databaseDumpWarningIgnored: z.boolean().optional(),
})
.meta({ id: 'ImmichBackupStatusDto' });
@@ -76,6 +85,10 @@ const ConfigureImmichIntegrationResponseSchema = z
})
.meta({ id: 'ConfigureImmichIntegrationResponseDto' });
const ConfigureImmichDatabaseDumpRequestSchema = ImmichDatabaseDumpConfigSchema.partial().meta({
id: 'ConfigureImmichDatabaseDumpRequestDto',
});
const ImmichRollbackRequestSchema = z
.object({
repositoryId: z.string(),
@@ -92,4 +105,6 @@ export class IntegrationsResponseDto extends createZodDto(IntegrationsResponseSc
export class ImmichBackupStatusDto extends createZodDto(ImmichBackupStatusSchema) {}
export class ConfigureImmichIntegrationRequestDto extends createZodDto(ConfigureImmichIntegrationRequestSchema) {}
export class ConfigureImmichIntegrationResponseDto extends createZodDto(ConfigureImmichIntegrationResponseSchema) {}
export class ImmichDatabaseDumpConfigDto extends createZodDto(ImmichDatabaseDumpConfigSchema) {}
export class ConfigureImmichDatabaseDumpRequestDto extends createZodDto(ConfigureImmichDatabaseDumpRequestSchema) {}
export class ImmichRollbackRequestDto extends createZodDto(ImmichRollbackRequestSchema) {}
@@ -22,6 +22,7 @@ export enum ConfigurationKey {
OnboardedKey = 'onboarded-key',
Telemetry = 'telemetry',
SkippedOnboardingExtraConfig = 'skipped-onboarding-extra-config',
IgnoredImmichDatabaseDumpWarning = 'ignored-immich-database-dump-warning',
ResticOptionRestConnections = 'restic-o-rest-connections',
SessionSecret = 'session-secret',
StatePath = 'state-path',
@@ -1,5 +1,6 @@
export * from './const';
export { EventsGateway, type GatewayEvent } from './events/events.gateway';
export type { ImmichDatabaseDumpConfig } from './moduleConfig';
export * from './orchestrationApi.module';
export { LoggingRepository } from './repositories/logging.repository';
export { ModuleConfigRepository } from './repositories/moduleConfig.repository';
@@ -7,6 +7,11 @@ export const ModuleConfigProvider = Symbol('ModuleConfig');
export type AuthenticatedUser = { user: { isAdmin: boolean } };
export type AuthenticateFn = (client: Socket) => Promise<AuthenticatedUser>;
export type ImmichDatabaseDumpConfig = {
enabled: boolean;
keepLastAmount: number;
};
export type ImmichIntegration = {
dataPath: string;
dataFolders: string[];
@@ -19,6 +24,8 @@ export type ImmichIntegration = {
hooks: {
createDatabaseBackup(): Promise<string>;
cleanupDatabaseBackups(): Promise<void>;
getImmichDatabaseDumpConfig(): Promise<ImmichDatabaseDumpConfig>;
configureImmichDatabaseDump(config: Partial<ImmichDatabaseDumpConfig>): Promise<void>;
enterMaintenanceRollback(
repositoryId: string,
snapshotId: string,
@@ -188,6 +188,14 @@ export class ConfigRepository {
return this.set(ConfigurationKey.SkippedOnboardingExtraConfig, '1');
}
async hasIgnoredImmichDatabaseDumpWarning() {
return this.has(ConfigurationKey.IgnoredImmichDatabaseDumpWarning);
}
async ignoreImmichDatabaseDumpWarning() {
return this.set(ConfigurationKey.IgnoredImmichDatabaseDumpWarning, '1');
}
async hasSessionSecret() {
return this.has(ConfigurationKey.SessionSecret);
}
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { OnEvent } from '@nestjs/event-emitter';
import { join } from 'node:path';
import {
ConfigureImmichDatabaseDumpRequestDto,
ConfigureImmichIntegrationRequestDto,
ImmichBackupStatusDto,
ImmichRollbackRequestDto,
@@ -10,6 +11,7 @@ import {
import { InternalEvent, TaskType } from '../enum';
import { EventsGateway } from '../events/events.gateway';
import type { ImmichIntegration, ModuleConfig } from '../moduleConfig';
import { ConfigRepository } from '../repositories/config.repository';
import { LockRepository } from '../repositories/lock.repository';
import { ModuleConfigRepository } from '../repositories/moduleConfig.repository';
import { RepositoryRepository } from '../repositories/repository.repository';
@@ -29,6 +31,7 @@ export class IntegrationsService {
private readonly backendService: BackendService,
private readonly events: EventsGateway,
private readonly moduleConfig: ModuleConfigRepository,
private readonly config: ConfigRepository,
private readonly repositoryIntegrationImmich: RepositoryIntegrationImmichRepository,
private readonly repositoryPath: RepositoryPathRepository,
private readonly repositoryService: RepositoryService,
@@ -63,17 +66,21 @@ export class IntegrationsService {
}
async getImmichBackupStatus(): Promise<ImmichBackupStatusDto> {
const { immichIntegration } = this.moduleConfig.get();
const integration = await this.repositoryIntegrationImmich.get();
if (!integration) {
if (!integration || !immichIntegration) {
return {};
}
const [{ repositories }, { backends }, schedule, latestBackupRun] = await Promise.all([
this.repositoryService.getRepositories(),
this.backendService.getBackends(),
this.schedule.get(integration.scheduleId),
this.runHistory.getLatest(integration.id, TaskType.Backup),
]);
const [{ repositories }, { backends }, schedule, latestBackupRun, databaseDump, databaseDumpWarningIgnored] =
await Promise.all([
this.repositoryService.getRepositories(),
this.backendService.getBackends(),
this.schedule.get(integration.scheduleId),
this.runHistory.getLatest(integration.id, TaskType.Backup),
immichIntegration.hooks.getImmichDatabaseDumpConfig(),
this.config.hasIgnoredImmichDatabaseDumpWarning(),
]);
const repository = repositories.find((entry) => entry.id === integration.id);
@@ -83,6 +90,8 @@ export class IntegrationsService {
backend: backends.find((entry) => entry.id === repository?.backends?.primary.id),
schedule,
latestBackupRun,
databaseDump,
databaseDumpWarningIgnored,
};
}
@@ -164,6 +173,19 @@ export class IntegrationsService {
});
}
async ignoreImmichDatabaseDumpWarning(): Promise<void> {
await this.config.ignoreImmichDatabaseDumpWarning();
}
async configureImmichDatabaseDump(dto: ConfigureImmichDatabaseDumpRequestDto): Promise<void> {
const { immichIntegration } = this.moduleConfig.get();
if (!immichIntegration) {
throw new BadRequestException('Immich integration is not enabled.');
}
await immichIntegration.hooks.configureImmichDatabaseDump(dto);
}
async enterImmichMaintenanceRollback(dto: ImmichRollbackRequestDto): Promise<{ jwt: string }> {
const { immichIntegration } = this.moduleConfig.get();
if (!immichIntegration) {
@@ -155,6 +155,10 @@ describe('Integrations', () => {
moduleConfig.update({ immichIntegration: undefined });
});
const hooks = {
getImmichDatabaseDumpConfig: jest.fn().mockResolvedValue({ enabled: true, keepLastAmount: 14 }),
configureImmichDatabaseDump: jest.fn(),
};
it('pauses and resumes the managed schedule', async () => {
const moduleConfig = ctx.module.get(ModuleConfigRepository);
const integrationsService = ctx.module.get(IntegrationsService);
@@ -166,6 +170,7 @@ describe('Integrations', () => {
dataPath: '/data/immich',
dataFolders: ['upload'],
libraries: [],
hooks: hooks as never,
},
});
@@ -189,6 +194,16 @@ describe('Integrations', () => {
expect((await getSchedule())?.paused).toBe(false);
const status = await integrationsService.getImmichBackupStatus();
expect(status.databaseDump).toEqual({ enabled: true, keepLastAmount: 14 });
await integrationsService.configureImmichDatabaseDump({ enabled: false });
expect(hooks.configureImmichDatabaseDump).toHaveBeenCalledWith({ enabled: false });
expect(status.databaseDumpWarningIgnored).toBe(false);
await integrationsService.ignoreImmichDatabaseDumpWarning();
expect((await integrationsService.getImmichBackupStatus()).databaseDumpWarningIgnored).toBe(true);
await integrationsService.configureImmichIntegration({ ...configuration, paused: true });
expect((await getSchedule())?.paused).toBe(true);
@@ -7,6 +7,7 @@
} from "$lib/services/immich.integration.service";
import { HStack, Icon, LoadingSpinner, Text } from "@immich/ui";
import {
mdiAlertCircleOutline,
mdiChevronRight,
mdiCloudAlertOutline,
mdiCloudCheckVariantOutline,
@@ -29,6 +30,10 @@
const configured = $derived(status.kind !== "unconfigured");
const appearance = $derived.by(() => {
if (backup.needsAttention) {
return { color: "warning", icon: mdiAlertCircleOutline } as const;
}
switch (status.kind) {
case "loading": {
return { color: "secondary", icon: undefined } as const;
@@ -85,7 +90,9 @@
{/if}
<Text size="tiny" class="flex-1 leading-tight">
{#if status.kind === "loading"}
{#if backup.needsAttention}
Needs attention
{:else if status.kind === "loading"}
Checking backup status
{:else if status.kind === "offline"}
Backup service is offline
@@ -0,0 +1,42 @@
<script lang="ts">
import {
useConfigureImmichDatabaseDump,
useIgnoreImmichDatabaseDumpWarning,
useImmichBackupStatus,
} from "$lib/services/immich.integration.service";
import { Alert, Button, HStack, Stack, Text } from "@immich/ui";
const backup = useImmichBackupStatus();
const configure = useConfigureImmichDatabaseDump();
const ignore = useIgnoreImmichDatabaseDumpWarning();
</script>
{#if backup.needsAttention}
<Alert color="warning" title="Immich database backups are still enabled">
<Stack gap={3}>
<Text size="small">
FUTO Backups already creates the same database backups as Immich.
<br />
Most users should select "use FUTO Backups only".
</Text>
<HStack gap={2}>
<Button
size="small"
color="primary"
loading={configure.isPending}
onclick={() => configure.mutate({ enabled: false })}
>
Use FUTO Backups only
</Button>
<Button
size="small"
variant="ghost"
loading={ignore.isPending}
onclick={() => ignore.mutate()}
>
Ignore
</Button>
</HStack>
</Stack>
</Alert>
{/if}
@@ -10,6 +10,7 @@
useImmichBackupStatusEventHandler,
} from "$lib/services/immich.integration.service";
import { Container, Stack } from "@immich/ui";
import ImmichDatabaseDumpAlert from "./ImmichDatabaseDumpAlert.svelte";
import ImmichManageBackupOverview from "./ImmichManageBackupOverview.svelte";
type Props = {
@@ -35,6 +36,7 @@
<Container size="medium" center>
{#if repository && schedule}
<Stack class="mt-4" gap={6}>
<ImmichDatabaseDumpAlert />
<ImmichManageBackupOverview {repository} {schedule} status={backup.status} />
<BackendsList {repository} />
<RepositoryRunHistory {repository} onViewAll={onViewAttempts} />
@@ -161,12 +161,18 @@ export type RunDto = {
status: RunStatus;
"type": RunType;
};
export type ImmichDatabaseDumpConfigDto = {
enabled: boolean;
keepLastAmount: number;
};
export type ImmichBackupStatusDto = {
integration?: ImmichIntegrationDto;
repository?: LocalRepositoryDto;
backend?: BackendDto;
schedule?: ScheduleDto;
latestBackupRun?: RunDto;
databaseDump?: ImmichDatabaseDumpConfigDto;
databaseDumpWarningIgnored?: boolean;
};
export type ConfigureImmichIntegrationRequestDto = {
name: string;
@@ -182,6 +188,10 @@ export type ConfigureImmichIntegrationRequestDto = {
export type ConfigureImmichIntegrationResponseDto = {
repositoryId: string;
};
export type ConfigureImmichDatabaseDumpRequestDto = {
enabled?: boolean;
keepLastAmount?: number;
};
export type ImmichRollbackRequestDto = {
repositoryId: string;
snapshotId: string;
@@ -435,6 +445,19 @@ export function configureImmichIntegration(configureImmichIntegrationRequestDto:
body: configureImmichIntegrationRequestDto
})));
}
export function ignoreImmichDatabaseDumpWarning(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/api/yucca/integrations/immich/database-dump/ignore-warning", {
...opts,
method: "POST"
}));
}
export function configureImmichDatabaseDump(configureImmichDatabaseDumpRequestDto: ConfigureImmichDatabaseDumpRequestDto, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/api/yucca/integrations/immich/database-dump", oazapfts.json({
...opts,
method: "POST",
body: configureImmichDatabaseDumpRequestDto
})));
}
export function startImmichRollback(immichRollbackRequestDto: ImmichRollbackRequestDto, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/api/yucca/integrations/immich/rollback", oazapfts.json({
...opts,
@@ -2,12 +2,16 @@ import BackupsRecoveryKeyModal from '$lib/components/onboarding/dialogs/BackupsR
import { modalManager, type ActionItem } from '@immich/ui';
import { mdiCloudUploadOutline, mdiCogOutline, mdiKeyOutline } from '@mdi/js';
import {
configureImmichDatabaseDump,
type ConfigureImmichDatabaseDumpRequestDto,
getImmichBackupStatus,
ignoreImmichDatabaseDumpWarning,
type ImmichBackupStatusDto,
} from '$lib/fetch-client';
import { queryClient } from '$lib/query-client';
import { handleError } from '$lib/utils/handle-error';
import { getBackupOutcome } from '$lib/utils/backup-status';
import { createQuery } from '@tanstack/svelte-query';
import { createMutation, createQuery } from '@tanstack/svelte-query';
import { handleCreateBackup } from './repository.service';
export const getBackupPageActions = (
@@ -45,6 +49,14 @@ const invalidateImmichBackupStatus = () =>
queryKey: immichBackupStatusKeys.all,
});
const updateImmichBackupStatus = (
update: (data: ImmichBackupStatusDto) => ImmichBackupStatusDto,
) =>
queryClient.setQueryData<ImmichBackupStatusDto>(
immichBackupStatusKeys.all,
(data) => data && update(data),
);
export const useImmichBackupStatusEventHandler = () => ({
onIntegrationUpdate: invalidateImmichBackupStatus,
onRepositoryUpdate: invalidateImmichBackupStatus,
@@ -123,5 +135,45 @@ export const useImmichBackupStatus = () => {
get status() {
return toImmichBackupStatus(query.data, query.isLoading);
},
get needsAttention() {
const usingImmichDbDump =
query.data?.databaseDump?.enabled === true &&
query.data.schedule !== undefined &&
!query.data.schedule.paused &&
query.data.databaseDumpWarningIgnored !== true;
return usingImmichDbDump;
},
};
};
export const useConfigureImmichDatabaseDump = () =>
createMutation(
() => ({
mutationFn: (dto: ConfigureImmichDatabaseDumpRequestDto) =>
configureImmichDatabaseDump(dto),
onSuccess: (_, dto) =>
updateImmichBackupStatus((data) => ({
...data,
databaseDump: data.databaseDump && { ...data.databaseDump, ...dto },
})),
onError: (error) =>
handleError(error, 'Failed to update Immich database dump settings'),
}),
() => queryClient,
);
export const useIgnoreImmichDatabaseDumpWarning = () =>
createMutation(
() => ({
mutationFn: () => ignoreImmichDatabaseDumpWarning(),
onSuccess: () =>
updateImmichBackupStatus((data) => ({
...data,
databaseDumpWarningIgnored: true,
})),
onError: (error) =>
handleError(error, 'Failed to ignore Immich database dump warning'),
}),
() => queryClient,
);