diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3f3ae2e6a2..ebb38ac907 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -159,6 +159,8 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings +*MaintenanceAdminApi* | [**deleteBackup**](doc//MaintenanceAdminApi.md#deletebackup) | **DELETE** /admin/maintenance/admin/maintenance/backups/{filename} | Delete backup +*MaintenanceAdminApi* | [**listBackups**](doc//MaintenanceAdminApi.md#listbackups) | **GET** /admin/maintenance/admin/maintenance/backups/list | List backups *MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode *MaintenanceAdminApi* | [**maintenanceStatus**](doc//MaintenanceAdminApi.md#maintenancestatus) | **GET** /admin/maintenance/admin/maintenance/status | Get maintenance mode status *MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode @@ -409,6 +411,7 @@ Class | Method | HTTP request | Description - [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md) - [MaintenanceAction](doc//MaintenanceAction.md) - [MaintenanceAuthDto](doc//MaintenanceAuthDto.md) + - [MaintenanceListBackupsResponseDto](doc//MaintenanceListBackupsResponseDto.md) - [MaintenanceLoginDto](doc//MaintenanceLoginDto.md) - [MaintenanceStatusResponseDto](doc//MaintenanceStatusResponseDto.md) - [ManualJobName](doc//ManualJobName.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index db8f3c36bc..7ca4bcdb83 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -166,6 +166,7 @@ part 'model/logout_response_dto.dart'; part 'model/machine_learning_availability_checks_dto.dart'; part 'model/maintenance_action.dart'; part 'model/maintenance_auth_dto.dart'; +part 'model/maintenance_list_backups_response_dto.dart'; part 'model/maintenance_login_dto.dart'; part 'model/maintenance_status_response_dto.dart'; part 'model/manual_job_name.dart'; diff --git a/mobile/openapi/lib/api/maintenance_admin_api.dart b/mobile/openapi/lib/api/maintenance_admin_api.dart index c5ca7788a7..ffa6517e62 100644 --- a/mobile/openapi/lib/api/maintenance_admin_api.dart +++ b/mobile/openapi/lib/api/maintenance_admin_api.dart @@ -16,6 +16,103 @@ class MaintenanceAdminApi { final ApiClient apiClient; + /// Delete backup + /// + /// Delete a backup by its filename + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] filename (required): + Future deleteBackupWithHttpInfo(String filename,) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/maintenance/admin/maintenance/backups/{filename}' + .replaceAll('{filename}', filename); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Delete backup + /// + /// Delete a backup by its filename + /// + /// Parameters: + /// + /// * [String] filename (required): + Future deleteBackup(String filename,) async { + final response = await deleteBackupWithHttpInfo(filename,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// List backups + /// + /// Get the list of the successful and failed backups + /// + /// Note: This method returns the HTTP [Response]. + Future listBackupsWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/maintenance/admin/maintenance/backups/list'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// List backups + /// + /// Get the list of the successful and failed backups + Future listBackups() async { + final response = await listBackupsWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceListBackupsResponseDto',) as MaintenanceListBackupsResponseDto; + + } + return null; + } + /// Log into maintenance mode /// /// Login with maintenance token or cookie to receive current information and perform further actions. diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index ed4c8e68cc..6fd2af7dfc 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -382,6 +382,8 @@ class ApiClient { return MaintenanceActionTypeTransformer().decode(value); case 'MaintenanceAuthDto': return MaintenanceAuthDto.fromJson(value); + case 'MaintenanceListBackupsResponseDto': + return MaintenanceListBackupsResponseDto.fromJson(value); case 'MaintenanceLoginDto': return MaintenanceLoginDto.fromJson(value); case 'MaintenanceStatusResponseDto': diff --git a/mobile/openapi/lib/model/maintenance_list_backups_response_dto.dart b/mobile/openapi/lib/model/maintenance_list_backups_response_dto.dart new file mode 100644 index 0000000000..de13b15ab9 --- /dev/null +++ b/mobile/openapi/lib/model/maintenance_list_backups_response_dto.dart @@ -0,0 +1,111 @@ +// +// 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 MaintenanceListBackupsResponseDto { + /// Returns a new [MaintenanceListBackupsResponseDto] instance. + MaintenanceListBackupsResponseDto({ + this.backups = const [], + this.failedBackups = const [], + }); + + List backups; + + List failedBackups; + + @override + bool operator ==(Object other) => identical(this, other) || other is MaintenanceListBackupsResponseDto && + _deepEquality.equals(other.backups, backups) && + _deepEquality.equals(other.failedBackups, failedBackups); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (backups.hashCode) + + (failedBackups.hashCode); + + @override + String toString() => 'MaintenanceListBackupsResponseDto[backups=$backups, failedBackups=$failedBackups]'; + + Map toJson() { + final json = {}; + json[r'backups'] = this.backups; + json[r'failedBackups'] = this.failedBackups; + return json; + } + + /// Returns a new [MaintenanceListBackupsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MaintenanceListBackupsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MaintenanceListBackupsResponseDto"); + if (value is Map) { + final json = value.cast(); + + return MaintenanceListBackupsResponseDto( + backups: json[r'backups'] is Iterable + ? (json[r'backups'] as Iterable).cast().toList(growable: false) + : const [], + failedBackups: json[r'failedBackups'] is Iterable + ? (json[r'failedBackups'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + 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 = MaintenanceListBackupsResponseDto.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 = MaintenanceListBackupsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MaintenanceListBackupsResponseDto-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] = MaintenanceListBackupsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'backups', + 'failedBackups', + }; +} + diff --git a/mobile/openapi/lib/model/maintenance_status_response_dto.dart b/mobile/openapi/lib/model/maintenance_status_response_dto.dart index 95954bef4e..aa93b27b13 100644 --- a/mobile/openapi/lib/model/maintenance_status_response_dto.dart +++ b/mobile/openapi/lib/model/maintenance_status_response_dto.dart @@ -13,13 +13,13 @@ part of openapi.api; class MaintenanceStatusResponseDto { /// Returns a new [MaintenanceStatusResponseDto] instance. MaintenanceStatusResponseDto({ - this.action, + required this.action, this.error, this.progress, this.task, }); - MaintenanceStatusResponseDtoActionEnum? action; + MaintenanceAction action; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -55,7 +55,7 @@ class MaintenanceStatusResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis - (action == null ? 0 : action!.hashCode) + + (action.hashCode) + (error == null ? 0 : error!.hashCode) + (progress == null ? 0 : progress!.hashCode) + (task == null ? 0 : task!.hashCode); @@ -65,11 +65,7 @@ class MaintenanceStatusResponseDto { Map toJson() { final json = {}; - if (this.action != null) { json[r'action'] = this.action; - } else { - // json[r'action'] = null; - } if (this.error != null) { json[r'error'] = this.error; } else { @@ -97,7 +93,7 @@ class MaintenanceStatusResponseDto { final json = value.cast(); return MaintenanceStatusResponseDto( - action: MaintenanceStatusResponseDtoActionEnum.fromJson(json[r'action']), + action: MaintenanceAction.fromJson(json[r'action'])!, error: mapValueOfType(json, r'error'), progress: num.parse('${json[r'progress']}'), task: mapValueOfType(json, r'task'), @@ -148,83 +144,7 @@ class MaintenanceStatusResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'action', }; } - -class MaintenanceStatusResponseDtoActionEnum { - /// Instantiate a new enum with the provided [value]. - const MaintenanceStatusResponseDtoActionEnum._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const start = MaintenanceStatusResponseDtoActionEnum._(r'start'); - static const end = MaintenanceStatusResponseDtoActionEnum._(r'end'); - static const restoreDatabase = MaintenanceStatusResponseDtoActionEnum._(r'restore_database'); - - /// List of all possible values in this [enum][MaintenanceStatusResponseDtoActionEnum]. - static const values = [ - start, - end, - restoreDatabase, - ]; - - static MaintenanceStatusResponseDtoActionEnum? fromJson(dynamic value) => MaintenanceStatusResponseDtoActionEnumTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = MaintenanceStatusResponseDtoActionEnum.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [MaintenanceStatusResponseDtoActionEnum] to String, -/// and [decode] dynamic data back to [MaintenanceStatusResponseDtoActionEnum]. -class MaintenanceStatusResponseDtoActionEnumTypeTransformer { - factory MaintenanceStatusResponseDtoActionEnumTypeTransformer() => _instance ??= const MaintenanceStatusResponseDtoActionEnumTypeTransformer._(); - - const MaintenanceStatusResponseDtoActionEnumTypeTransformer._(); - - String encode(MaintenanceStatusResponseDtoActionEnum data) => data.value; - - /// Decodes a [dynamic value][data] to a MaintenanceStatusResponseDtoActionEnum. - /// - /// 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] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// 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. - MaintenanceStatusResponseDtoActionEnum? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'start': return MaintenanceStatusResponseDtoActionEnum.start; - case r'end': return MaintenanceStatusResponseDtoActionEnum.end; - case r'restore_database': return MaintenanceStatusResponseDtoActionEnum.restoreDatabase; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [MaintenanceStatusResponseDtoActionEnumTypeTransformer] instance. - static MaintenanceStatusResponseDtoActionEnumTypeTransformer? _instance; -} - - diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 087957d343..915b6e6b0d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16705,12 +16705,11 @@ "MaintenanceStatusResponseDto": { "properties": { "action": { - "enum": [ - "start", - "end", - "restore_database" - ], - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/MaintenanceAction" + } + ] }, "error": { "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 8b5afaa904..fc0d379f23 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -44,8 +44,12 @@ export type SetMaintenanceModeDto = { action: MaintenanceAction; restoreBackupFilename?: string; }; +export type MaintenanceListBackupsResponseDto = { + backups: string[]; + failedBackups: string[]; +}; export type MaintenanceStatusResponseDto = { - action?: Action; + action: MaintenanceAction; error?: string; progress?: number; task?: string; @@ -521,7 +525,7 @@ export type AssetBulkUploadCheckDto = { assets: AssetBulkUploadCheckItem[]; }; export type AssetBulkUploadCheckResult = { - action: Action2; + action: Action; assetId?: string; id: string; isTrashed?: boolean; @@ -1851,6 +1855,28 @@ export function setMaintenanceMode({ setMaintenanceModeDto }: { body: setMaintenanceModeDto }))); } +/** + * List backups + */ +export function listBackups(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: MaintenanceListBackupsResponseDto; + }>("/admin/maintenance/admin/maintenance/backups/list", { + ...opts + })); +} +/** + * Delete backup + */ +export function deleteBackup({ filename }: { + filename: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/admin/maintenance/admin/maintenance/backups/${encodeURIComponent(filename)}`, { + ...opts, + method: "DELETE" + })); +} /** * Get maintenance mode status */ @@ -5074,11 +5100,6 @@ export enum MaintenanceAction { End = "end", RestoreDatabase = "restore_database" } -export enum Action { - Start = "start", - End = "end", - RestoreDatabase = "restore_database" -} export enum NotificationLevel { Success = "success", Error = "error", @@ -5284,7 +5305,7 @@ export enum AssetMediaStatus { Replaced = "replaced", Duplicate = "duplicate" } -export enum Action2 { +export enum Action { Accept = "accept", Reject = "reject" } diff --git a/server/src/dtos/maintenance.dto.ts b/server/src/dtos/maintenance.dto.ts index 64bce1d438..53f4520ca9 100644 --- a/server/src/dtos/maintenance.dto.ts +++ b/server/src/dtos/maintenance.dto.ts @@ -19,6 +19,7 @@ export class MaintenanceAuthDto { } export class MaintenanceStatusResponseDto { + @ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' }) action!: MaintenanceAction; progress?: number; diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 6ef26168bc..783e3513c7 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -22,6 +22,7 @@ export enum AppRoute { ADMIN_USERS = '/admin/users', ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management', ADMIN_SETTINGS = '/admin/system-settings', + ADMIN_MAINTENANCE = '/admin/maintenance', ADMIN_STATS = '/admin/server-status', ADMIN_JOBS = '/admin/jobs-status', ADMIN_REPAIR = '/admin/repair', diff --git a/web/src/lib/stores/maintenance.store.ts b/web/src/lib/stores/maintenance.store.ts index 617da1523a..29d11038ed 100644 --- a/web/src/lib/stores/maintenance.store.ts +++ b/web/src/lib/stores/maintenance.store.ts @@ -1,6 +1,7 @@ -import { type MaintenanceAuthDto } from '@immich/sdk'; +import { type MaintenanceAuthDto, type MaintenanceStatusResponseDto } from '@immich/sdk'; import { writable } from 'svelte/store'; export const maintenanceStore = { auth: writable(), + status: writable(), }; diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 9f01c6878e..7bc26eeca1 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -1,9 +1,16 @@ import { page } from '$app/state'; import { AppRoute } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; +import { maintenanceStore } from '$lib/stores/maintenance.store'; import { notificationManager } from '$lib/stores/notification-manager.svelte'; import { createEventEmitter } from '$lib/utils/eventemitter'; -import { type AssetResponseDto, type NotificationDto, type ServerVersionResponseDto } from '@immich/sdk'; +import { + MaintenanceAction, + type AssetResponseDto, + type MaintenanceStatusResponseDto, + type NotificationDto, + type ServerVersionResponseDto, +} from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; import { get, writable } from 'svelte/store'; import { user } from './user.store'; @@ -37,6 +44,8 @@ export interface Events { on_notification: (notification: NotificationDto) => void; AppRestartV1: (event: AppRestartEvent) => void; + + MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void; } const websocket: Socket = io({ @@ -61,6 +70,15 @@ websocket .on('disconnect', () => websocketStore.connected.set(false)) .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('AppRestartV1', (mode) => websocketStore.serverRestarting.set(mode)) + .on('MaintenanceStatusV1', (status) => { + maintenanceStore.status.set(status); + + if (status.action === MaintenanceAction.End) { + websocketStore.serverRestarting.set({ + isMaintenanceMode: false, + }); + } + }) .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion)) .on('on_session_delete', () => authManager.logout()) .on('on_notification', () => notificationManager.refresh()) @@ -68,7 +86,7 @@ websocket export const openWebsocketConnection = () => { try { - if (get(user) || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) { + if (get(user) || get(websocketStore.serverRestarting) || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) { websocket.connect(); } } catch (error) { diff --git a/web/src/lib/utils/maintenance.ts b/web/src/lib/utils/maintenance.ts index 71d7fc3c80..c543243b6e 100644 --- a/web/src/lib/utils/maintenance.ts +++ b/web/src/lib/utils/maintenance.ts @@ -1,6 +1,7 @@ import { AppRoute } from '$lib/constants'; import { maintenanceStore } from '$lib/stores/maintenance.store'; -import { maintenanceLogin } from '@immich/sdk'; +import { websocketStore } from '$lib/stores/websocket'; +import { MaintenanceAction, maintenanceLogin, maintenanceStatus } from '@immich/sdk'; export function maintenanceCreateUrl(url: URL) { const target = new URL(AppRoute.MAINTENANCE, url.origin); @@ -31,3 +32,22 @@ export const loadMaintenanceAuth = async () => { // silently fail } }; + +export const loadMaintenanceStatus = async () => { + try { + const status = await maintenanceStatus(); + maintenanceStore.status.set(status); + + if (status.action === MaintenanceAction.End) { + websocketStore.serverRestarting.set({ + isMaintenanceMode: false, + }); + } + } catch (error) { + const status = (error as { status: number })?.status; + if (status && status >= 500 && status < 600) { + await new Promise((r) => setTimeout(r, 1e3)); + await loadMaintenanceStatus(); + } + } +}; diff --git a/web/src/routes/maintenance/+page.ts b/web/src/routes/maintenance/+page.ts index 8eec36fec4..94474059de 100644 --- a/web/src/routes/maintenance/+page.ts +++ b/web/src/routes/maintenance/+page.ts @@ -1,6 +1,6 @@ -import { loadMaintenanceAuth } from '$lib/utils/maintenance'; +import { loadMaintenanceAuth, loadMaintenanceStatus } from '$lib/utils/maintenance'; import type { PageLoad } from '../admin/$types'; export const load = (async () => { - await loadMaintenanceAuth(); + await Promise.allSettled([loadMaintenanceAuth(), loadMaintenanceStatus()]); }) satisfies PageLoad;