diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 5c9909954c..b9f7155fe0 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -1,9 +1,9 @@ { "features": { - "ghcr.io/devcontainers/features/docker-in-docker:2": { - "version": "2.17.0", - "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c", - "integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c" + "ghcr.io/devcontainers/features/docker-in-docker:4": { + "version": "4.0.0", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:4fa87399214366e320d489991769c4f3f461e1ffe461f54eea78a41b34945bb5", + "integrity": "sha256:4fa87399214366e320d489991769c4f3f461e1ffe461f54eea78a41b34945bb5" } } } diff --git a/e2e/package.json b/e2e/package.json index 7b437af114..3f342f22f2 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -26,7 +26,7 @@ "devDependencies": { "@eslint/js": "^10.0.0", "@faker-js/faker": "^10.1.0", - "@futo-org/backups-orchestrator-ui": "0.12.0", + "@futo-org/backups-orchestrator-ui": "0.19.3", "@immich/cli": "workspace:*", "@immich/e2e-auth-server": "workspace:*", "@immich/sdk": "workspace:*", diff --git a/i18n/en.json b/i18n/en.json index de50c5926f..d67c88abd7 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1337,6 +1337,7 @@ "main_branch_warning": "You're using a development version; we strongly recommend using a release version!", "main_menu": "Main menu", "maintenance_action_restore": "Restoring Database", + "maintenance_action_rollback": "Restoring from Backup", "maintenance_description": "Immich has been put into maintenance mode.", "maintenance_end": "End maintenance mode", "maintenance_logged_in_as": "Currently logged in as {user}", diff --git a/mise.lock b/mise.lock index 4418f81956..c007db57eb 100644 --- a/mise.lock +++ b/mise.lock @@ -82,35 +82,6 @@ url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133" version = "7.1.3-6" backend = "github:jellyfin/jellyfin-ffmpeg" -[tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" - -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"] -checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838" - -[[tools."github:jellyfin/jellyfin-ffmpeg"]] -version = "7.1.3-6" -backend = "github:jellyfin/jellyfin-ffmpeg" - -[tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" - -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"] -checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" - -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"] -checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" - -[[tools."github:jellyfin/jellyfin-ffmpeg"]] -version = "7.1.3-6" -backend = "github:jellyfin/jellyfin-ffmpeg" - [tools."github:jellyfin/jellyfin-ffmpeg".options] asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" @@ -124,30 +95,6 @@ checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21 url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz" url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879" -[[tools."github:jellyfin/jellyfin-ffmpeg"]] -version = "7.1.3-6" -backend = "github:jellyfin/jellyfin-ffmpeg" - -[tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" - -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"] -checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889" - -[[tools."github:jellyfin/jellyfin-ffmpeg"]] -version = "7.1.3-6" -backend = "github:jellyfin/jellyfin-ffmpeg" - -[tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" - -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"] -checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838" - [[tools."github:webassembly/binaryen"]] version = "version_124" backend = "github:webassembly/binaryen" diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index a9f3c76f64..b231bd9616 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,13 +3,13 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 3.0.0-rc.2 -- Generator version: 7.22.0 +- API version: 3.0.3 +- Generator version: 7.24.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements -Dart 2.12 or later +Dart 2.17 or later ## Installation & Usage @@ -179,6 +179,7 @@ Class | Method | HTTP request | Description *FilesystemApi* | [**getFileListing**](doc//FilesystemApi.md#getfilelisting) | **GET** /yucca/fs | *IntegrationsApi* | [**configureImmichIntegration**](doc//IntegrationsApi.md#configureimmichintegration) | **POST** /yucca/integrations/immich | *IntegrationsApi* | [**getIntegrations**](doc//IntegrationsApi.md#getintegrations) | **GET** /yucca/integrations | +*IntegrationsApi* | [**startImmichRollback**](doc//IntegrationsApi.md#startimmichrollback) | **POST** /yucca/integrations/immich/rollback | *JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | Create a manual job *JobsApi* | [**getQueuesLegacy**](doc//JobsApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status *JobsApi* | [**runQueueCommandLegacy**](doc//JobsApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs @@ -503,10 +504,12 @@ Class | Method | HTTP request | Description - [FilesystemListingResponseDto](doc//FilesystemListingResponseDto.md) - [FoldersResponse](doc//FoldersResponse.md) - [FoldersUpdate](doc//FoldersUpdate.md) + - [HlsVideoResolution](doc//HlsVideoResolution.md) - [ImageFormat](doc//ImageFormat.md) - [ImmichIntegrationConfigurationDto](doc//ImmichIntegrationConfigurationDto.md) - [ImmichIntegrationDto](doc//ImmichIntegrationDto.md) - [ImmichLibraryDto](doc//ImmichLibraryDto.md) + - [ImmichRollbackRequestDto](doc//ImmichRollbackRequestDto.md) - [ImmichStateDto](doc//ImmichStateDto.md) - [ImportRecoveryKeyRequest](doc//ImportRecoveryKeyRequest.md) - [InspectedLocalRepositoryDto](doc//InspectedLocalRepositoryDto.md) @@ -607,6 +610,8 @@ Class | Method | HTTP request | Description - [RatingsUpdate](doc//RatingsUpdate.md) - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) + - [RecentlyAddedResponse](doc//RecentlyAddedResponse.md) + - [RecentlyAddedUpdate](doc//RecentlyAddedUpdate.md) - [ReleaseChannel](doc//ReleaseChannel.md) - [ReleaseEventV1](doc//ReleaseEventV1.md) - [ReleaseType](doc//ReleaseType.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 7b771bc599..6ca7433987 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -196,10 +196,12 @@ part 'model/filesystem_listing_item_dto.dart'; part 'model/filesystem_listing_response_dto.dart'; part 'model/folders_response.dart'; part 'model/folders_update.dart'; +part 'model/hls_video_resolution.dart'; part 'model/image_format.dart'; part 'model/immich_integration_configuration_dto.dart'; part 'model/immich_integration_dto.dart'; part 'model/immich_library_dto.dart'; +part 'model/immich_rollback_request_dto.dart'; part 'model/immich_state_dto.dart'; part 'model/import_recovery_key_request.dart'; part 'model/inspected_local_repository_dto.dart'; @@ -300,6 +302,8 @@ part 'model/ratings_response.dart'; part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; +part 'model/recently_added_response.dart'; +part 'model/recently_added_update.dart'; part 'model/release_channel.dart'; part 'model/release_event_v1.dart'; part 'model/release_type.dart'; diff --git a/mobile/openapi/lib/api/integrations_api.dart b/mobile/openapi/lib/api/integrations_api.dart index 3f3667fb96..1906a0694c 100644 --- a/mobile/openapi/lib/api/integrations_api.dart +++ b/mobile/openapi/lib/api/integrations_api.dart @@ -97,4 +97,44 @@ class IntegrationsApi { } return null; } + + /// Performs an HTTP 'POST /yucca/integrations/immich/rollback' operation and returns the [Response]. + /// Parameters: + /// + /// * [ImmichRollbackRequestDto] immichRollbackRequestDto (required): + Future startImmichRollbackWithHttpInfo(ImmichRollbackRequestDto immichRollbackRequestDto, { Future? abortTrigger, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/yucca/integrations/immich/rollback'; + + // ignore: prefer_final_locals + Object? postBody = immichRollbackRequestDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + abortTrigger: abortTrigger, + ); + } + + /// Parameters: + /// + /// * [ImmichRollbackRequestDto] immichRollbackRequestDto (required): + Future startImmichRollback(ImmichRollbackRequestDto immichRollbackRequestDto, { Future? abortTrigger, }) async { + final response = await startImmichRollbackWithHttpInfo(immichRollbackRequestDto, abortTrigger: abortTrigger,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 59ca26ec7e..60213a9b28 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -97,9 +97,9 @@ class ApiClient { if (nullableHeaderParams != null) { request.headers.addAll(nullableHeaderParams); } - if (msgBody is String) { + if (msgBody is String && msgBody.isNotEmpty) { request.body = msgBody; - } else if (msgBody is List) { + } else if (msgBody is List && msgBody.isNotEmpty) { request.bodyBytes = msgBody; } else if (msgBody is Map) { request.bodyFields = msgBody; @@ -417,6 +417,8 @@ class ApiClient { return FoldersResponse.fromJson(value); case 'FoldersUpdate': return FoldersUpdate.fromJson(value); + case 'HlsVideoResolution': + return HlsVideoResolutionTypeTransformer().decode(value); case 'ImageFormat': return ImageFormatTypeTransformer().decode(value); case 'ImmichIntegrationConfigurationDto': @@ -425,6 +427,8 @@ class ApiClient { return ImmichIntegrationDto.fromJson(value); case 'ImmichLibraryDto': return ImmichLibraryDto.fromJson(value); + case 'ImmichRollbackRequestDto': + return ImmichRollbackRequestDto.fromJson(value); case 'ImmichStateDto': return ImmichStateDto.fromJson(value); case 'ImportRecoveryKeyRequest': @@ -625,6 +629,10 @@ class ApiClient { return ReactionLevelTypeTransformer().decode(value); case 'ReactionType': return ReactionTypeTypeTransformer().decode(value); + case 'RecentlyAddedResponse': + return RecentlyAddedResponse.fromJson(value); + case 'RecentlyAddedUpdate': + return RecentlyAddedUpdate.fromJson(value); case 'ReleaseChannel': return ReleaseChannelTypeTransformer().decode(value); case 'ReleaseEventV1': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 0c21f01afb..0d8949e7c5 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -112,6 +112,9 @@ String parameterToString(dynamic value) { if (value is Colorspace) { return ColorspaceTypeTransformer().encode(value).toString(); } + if (value is HlsVideoResolution) { + return HlsVideoResolutionTypeTransformer().encode(value).toString(); + } if (value is ImageFormat) { return ImageFormatTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/backend_type.dart b/mobile/openapi/lib/model/backend_type.dart index cacf63ae17..d056c6cdc7 100644 --- a/mobile/openapi/lib/model/backend_type.dart +++ b/mobile/openapi/lib/model/backend_type.dart @@ -11,31 +11,30 @@ part of openapi.api; -class BackendType { - /// Instantiate a new enum with the provided [value]. - const BackendType._(this.value); +enum BackendType { + yucca._(r'yucca'), + local._(r'local'), + s3._(r's3'), + ; + + /// Instantiate a new enum with the provided value. + const BackendType._(this._value); /// The underlying value of this enum member. - final String value; + final String _value; @override - String toString() => value; + String toString() => _value; - String toJson() => value; - - static const yucca = BackendType._(r'yucca'); - static const local = BackendType._(r'local'); - static const s3 = BackendType._(r's3'); - - /// List of all possible values in this [enum][BackendType]. - static const values = [ - yucca, - local, - s3, - ]; + /// Encodes this enum as a value suitable for JSON. + String toJson() => _value; + /// Returns the instance of [BackendType] that was successfully decoded + /// from the passed [value] on success, null otherwise. static BackendType? fromJson(dynamic value) => BackendTypeTypeTransformer().decode(value); + /// Returns a [List] containing instances of [BackendType] + /// that were successfully decoded from the passed [JSON][json]. static List listFromJson(dynamic json, {bool growable = false,}) { final result = []; if (json is List && json.isNotEmpty) { @@ -57,9 +56,11 @@ class BackendTypeTypeTransformer { const BackendTypeTypeTransformer._(); - String encode(BackendType data) => data.value; + /// Encodes this enum as a value suitable for JSON. + String encode(BackendType data) => data._value; - /// Decodes a [dynamic value][data] to a BackendType. + /// Returns the instance of [BackendType] that was successfully decoded + /// from the passed [data] value on success, null otherwise. /// /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] @@ -68,6 +69,9 @@ class BackendTypeTypeTransformer { /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, /// and users are still using an old app with the old code. BackendType? decode(dynamic data, {bool allowNull = true}) { + if (data is BackendType) { + return data; + } if (data != null) { switch (data) { case r'yucca': return BackendType.yucca; @@ -82,7 +86,7 @@ class BackendTypeTypeTransformer { return null; } - /// Singleton [BackendTypeTypeTransformer] instance. + /// The singleton instance of this transformer. static BackendTypeTypeTransformer? _instance; } diff --git a/mobile/openapi/lib/model/bootstrap_status.dart b/mobile/openapi/lib/model/bootstrap_status.dart index 8a2ae0a7ec..991400d651 100644 --- a/mobile/openapi/lib/model/bootstrap_status.dart +++ b/mobile/openapi/lib/model/bootstrap_status.dart @@ -11,31 +11,30 @@ part of openapi.api; -class BootstrapStatus { - /// Instantiate a new enum with the provided [value]. - const BootstrapStatus._(this.value); +enum BootstrapStatus { + notReady._(r'not-ready'), + ready._(r'ready'), + error._(r'error'), + ; + + /// Instantiate a new enum with the provided value. + const BootstrapStatus._(this._value); /// The underlying value of this enum member. - final String value; + final String _value; @override - String toString() => value; + String toString() => _value; - String toJson() => value; - - static const notReady = BootstrapStatus._(r'not-ready'); - static const ready = BootstrapStatus._(r'ready'); - static const error = BootstrapStatus._(r'error'); - - /// List of all possible values in this [enum][BootstrapStatus]. - static const values = [ - notReady, - ready, - error, - ]; + /// Encodes this enum as a value suitable for JSON. + String toJson() => _value; + /// Returns the instance of [BootstrapStatus] that was successfully decoded + /// from the passed [value] on success, null otherwise. static BootstrapStatus? fromJson(dynamic value) => BootstrapStatusTypeTransformer().decode(value); + /// Returns a [List] containing instances of [BootstrapStatus] + /// that were successfully decoded from the passed [JSON][json]. static List listFromJson(dynamic json, {bool growable = false,}) { final result = []; if (json is List && json.isNotEmpty) { @@ -57,9 +56,11 @@ class BootstrapStatusTypeTransformer { const BootstrapStatusTypeTransformer._(); - String encode(BootstrapStatus data) => data.value; + /// Encodes this enum as a value suitable for JSON. + String encode(BootstrapStatus data) => data._value; - /// Decodes a [dynamic value][data] to a BootstrapStatus. + /// Returns the instance of [BootstrapStatus] that was successfully decoded + /// from the passed [data] value on success, null otherwise. /// /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] @@ -68,6 +69,9 @@ class BootstrapStatusTypeTransformer { /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, /// and users are still using an old app with the old code. BootstrapStatus? decode(dynamic data, {bool allowNull = true}) { + if (data is BootstrapStatus) { + return data; + } if (data != null) { switch (data) { case r'not-ready': return BootstrapStatus.notReady; @@ -82,7 +86,7 @@ class BootstrapStatusTypeTransformer { return null; } - /// Singleton [BootstrapStatusTypeTransformer] instance. + /// The singleton instance of this transformer. static BootstrapStatusTypeTransformer? _instance; } diff --git a/mobile/openapi/lib/model/immich_rollback_request_dto.dart b/mobile/openapi/lib/model/immich_rollback_request_dto.dart new file mode 100644 index 0000000000..8c8d35263f --- /dev/null +++ b/mobile/openapi/lib/model/immich_rollback_request_dto.dart @@ -0,0 +1,123 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class ImmichRollbackRequestDto { + /// Returns a new [ImmichRollbackRequestDto] instance. + ImmichRollbackRequestDto({ + this.backupFileName = const Optional.absent(), + required this.repositoryId, + required this.snapshotId, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Optional backupFileName; + + String repositoryId; + + String snapshotId; + + @override + bool operator ==(Object other) => identical(this, other) || other is ImmichRollbackRequestDto && + other.backupFileName == backupFileName && + other.repositoryId == repositoryId && + other.snapshotId == snapshotId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (backupFileName == null ? 0 : backupFileName!.hashCode) + + (repositoryId.hashCode) + + (snapshotId.hashCode); + + @override + String toString() => 'ImmichRollbackRequestDto[backupFileName=$backupFileName, repositoryId=$repositoryId, snapshotId=$snapshotId]'; + + Map toJson() { + final json = {}; + if (this.backupFileName.isPresent) { + final value = this.backupFileName.value; + json[r'backupFileName'] = value; + } + json[r'repositoryId'] = this.repositoryId; + json[r'snapshotId'] = this.snapshotId; + return json; + } + + /// Returns a new [ImmichRollbackRequestDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ImmichRollbackRequestDto? fromJson(dynamic value) { + upgradeDto(value, "ImmichRollbackRequestDto"); + if (value is Map) { + final json = value.cast(); + + return ImmichRollbackRequestDto( + backupFileName: json.containsKey(r'backupFileName') ? Optional.present(mapValueOfType(json, r'backupFileName')) : const Optional.absent(), + repositoryId: mapValueOfType(json, r'repositoryId')!, + snapshotId: mapValueOfType(json, r'snapshotId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ImmichRollbackRequestDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ImmichRollbackRequestDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ImmichRollbackRequestDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ImmichRollbackRequestDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'repositoryId', + 'snapshotId', + }; +} + diff --git a/mobile/openapi/lib/model/maintenance_action.dart b/mobile/openapi/lib/model/maintenance_action.dart index d8dd6b8846..30212f79ae 100644 --- a/mobile/openapi/lib/model/maintenance_action.dart +++ b/mobile/openapi/lib/model/maintenance_action.dart @@ -16,6 +16,7 @@ enum MaintenanceAction { end._(r'end'), selectDatabaseRestore._(r'select_database_restore'), restoreDatabase._(r'restore_database'), + rollback._(r'rollback'), ; /// Instantiate a new enum with the provided value. @@ -79,6 +80,7 @@ class MaintenanceActionTypeTransformer { case r'end': return MaintenanceAction.end; case r'select_database_restore': return MaintenanceAction.selectDatabaseRestore; case r'restore_database': return MaintenanceAction.restoreDatabase; + case r'rollback': return MaintenanceAction.rollback; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/maintenance_status_response_dto.dart b/mobile/openapi/lib/model/maintenance_status_response_dto.dart index 82ad12d340..74b9adbcfb 100644 --- a/mobile/openapi/lib/model/maintenance_status_response_dto.dart +++ b/mobile/openapi/lib/model/maintenance_status_response_dto.dart @@ -18,6 +18,7 @@ class MaintenanceStatusResponseDto { this.error = const Optional.absent(), this.progress = const Optional.absent(), this.task = const Optional.absent(), + this.yuccaLogId = const Optional.absent(), }); MaintenanceAction action; @@ -50,13 +51,23 @@ class MaintenanceStatusResponseDto { /// Optional task; + /// Yucca log ID + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Optional yuccaLogId; + @override bool operator ==(Object other) => identical(this, other) || other is MaintenanceStatusResponseDto && other.action == action && other.active == active && other.error == error && other.progress == progress && - other.task == task; + other.task == task && + other.yuccaLogId == yuccaLogId; @override int get hashCode => @@ -65,10 +76,11 @@ class MaintenanceStatusResponseDto { (active.hashCode) + (error == null ? 0 : error!.hashCode) + (progress == null ? 0 : progress!.hashCode) + - (task == null ? 0 : task!.hashCode); + (task == null ? 0 : task!.hashCode) + + (yuccaLogId == null ? 0 : yuccaLogId!.hashCode); @override - String toString() => 'MaintenanceStatusResponseDto[action=$action, active=$active, error=$error, progress=$progress, task=$task]'; + String toString() => 'MaintenanceStatusResponseDto[action=$action, active=$active, error=$error, progress=$progress, task=$task, yuccaLogId=$yuccaLogId]'; Map toJson() { final json = {}; @@ -86,6 +98,10 @@ class MaintenanceStatusResponseDto { final value = this.task.value; json[r'task'] = value; } + if (this.yuccaLogId.isPresent) { + final value = this.yuccaLogId.value; + json[r'yuccaLogId'] = value; + } return json; } @@ -103,6 +119,7 @@ class MaintenanceStatusResponseDto { error: json.containsKey(r'error') ? Optional.present(mapValueOfType(json, r'error')) : const Optional.absent(), progress: json.containsKey(r'progress') ? Optional.present(json[r'progress'] == null ? null : int.parse('${json[r'progress']}')) : const Optional.absent(), task: json.containsKey(r'task') ? Optional.present(mapValueOfType(json, r'task')) : const Optional.absent(), + yuccaLogId: json.containsKey(r'yuccaLogId') ? Optional.present(mapValueOfType(json, r'yuccaLogId')) : const Optional.absent(), ); } return null; diff --git a/mobile/openapi/lib/model/run_status.dart b/mobile/openapi/lib/model/run_status.dart index 54ae25920e..212d866b04 100644 --- a/mobile/openapi/lib/model/run_status.dart +++ b/mobile/openapi/lib/model/run_status.dart @@ -11,31 +11,30 @@ part of openapi.api; -class RunStatus { - /// Instantiate a new enum with the provided [value]. - const RunStatus._(this.value); +enum RunStatus { + incomplete._(r'incomplete'), + complete._(r'complete'), + failed._(r'failed'), + ; + + /// Instantiate a new enum with the provided value. + const RunStatus._(this._value); /// The underlying value of this enum member. - final String value; + final String _value; @override - String toString() => value; + String toString() => _value; - String toJson() => value; - - static const incomplete = RunStatus._(r'incomplete'); - static const complete = RunStatus._(r'complete'); - static const failed = RunStatus._(r'failed'); - - /// List of all possible values in this [enum][RunStatus]. - static const values = [ - incomplete, - complete, - failed, - ]; + /// Encodes this enum as a value suitable for JSON. + String toJson() => _value; + /// Returns the instance of [RunStatus] that was successfully decoded + /// from the passed [value] on success, null otherwise. static RunStatus? fromJson(dynamic value) => RunStatusTypeTransformer().decode(value); + /// Returns a [List] containing instances of [RunStatus] + /// that were successfully decoded from the passed [JSON][json]. static List listFromJson(dynamic json, {bool growable = false,}) { final result = []; if (json is List && json.isNotEmpty) { @@ -57,9 +56,11 @@ class RunStatusTypeTransformer { const RunStatusTypeTransformer._(); - String encode(RunStatus data) => data.value; + /// Encodes this enum as a value suitable for JSON. + String encode(RunStatus data) => data._value; - /// Decodes a [dynamic value][data] to a RunStatus. + /// Returns the instance of [RunStatus] that was successfully decoded + /// from the passed [data] value on success, null otherwise. /// /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] @@ -68,6 +69,9 @@ class RunStatusTypeTransformer { /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, /// and users are still using an old app with the old code. RunStatus? decode(dynamic data, {bool allowNull = true}) { + if (data is RunStatus) { + return data; + } if (data != null) { switch (data) { case r'incomplete': return RunStatus.incomplete; @@ -82,7 +86,7 @@ class RunStatusTypeTransformer { return null; } - /// Singleton [RunStatusTypeTransformer] instance. + /// The singleton instance of this transformer. static RunStatusTypeTransformer? _instance; } diff --git a/mobile/openapi/lib/model/run_type.dart b/mobile/openapi/lib/model/run_type.dart index b8eb404bb0..5b853b4ddb 100644 --- a/mobile/openapi/lib/model/run_type.dart +++ b/mobile/openapi/lib/model/run_type.dart @@ -11,33 +11,31 @@ part of openapi.api; -class RunType { - /// Instantiate a new enum with the provided [value]. - const RunType._(this.value); +enum RunType { + schedule._(r'schedule'), + restore._(r'restore'), + backup._(r'backup'), + forget._(r'forget'), + ; + + /// Instantiate a new enum with the provided value. + const RunType._(this._value); /// The underlying value of this enum member. - final String value; + final String _value; @override - String toString() => value; + String toString() => _value; - String toJson() => value; - - static const schedule = RunType._(r'schedule'); - static const restore = RunType._(r'restore'); - static const backup = RunType._(r'backup'); - static const forget = RunType._(r'forget'); - - /// List of all possible values in this [enum][RunType]. - static const values = [ - schedule, - restore, - backup, - forget, - ]; + /// Encodes this enum as a value suitable for JSON. + String toJson() => _value; + /// Returns the instance of [RunType] that was successfully decoded + /// from the passed [value] on success, null otherwise. static RunType? fromJson(dynamic value) => RunTypeTypeTransformer().decode(value); + /// Returns a [List] containing instances of [RunType] + /// that were successfully decoded from the passed [JSON][json]. static List listFromJson(dynamic json, {bool growable = false,}) { final result = []; if (json is List && json.isNotEmpty) { @@ -59,9 +57,11 @@ class RunTypeTypeTransformer { const RunTypeTypeTransformer._(); - String encode(RunType data) => data.value; + /// Encodes this enum as a value suitable for JSON. + String encode(RunType data) => data._value; - /// Decodes a [dynamic value][data] to a RunType. + /// Returns the instance of [RunType] that was successfully decoded + /// from the passed [data] value on success, null otherwise. /// /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] @@ -70,6 +70,9 @@ class RunTypeTypeTransformer { /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, /// and users are still using an old app with the old code. RunType? decode(dynamic data, {bool allowNull = true}) { + if (data is RunType) { + return data; + } if (data != null) { switch (data) { case r'schedule': return RunType.schedule; @@ -85,7 +88,7 @@ class RunTypeTypeTransformer { return null; } - /// Singleton [RunTypeTypeTransformer] instance. + /// The singleton instance of this transformer. static RunTypeTypeTransformer? _instance; } diff --git a/mobile/openapi/lib/model/set_maintenance_mode_dto.dart b/mobile/openapi/lib/model/set_maintenance_mode_dto.dart index 21f123bb84..6bfd7d75ef 100644 --- a/mobile/openapi/lib/model/set_maintenance_mode_dto.dart +++ b/mobile/openapi/lib/model/set_maintenance_mode_dto.dart @@ -15,6 +15,8 @@ class SetMaintenanceModeDto { SetMaintenanceModeDto({ required this.action, this.restoreBackupFilename = const Optional.absent(), + this.rollbackRepositoryId = const Optional.absent(), + this.rollbackSnapshotId = const Optional.absent(), }); MaintenanceAction action; @@ -28,19 +30,41 @@ class SetMaintenanceModeDto { /// Optional restoreBackupFilename; + /// Rollback repository ID + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Optional rollbackRepositoryId; + + /// Rollback snapshot ID + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Optional rollbackSnapshotId; + @override bool operator ==(Object other) => identical(this, other) || other is SetMaintenanceModeDto && other.action == action && - other.restoreBackupFilename == restoreBackupFilename; + other.restoreBackupFilename == restoreBackupFilename && + other.rollbackRepositoryId == rollbackRepositoryId && + other.rollbackSnapshotId == rollbackSnapshotId; @override int get hashCode => // ignore: unnecessary_parenthesis (action.hashCode) + - (restoreBackupFilename == null ? 0 : restoreBackupFilename!.hashCode); + (restoreBackupFilename == null ? 0 : restoreBackupFilename!.hashCode) + + (rollbackRepositoryId == null ? 0 : rollbackRepositoryId!.hashCode) + + (rollbackSnapshotId == null ? 0 : rollbackSnapshotId!.hashCode); @override - String toString() => 'SetMaintenanceModeDto[action=$action, restoreBackupFilename=$restoreBackupFilename]'; + String toString() => 'SetMaintenanceModeDto[action=$action, restoreBackupFilename=$restoreBackupFilename, rollbackRepositoryId=$rollbackRepositoryId, rollbackSnapshotId=$rollbackSnapshotId]'; Map toJson() { final json = {}; @@ -49,6 +73,14 @@ class SetMaintenanceModeDto { final value = this.restoreBackupFilename.value; json[r'restoreBackupFilename'] = value; } + if (this.rollbackRepositoryId.isPresent) { + final value = this.rollbackRepositoryId.value; + json[r'rollbackRepositoryId'] = value; + } + if (this.rollbackSnapshotId.isPresent) { + final value = this.rollbackSnapshotId.value; + json[r'rollbackSnapshotId'] = value; + } return json; } @@ -63,6 +95,8 @@ class SetMaintenanceModeDto { return SetMaintenanceModeDto( action: MaintenanceAction.fromJson(json[r'action'])!, restoreBackupFilename: json.containsKey(r'restoreBackupFilename') ? Optional.present(mapValueOfType(json, r'restoreBackupFilename')) : const Optional.absent(), + rollbackRepositoryId: json.containsKey(r'rollbackRepositoryId') ? Optional.present(mapValueOfType(json, r'rollbackRepositoryId')) : const Optional.absent(), + rollbackSnapshotId: json.containsKey(r'rollbackSnapshotId') ? Optional.present(mapValueOfType(json, r'rollbackSnapshotId')) : const Optional.absent(), ); } return null; diff --git a/mobile/openapi/lib/model/task_status.dart b/mobile/openapi/lib/model/task_status.dart index 95aaa2acdd..692ea1027f 100644 --- a/mobile/openapi/lib/model/task_status.dart +++ b/mobile/openapi/lib/model/task_status.dart @@ -11,31 +11,30 @@ part of openapi.api; -class TaskStatus { - /// Instantiate a new enum with the provided [value]. - const TaskStatus._(this.value); +enum TaskStatus { + incomplete._(r'incomplete'), + complete._(r'complete'), + failed._(r'failed'), + ; + + /// Instantiate a new enum with the provided value. + const TaskStatus._(this._value); /// The underlying value of this enum member. - final String value; + final String _value; @override - String toString() => value; + String toString() => _value; - String toJson() => value; - - static const incomplete = TaskStatus._(r'incomplete'); - static const complete = TaskStatus._(r'complete'); - static const failed = TaskStatus._(r'failed'); - - /// List of all possible values in this [enum][TaskStatus]. - static const values = [ - incomplete, - complete, - failed, - ]; + /// Encodes this enum as a value suitable for JSON. + String toJson() => _value; + /// Returns the instance of [TaskStatus] that was successfully decoded + /// from the passed [value] on success, null otherwise. static TaskStatus? fromJson(dynamic value) => TaskStatusTypeTransformer().decode(value); + /// Returns a [List] containing instances of [TaskStatus] + /// that were successfully decoded from the passed [JSON][json]. static List listFromJson(dynamic json, {bool growable = false,}) { final result = []; if (json is List && json.isNotEmpty) { @@ -57,9 +56,11 @@ class TaskStatusTypeTransformer { const TaskStatusTypeTransformer._(); - String encode(TaskStatus data) => data.value; + /// Encodes this enum as a value suitable for JSON. + String encode(TaskStatus data) => data._value; - /// Decodes a [dynamic value][data] to a TaskStatus. + /// Returns the instance of [TaskStatus] that was successfully decoded + /// from the passed [data] value on success, null otherwise. /// /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] @@ -68,6 +69,9 @@ class TaskStatusTypeTransformer { /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, /// and users are still using an old app with the old code. TaskStatus? decode(dynamic data, {bool allowNull = true}) { + if (data is TaskStatus) { + return data; + } if (data != null) { switch (data) { case r'incomplete': return TaskStatus.incomplete; @@ -82,7 +86,7 @@ class TaskStatusTypeTransformer { return null; } - /// Singleton [TaskStatusTypeTransformer] instance. + /// The singleton instance of this transformer. static TaskStatusTypeTransformer? _instance; } diff --git a/mobile/openapi/lib/model/task_type.dart b/mobile/openapi/lib/model/task_type.dart index 880aac2d39..2090749a72 100644 --- a/mobile/openapi/lib/model/task_type.dart +++ b/mobile/openapi/lib/model/task_type.dart @@ -11,33 +11,31 @@ part of openapi.api; -class TaskType { - /// Instantiate a new enum with the provided [value]. - const TaskType._(this.value); +enum TaskType { + schedule._(r'schedule'), + restore._(r'restore'), + backup._(r'backup'), + forget._(r'forget'), + ; + + /// Instantiate a new enum with the provided value. + const TaskType._(this._value); /// The underlying value of this enum member. - final String value; + final String _value; @override - String toString() => value; + String toString() => _value; - String toJson() => value; - - static const schedule = TaskType._(r'schedule'); - static const restore = TaskType._(r'restore'); - static const backup = TaskType._(r'backup'); - static const forget = TaskType._(r'forget'); - - /// List of all possible values in this [enum][TaskType]. - static const values = [ - schedule, - restore, - backup, - forget, - ]; + /// Encodes this enum as a value suitable for JSON. + String toJson() => _value; + /// Returns the instance of [TaskType] that was successfully decoded + /// from the passed [value] on success, null otherwise. static TaskType? fromJson(dynamic value) => TaskTypeTypeTransformer().decode(value); + /// Returns a [List] containing instances of [TaskType] + /// that were successfully decoded from the passed [JSON][json]. static List listFromJson(dynamic json, {bool growable = false,}) { final result = []; if (json is List && json.isNotEmpty) { @@ -59,9 +57,11 @@ class TaskTypeTypeTransformer { const TaskTypeTypeTransformer._(); - String encode(TaskType data) => data.value; + /// Encodes this enum as a value suitable for JSON. + String encode(TaskType data) => data._value; - /// Decodes a [dynamic value][data] to a TaskType. + /// Returns the instance of [TaskType] that was successfully decoded + /// from the passed [data] value on success, null otherwise. /// /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] @@ -70,6 +70,9 @@ class TaskTypeTypeTransformer { /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, /// and users are still using an old app with the old code. TaskType? decode(dynamic data, {bool allowNull = true}) { + if (data is TaskType) { + return data; + } if (data != null) { switch (data) { case r'schedule': return TaskType.schedule; @@ -85,7 +88,7 @@ class TaskTypeTypeTransformer { return null; } - /// Singleton [TaskTypeTypeTransformer] instance. + /// The singleton instance of this transformer. static TaskTypeTypeTransformer? _instance; } diff --git a/mobile/openapi/lib/model/telemetry_level.dart b/mobile/openapi/lib/model/telemetry_level.dart index b18b26be77..03984a0446 100644 --- a/mobile/openapi/lib/model/telemetry_level.dart +++ b/mobile/openapi/lib/model/telemetry_level.dart @@ -11,29 +11,29 @@ part of openapi.api; -class TelemetryLevel { - /// Instantiate a new enum with the provided [value]. - const TelemetryLevel._(this.value); +enum TelemetryLevel { + full._(r'full'), + none._(r'none'), + ; + + /// Instantiate a new enum with the provided value. + const TelemetryLevel._(this._value); /// The underlying value of this enum member. - final String value; + final String _value; @override - String toString() => value; + String toString() => _value; - String toJson() => value; - - static const full = TelemetryLevel._(r'full'); - static const none = TelemetryLevel._(r'none'); - - /// List of all possible values in this [enum][TelemetryLevel]. - static const values = [ - full, - none, - ]; + /// Encodes this enum as a value suitable for JSON. + String toJson() => _value; + /// Returns the instance of [TelemetryLevel] that was successfully decoded + /// from the passed [value] on success, null otherwise. static TelemetryLevel? fromJson(dynamic value) => TelemetryLevelTypeTransformer().decode(value); + /// Returns a [List] containing instances of [TelemetryLevel] + /// that were successfully decoded from the passed [JSON][json]. static List listFromJson(dynamic json, {bool growable = false,}) { final result = []; if (json is List && json.isNotEmpty) { @@ -55,9 +55,11 @@ class TelemetryLevelTypeTransformer { const TelemetryLevelTypeTransformer._(); - String encode(TelemetryLevel data) => data.value; + /// Encodes this enum as a value suitable for JSON. + String encode(TelemetryLevel data) => data._value; - /// Decodes a [dynamic value][data] to a TelemetryLevel. + /// Returns the instance of [TelemetryLevel] that was successfully decoded + /// from the passed [data] value on success, null otherwise. /// /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] @@ -66,6 +68,9 @@ class TelemetryLevelTypeTransformer { /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, /// and users are still using an old app with the old code. TelemetryLevel? decode(dynamic data, {bool allowNull = true}) { + if (data is TelemetryLevel) { + return data; + } if (data != null) { switch (data) { case r'full': return TelemetryLevel.full; @@ -79,7 +84,7 @@ class TelemetryLevelTypeTransformer { return null; } - /// Singleton [TelemetryLevelTypeTransformer] instance. + /// The singleton instance of this transformer. static TelemetryLevelTypeTransformer? _instance; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fa5c67d0ab..5ce9bc2a80 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16399,6 +16399,30 @@ ] } }, + "/yucca/integrations/immich/rollback": { + "post": { + "operationId": "startImmichRollback", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImmichRollbackRequestDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Integrations" + ] + } + }, "/yucca/logs/{id}": { "get": { "operationId": "getRun", @@ -20541,6 +20565,24 @@ ], "type": "object" }, + "ImmichRollbackRequestDto": { + "properties": { + "backupFileName": { + "type": "string" + }, + "repositoryId": { + "type": "string" + }, + "snapshotId": { + "type": "string" + } + }, + "required": [ + "repositoryId", + "snapshotId" + ], + "type": "object" + }, "ImmichStateDto": { "properties": { "dataFolders": { @@ -21113,7 +21155,8 @@ "start", "end", "select_database_restore", - "restore_database" + "restore_database", + "rollback" ], "type": "string" }, @@ -21198,6 +21241,10 @@ }, "task": { "type": "string" + }, + "yuccaLogId": { + "description": "Yucca log ID", + "type": "string" } }, "required": [ @@ -25068,6 +25115,14 @@ "restoreBackupFilename": { "description": "Restore backup filename", "type": "string" + }, + "rollbackRepositoryId": { + "description": "Rollback repository ID", + "type": "string" + }, + "rollbackSnapshotId": { + "description": "Rollback snapshot ID", + "type": "string" } }, "required": [ diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 243c684e2b..ea84d36e2a 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -93,6 +93,10 @@ export type SetMaintenanceModeDto = { action: MaintenanceAction; /** Restore backup filename */ restoreBackupFilename?: string; + /** Rollback repository ID */ + rollbackRepositoryId?: string; + /** Rollback snapshot ID */ + rollbackSnapshotId?: string; }; export type MaintenanceDetectInstallStorageFolderDto = { /** Number of files in the folder */ @@ -120,6 +124,8 @@ export type MaintenanceStatusResponseDto = { error?: string; progress?: number; task?: string; + /** Yucca log ID */ + yuccaLogId?: string; }; export type NotificationCreateDto = { /** Additional notification data */ @@ -2924,6 +2930,11 @@ export type ConfigureImmichIntegrationRequestDto = { retentionPolicy?: (RetentionPolicyDto) | null; worm: boolean; }; +export type ImmichRollbackRequestDto = { + backupFileName?: string; + repositoryId: string; + snapshotId: string; +}; export type RunDto = { end: string; id: string; @@ -7387,6 +7398,15 @@ export function configureImmichIntegration({ configureImmichIntegrationRequestDt body: configureImmichIntegrationRequestDto }))); } +export function startImmichRollback({ immichRollbackRequestDto }: { + immichRollbackRequestDto: ImmichRollbackRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/yucca/integrations/immich/rollback", oazapfts.json({ + ...opts, + method: "POST", + body: immichRollbackRequestDto + }))); +} export function getRun({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { @@ -7737,7 +7757,8 @@ export enum MaintenanceAction { Start = "start", End = "end", SelectDatabaseRestore = "select_database_restore", - RestoreDatabase = "restore_database" + RestoreDatabase = "restore_database", + Rollback = "rollback" } export enum StorageFolder { EncodedVideo = "encoded-video", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1326bec337..75bbec0bf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,8 +112,8 @@ importers: specifier: ^10.1.0 version: 10.5.0 '@futo-org/backups-orchestrator-ui': - specifier: 0.12.0 - version: 0.12.0(svelte@5.56.5(@typescript-eslint/types@8.64.0)) + specifier: 0.19.3 + version: 0.19.3(svelte@5.56.5(@typescript-eslint/types@8.64.0)) '@immich/cli': specifier: workspace:* version: link:../packages/cli @@ -404,8 +404,8 @@ importers: specifier: 2.0.0-rc13 version: 2.0.0-rc13 '@futo-org/backups-orchestrator-api': - specifier: 0.12.0 - version: 0.12.0(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-socket.io@11.1.28)(@nestjs/schedule@6.1.3(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28))(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2) + specifier: 0.19.3 + version: 0.19.3(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-socket.io@11.1.28)(@nestjs/schedule@6.1.3(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28))(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2) '@immich/plugin-sdk': specifier: workspace:* version: link:../packages/plugin-sdk @@ -798,8 +798,8 @@ importers: specifier: ^3.0.0 version: 3.5.15 '@futo-org/backups-orchestrator-ui': - specifier: 0.12.0 - version: 0.12.0(@sveltejs/kit@2.69.3(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.5(@typescript-eslint/types@8.64.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.5(@typescript-eslint/types@8.64.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.5(@typescript-eslint/types@8.64.0)) + specifier: 0.19.3 + version: 0.19.3(@sveltejs/kit@2.69.3(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.5(@typescript-eslint/types@8.64.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.5(@typescript-eslint/types@8.64.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.5(@typescript-eslint/types@8.64.0)) '@immich/justified-layout-wasm': specifier: ^0.4.3 version: 0.4.3 @@ -2907,11 +2907,11 @@ packages: resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==} engines: {node: '>=6'} - '@futo-org/backups-api-client@0.12.0': - resolution: {integrity: sha512-DJ+XJusy8h6qCYyQUKZvY1nvLfvf8OgLIBphIV5MbcPmbBR5oLQcfyVgiyXCjWmGnbnNi8YIDiE07nerWqWfeg==} + '@futo-org/backups-api-client@0.19.3': + resolution: {integrity: sha512-aaZ/bQF2eDFrkSjRadziO6+ZrcRpdupelxwF2UusMp/WyQQCC0Qo3d59Ok5YnMxuC0iS2Kz3aDBNZqnqweBtGw==} - '@futo-org/backups-orchestrator-api@0.12.0': - resolution: {integrity: sha512-8wC5gbkL3yG0gZh90vh9j1IgOf6DlsT1beqVVP3qe84cIyC0yvd4GoDiBK1ymMEfXElqVSV8vV8KgjtKWb6MbA==} + '@futo-org/backups-orchestrator-api@0.19.3': + resolution: {integrity: sha512-ZEMVvtgTdwvEhiZRLVUSj4c+6vFifV0sMrXkOq1o9i19NR+jTq6BtfancJ5HnJL47zTmWHYDewgUzjMO4EqoKg==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -2919,8 +2919,8 @@ packages: '@nestjs/schedule': ^6.0.0 '@nestjs/websockets': ^11.0.0 - '@futo-org/backups-orchestrator-ui@0.12.0': - resolution: {integrity: sha512-nrGQKBdkGitvfL78P5wu0ITaOykbIQEbbUJyuLIYE6yhqH0hX6/u1WlnRoTfcXJeat3Qt+3/W4ayUYr1qOOpmA==} + '@futo-org/backups-orchestrator-ui@0.19.3': + resolution: {integrity: sha512-3fA4hTIrdESm1MqN9pddBnZndn2oAcHQPcLBslhR0TJbgeujSRl3/7jWu9q7reTeq/r4oDPxQecTLo66C1v6DQ==} peerDependencies: svelte: ^5.0.0 @@ -15974,13 +15974,13 @@ snapshots: dependencies: '@fortawesome/fontawesome-common-types': 7.2.0 - '@futo-org/backups-api-client@0.12.0': + '@futo-org/backups-api-client@0.19.3': dependencies: '@oazapfts/runtime': 1.2.0 - '@futo-org/backups-orchestrator-api@0.12.0(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-socket.io@11.1.28)(@nestjs/schedule@6.1.3(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28))(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)': + '@futo-org/backups-orchestrator-api@0.19.3(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-socket.io@11.1.28)(@nestjs/schedule@6.1.3(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28))(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)': dependencies: - '@futo-org/backups-api-client': 0.12.0 + '@futo-org/backups-api-client': 0.19.3 '@futo-org/restic-wrapper': 1.3.0 '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -16007,9 +16007,9 @@ snapshots: - supports-color - utf-8-validate - '@futo-org/backups-orchestrator-ui@0.12.0(@sveltejs/kit@2.69.3(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.5(@typescript-eslint/types@8.64.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.5(@typescript-eslint/types@8.64.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.5(@typescript-eslint/types@8.64.0))': + '@futo-org/backups-orchestrator-ui@0.19.3(@sveltejs/kit@2.69.3(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.5(@typescript-eslint/types@8.64.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.5(@typescript-eslint/types@8.64.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.5(@typescript-eslint/types@8.64.0))': dependencies: - '@futo-org/backups-api-client': 0.12.0 + '@futo-org/backups-api-client': 0.19.3 '@immich/ui': 0.59.0(@sveltejs/kit@2.69.3(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.5(@typescript-eslint/types@8.64.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.5(@typescript-eslint/types@8.64.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.5(@typescript-eslint/types@8.64.0)) '@mdi/js': 7.4.47 '@oazapfts/runtime': 1.2.0 @@ -16026,9 +16026,9 @@ snapshots: - supports-color - utf-8-validate - '@futo-org/backups-orchestrator-ui@0.12.0(svelte@5.56.5(@typescript-eslint/types@8.64.0))': + '@futo-org/backups-orchestrator-ui@0.19.3(svelte@5.56.5(@typescript-eslint/types@8.64.0))': dependencies: - '@futo-org/backups-api-client': 0.12.0 + '@futo-org/backups-api-client': 0.19.3 '@immich/ui': 0.59.0(svelte@5.56.5(@typescript-eslint/types@8.64.0)) '@mdi/js': 7.4.47 '@oazapfts/runtime': 1.2.0 diff --git a/server/package.json b/server/package.json index d1abf0a47b..fa82b1b95f 100644 --- a/server/package.json +++ b/server/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@extism/extism": "2.0.0-rc13", - "@futo-org/backups-orchestrator-api": "0.12.0", + "@futo-org/backups-orchestrator-api": "0.19.3", "@immich/plugin-sdk": "workspace:*", "@immich/sql-tools": "^0.5.1", "@nestjs/bullmq": "^11.0.1", diff --git a/server/src/dtos/database-backup.dto.ts b/server/src/dtos/database-backup.dto.ts index bc16f0aebf..ce623b19d2 100644 --- a/server/src/dtos/database-backup.dto.ts +++ b/server/src/dtos/database-backup.dto.ts @@ -27,6 +27,8 @@ const DatabaseBackupDeleteSchema = z }) .meta({ id: 'DatabaseBackupDeleteDto' }); +export type DatabaseBackupDto = z.infer; + export class DatabaseBackupListResponseDto extends createZodDto(DatabaseBackupListResponseSchema) {} export class DatabaseBackupUploadDto extends createZodDto(DatabaseBackupUploadSchema) {} export class DatabaseBackupDeleteDto extends createZodDto(DatabaseBackupDeleteSchema) {} diff --git a/server/src/dtos/maintenance.dto.ts b/server/src/dtos/maintenance.dto.ts index 96b376c5e6..f2446d223a 100644 --- a/server/src/dtos/maintenance.dto.ts +++ b/server/src/dtos/maintenance.dto.ts @@ -6,6 +6,8 @@ const SetMaintenanceModeSchema = z .object({ action: MaintenanceActionSchema, restoreBackupFilename: z.string().optional().describe('Restore backup filename'), + rollbackRepositoryId: z.string().optional().describe('Rollback repository ID'), + rollbackSnapshotId: z.string().optional().describe('Rollback snapshot ID'), }) .refine( (data) => data.action !== MaintenanceAction.RestoreDatabase || (data.restoreBackupFilename?.length ?? 0) > 0, @@ -32,6 +34,7 @@ const MaintenanceStatusResponseSchema = z progress: z.int().optional(), task: z.string().optional(), error: z.string().optional(), + yuccaLogId: z.string().optional().describe('Yucca log ID'), }) .meta({ id: 'MaintenanceStatusResponseDto' }); diff --git a/server/src/enum.ts b/server/src/enum.ts index 7b58c7ab7b..cd665c813e 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -977,6 +977,7 @@ export enum MaintenanceAction { End = 'end', SelectDatabaseRestore = 'select_database_restore', RestoreDatabase = 'restore_database', + Rollback = 'rollback', } export const MaintenanceActionSchema = z diff --git a/server/src/maintenance/maintenance-worker.service.ts b/server/src/maintenance/maintenance-worker.service.ts index e76be43d2f..e7ad222b3a 100644 --- a/server/src/maintenance/maintenance-worker.service.ts +++ b/server/src/maintenance/maintenance-worker.service.ts @@ -1,4 +1,6 @@ +import { YuccaService } from '@futo-org/backups-orchestrator-api'; import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { parse } from 'cookie'; import { NextFunction, Request, Response } from 'express'; import { jwtVerify } from 'jose'; @@ -30,6 +32,7 @@ import { type ServerService as _ServerService } from 'src/services/server.servic import { type VersionService as _VersionService } from 'src/services/version.service'; import { MaintenanceModeState } from 'src/types'; import { getConfig } from 'src/utils/config'; +import { getLatestDatabaseBackup } from 'src/utils/database-backups'; import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; import { detectMediaLocation } from 'src/utils/storage'; @@ -56,6 +59,7 @@ export class MaintenanceWorkerService { private processRepository: ProcessRepository, private databaseRepository: DatabaseRepository, private databaseBackupService: DatabaseBackupService, + private moduleRef: ModuleRef, ) { this.logger.setContext(this.constructor.name); } @@ -271,6 +275,9 @@ export class MaintenanceWorkerService { case MaintenanceAction.RestoreDatabase: { return this.runRestoreDatabase(action); } + case MaintenanceAction.Rollback: { + return this.runRollback(action); + } } } @@ -329,6 +336,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: '' + error, + }); + } + } + + private async rollback(repositoryId: string, snapshotId: string): Promise { + this.setStatus({ + active: true, + action: MaintenanceAction.Rollback, + }); + + const yucca = this.moduleRef.get(YuccaService, { strict: false }); + const { logId, task } = await yucca.restoreSnapshotInplace(repositoryId, snapshotId); + + this.setStatus({ + active: true, + action: MaintenanceAction.Rollback, + yuccaLogId: logId, + }); + + await task; + + const { backups } = await this.databaseBackupService.listBackups(); + const latest = getLatestDatabaseBackup(backups); + if (!latest) { + return this.setAction({ + action: MaintenanceAction.SelectDatabaseRestore, + }); + } + + await this.restoreBackup(latest.filename); + } + private async endMaintenance(): Promise { const state: MaintenanceModeState = { isMaintenanceMode: false as const }; await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state); diff --git a/server/src/services/yucca.service.ts b/server/src/services/yucca.service.ts index b8c953746d..aafdbb1b81 100644 --- a/server/src/services/yucca.service.ts +++ b/server/src/services/yucca.service.ts @@ -3,10 +3,12 @@ import { Injectable, Optional } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent } from 'src/decorators'; -import { DatabaseLock, ImmichWorker, StorageFolder } from 'src/enum'; +import { DatabaseLock, ImmichWorker, MaintenanceAction, StorageFolder } from 'src/enum'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { ArgOf } from 'src/repositories/event.repository'; import { LibraryRepository } from 'src/repositories/library.repository'; +import { DatabaseBackupService } from 'src/services/database-backup.service'; +import { MaintenanceService } from 'src/services/maintenance.service'; import { getExternalDomain } from 'src/utils/misc'; @Injectable() @@ -14,8 +16,13 @@ export class YuccaService { constructor( private readonly databaseRepository: DatabaseRepository, private readonly libraryRepository: LibraryRepository, + private readonly databaseBackupService: DatabaseBackupService, + @Optional() private readonly maintenanceService: MaintenanceService, @Optional() private readonly yuccaService: YuccaOrchestratorService, - ) {} + ) { + this.createDatabaseBackup = this.createDatabaseBackup.bind(this); + this.enterMaintenanceRollback = this.enterMaintenanceRollback.bind(this); + } private updateSystemConfig({ server }: SystemConfig) { this.yuccaService.setExternalBaseUrl(getExternalDomain(server)); @@ -30,9 +37,28 @@ export class YuccaService { libraries: libraries .filter((library) => !library.deletedAt) .map(({ id, name, importPaths, exclusionPatterns }) => ({ id, name, importPaths, exclusionPatterns })), + hooks: { + createDatabaseBackup: this.createDatabaseBackup, + enterMaintenanceRollback: this.enterMaintenanceRollback, + }, }); } + private async createDatabaseBackup() { + await this.databaseBackupService.createDatabaseBackup(); + } + + private enterMaintenanceRollback(repositoryId: string, snapshotId: string) { + return this.maintenanceService.startMaintenance( + { + action: MaintenanceAction.Rollback, + rollbackRepositoryId: repositoryId, + rollbackSnapshotId: snapshotId, + }, + 'yucca-rollback', + ); + } + @OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.Api] }) async onConfigInit({ newConfig }: ArgOf<'ConfigInit'>) { this.updateSystemConfig(newConfig); diff --git a/server/src/utils/database-backups.ts b/server/src/utils/database-backups.ts index 70bedb32b1..4bc1f33cfd 100644 --- a/server/src/utils/database-backups.ts +++ b/server/src/utils/database-backups.ts @@ -1,3 +1,6 @@ +import { DateTime } from 'luxon'; +import { DatabaseBackupDto } from 'src/dtos/database-backup.dto'; + export function isValidDatabaseBackupName(filename: string) { return filename.match(/^[\d\w-.]+\.sql(?:\.gz)?$/); } @@ -17,6 +20,23 @@ export function findDatabaseBackupVersion(filename: string) { return /-v(.*)-/.exec(filename)?.[1]; } +function getDatabaseBackupTimestamp(backup: DatabaseBackupDto): number { + const dateMatch = backup.filename.match(/\d+T\d+/); + if (!dateMatch) { + return 0; + } + + return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: backup.timezone }).toMillis(); +} + +export function getLatestDatabaseBackup(backups: T[]): T | undefined { + if (backups.length === 0) { + return undefined; + } + + return backups.toSorted((a, b) => getDatabaseBackupTimestamp(b) - getDatabaseBackupTimestamp(a))[0]; +} + export class UnsupportedPostgresError extends Error { constructor(databaseVersion: string) { super(`Unsupported PostgreSQL version: ${databaseVersion}`); diff --git a/web/package.json b/web/package.json index 6c1a71abec..e158665e2a 100644 --- a/web/package.json +++ b/web/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@formatjs/icu-messageformat-parser": "^3.0.0", - "@futo-org/backups-orchestrator-ui": "0.12.0", + "@futo-org/backups-orchestrator-ui": "0.19.3", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", "@immich/ui": "^0.83.0", diff --git a/web/src/routes/maintenance/+page.svelte b/web/src/routes/maintenance/+page.svelte index aadec0df0c..b58ede7e84 100644 --- a/web/src/routes/maintenance/+page.svelte +++ b/web/src/routes/maintenance/+page.svelte @@ -7,7 +7,7 @@ import { maintenanceStore } from '$lib/stores/maintenance.store'; import { MaintenanceAction } from '@immich/sdk'; import { Button, Heading, Link, ProgressBar, Scrollable, Text } from '@immich/ui'; - import { YuccaContext } from '@futo-org/backups-orchestrator-ui'; + import { YuccaContext, ViewLogModal } from '@futo-org/backups-orchestrator-ui'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -72,6 +72,8 @@ {$t('maintenance_task_rollback')} {/if} {/if} + {:else if $status?.action === MaintenanceAction.Rollback} + void 0} /> {:else if $status?.action === MaintenanceAction.SelectDatabaseRestore && $auth} {:else}