mirror of
https://github.com/immich-app/immich.git
synced 2025-12-17 17:23:20 +03:00
feat: system integrity check in restore flow
This commit is contained in:
3
mobile/openapi/README.md
generated
3
mobile/openapi/README.md
generated
@@ -160,6 +160,8 @@ Class | Method | HTTP request | Description
|
|||||||
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update 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
|
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
|
||||||
*MaintenanceAdminApi* | [**deleteBackup**](doc//MaintenanceAdminApi.md#deletebackup) | **DELETE** /admin/maintenance/backups/{filename} | Delete backup
|
*MaintenanceAdminApi* | [**deleteBackup**](doc//MaintenanceAdminApi.md#deletebackup) | **DELETE** /admin/maintenance/backups/{filename} | Delete backup
|
||||||
|
*MaintenanceAdminApi* | [**downloadBackup**](doc//MaintenanceAdminApi.md#downloadbackup) | **GET** /admin/maintenance/backups/{filename} | Download backup
|
||||||
|
*MaintenanceAdminApi* | [**integrityCheck**](doc//MaintenanceAdminApi.md#integritycheck) | **GET** /admin/maintenance/integrity | Get integrity and heuristics
|
||||||
*MaintenanceAdminApi* | [**listBackups**](doc//MaintenanceAdminApi.md#listbackups) | **GET** /admin/maintenance/backups/list | List backups
|
*MaintenanceAdminApi* | [**listBackups**](doc//MaintenanceAdminApi.md#listbackups) | **GET** /admin/maintenance/backups/list | List backups
|
||||||
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
|
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
|
||||||
*MaintenanceAdminApi* | [**maintenanceStatus**](doc//MaintenanceAdminApi.md#maintenancestatus) | **GET** /admin/maintenance/status | Get maintenance mode status
|
*MaintenanceAdminApi* | [**maintenanceStatus**](doc//MaintenanceAdminApi.md#maintenancestatus) | **GET** /admin/maintenance/status | Get maintenance mode status
|
||||||
@@ -413,6 +415,7 @@ Class | Method | HTTP request | Description
|
|||||||
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
|
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
|
||||||
- [MaintenanceAction](doc//MaintenanceAction.md)
|
- [MaintenanceAction](doc//MaintenanceAction.md)
|
||||||
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
|
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
|
||||||
|
- [MaintenanceIntegrityResponseDto](doc//MaintenanceIntegrityResponseDto.md)
|
||||||
- [MaintenanceListBackupsResponseDto](doc//MaintenanceListBackupsResponseDto.md)
|
- [MaintenanceListBackupsResponseDto](doc//MaintenanceListBackupsResponseDto.md)
|
||||||
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
|
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
|
||||||
- [MaintenanceStatusResponseDto](doc//MaintenanceStatusResponseDto.md)
|
- [MaintenanceStatusResponseDto](doc//MaintenanceStatusResponseDto.md)
|
||||||
|
|||||||
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
@@ -166,6 +166,7 @@ part 'model/logout_response_dto.dart';
|
|||||||
part 'model/machine_learning_availability_checks_dto.dart';
|
part 'model/machine_learning_availability_checks_dto.dart';
|
||||||
part 'model/maintenance_action.dart';
|
part 'model/maintenance_action.dart';
|
||||||
part 'model/maintenance_auth_dto.dart';
|
part 'model/maintenance_auth_dto.dart';
|
||||||
|
part 'model/maintenance_integrity_response_dto.dart';
|
||||||
part 'model/maintenance_list_backups_response_dto.dart';
|
part 'model/maintenance_list_backups_response_dto.dart';
|
||||||
part 'model/maintenance_login_dto.dart';
|
part 'model/maintenance_login_dto.dart';
|
||||||
part 'model/maintenance_status_response_dto.dart';
|
part 'model/maintenance_status_response_dto.dart';
|
||||||
|
|||||||
105
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
105
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
@@ -65,6 +65,111 @@ class MaintenanceAdminApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download backup
|
||||||
|
///
|
||||||
|
/// Downloads the database backup file
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] filename (required):
|
||||||
|
Future<Response> downloadBackupWithHttpInfo(String filename,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/admin/maintenance/backups/{filename}'
|
||||||
|
.replaceAll('{filename}', filename);
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download backup
|
||||||
|
///
|
||||||
|
/// Downloads the database backup file
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] filename (required):
|
||||||
|
Future<MultipartFile?> downloadBackup(String filename,) async {
|
||||||
|
final response = await downloadBackupWithHttpInfo(filename,);
|
||||||
|
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), 'MultipartFile',) as MultipartFile;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get integrity and heuristics
|
||||||
|
///
|
||||||
|
/// Collect integrity checks and other heuristics about local data.
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
Future<Response> integrityCheckWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/admin/maintenance/integrity';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get integrity and heuristics
|
||||||
|
///
|
||||||
|
/// Collect integrity checks and other heuristics about local data.
|
||||||
|
Future<MaintenanceIntegrityResponseDto?> integrityCheck() async {
|
||||||
|
final response = await integrityCheckWithHttpInfo();
|
||||||
|
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), 'MaintenanceIntegrityResponseDto',) as MaintenanceIntegrityResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// List backups
|
/// List backups
|
||||||
///
|
///
|
||||||
/// Get the list of the successful and failed backups
|
/// Get the list of the successful and failed backups
|
||||||
|
|||||||
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
@@ -382,6 +382,8 @@ class ApiClient {
|
|||||||
return MaintenanceActionTypeTransformer().decode(value);
|
return MaintenanceActionTypeTransformer().decode(value);
|
||||||
case 'MaintenanceAuthDto':
|
case 'MaintenanceAuthDto':
|
||||||
return MaintenanceAuthDto.fromJson(value);
|
return MaintenanceAuthDto.fromJson(value);
|
||||||
|
case 'MaintenanceIntegrityResponseDto':
|
||||||
|
return MaintenanceIntegrityResponseDto.fromJson(value);
|
||||||
case 'MaintenanceListBackupsResponseDto':
|
case 'MaintenanceListBackupsResponseDto':
|
||||||
return MaintenanceListBackupsResponseDto.fromJson(value);
|
return MaintenanceListBackupsResponseDto.fromJson(value);
|
||||||
case 'MaintenanceLoginDto':
|
case 'MaintenanceLoginDto':
|
||||||
|
|||||||
107
mobile/openapi/lib/model/maintenance_integrity_response_dto.dart
generated
Normal file
107
mobile/openapi/lib/model/maintenance_integrity_response_dto.dart
generated
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
//
|
||||||
|
// 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 MaintenanceIntegrityResponseDto {
|
||||||
|
/// Returns a new [MaintenanceIntegrityResponseDto] instance.
|
||||||
|
MaintenanceIntegrityResponseDto({
|
||||||
|
required this.storageHeuristics,
|
||||||
|
required this.storageIntegrity,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object storageHeuristics;
|
||||||
|
|
||||||
|
Object storageIntegrity;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is MaintenanceIntegrityResponseDto &&
|
||||||
|
other.storageHeuristics == storageHeuristics &&
|
||||||
|
other.storageIntegrity == storageIntegrity;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(storageHeuristics.hashCode) +
|
||||||
|
(storageIntegrity.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MaintenanceIntegrityResponseDto[storageHeuristics=$storageHeuristics, storageIntegrity=$storageIntegrity]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'storageHeuristics'] = this.storageHeuristics;
|
||||||
|
json[r'storageIntegrity'] = this.storageIntegrity;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [MaintenanceIntegrityResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static MaintenanceIntegrityResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "MaintenanceIntegrityResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return MaintenanceIntegrityResponseDto(
|
||||||
|
storageHeuristics: mapValueOfType<Object>(json, r'storageHeuristics')!,
|
||||||
|
storageIntegrity: mapValueOfType<Object>(json, r'storageIntegrity')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<MaintenanceIntegrityResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <MaintenanceIntegrityResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = MaintenanceIntegrityResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, MaintenanceIntegrityResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, MaintenanceIntegrityResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = MaintenanceIntegrityResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of MaintenanceIntegrityResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<MaintenanceIntegrityResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<MaintenanceIntegrityResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = MaintenanceIntegrityResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'storageHeuristics',
|
||||||
|
'storageIntegrity',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,31 +14,25 @@ class MaintenanceListBackupsResponseDto {
|
|||||||
/// Returns a new [MaintenanceListBackupsResponseDto] instance.
|
/// Returns a new [MaintenanceListBackupsResponseDto] instance.
|
||||||
MaintenanceListBackupsResponseDto({
|
MaintenanceListBackupsResponseDto({
|
||||||
this.backups = const [],
|
this.backups = const [],
|
||||||
this.failedBackups = const [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
List<String> backups;
|
List<String> backups;
|
||||||
|
|
||||||
List<String> failedBackups;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is MaintenanceListBackupsResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is MaintenanceListBackupsResponseDto &&
|
||||||
_deepEquality.equals(other.backups, backups) &&
|
_deepEquality.equals(other.backups, backups);
|
||||||
_deepEquality.equals(other.failedBackups, failedBackups);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(backups.hashCode) +
|
(backups.hashCode);
|
||||||
(failedBackups.hashCode);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'MaintenanceListBackupsResponseDto[backups=$backups, failedBackups=$failedBackups]';
|
String toString() => 'MaintenanceListBackupsResponseDto[backups=$backups]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'backups'] = this.backups;
|
json[r'backups'] = this.backups;
|
||||||
json[r'failedBackups'] = this.failedBackups;
|
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +48,6 @@ class MaintenanceListBackupsResponseDto {
|
|||||||
backups: json[r'backups'] is Iterable
|
backups: json[r'backups'] is Iterable
|
||||||
? (json[r'backups'] as Iterable).cast<String>().toList(growable: false)
|
? (json[r'backups'] as Iterable).cast<String>().toList(growable: false)
|
||||||
: const [],
|
: const [],
|
||||||
failedBackups: json[r'failedBackups'] is Iterable
|
|
||||||
? (json[r'failedBackups'] as Iterable).cast<String>().toList(growable: false)
|
|
||||||
: const [],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -105,7 +96,6 @@ class MaintenanceListBackupsResponseDto {
|
|||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'backups',
|
'backups',
|
||||||
'failedBackups',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -603,6 +603,40 @@
|
|||||||
"x-immich-state": "Alpha"
|
"x-immich-state": "Alpha"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/maintenance/integrity": {
|
||||||
|
"get": {
|
||||||
|
"description": "Collect integrity checks and other heuristics about local data.",
|
||||||
|
"operationId": "integrityCheck",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MaintenanceIntegrityResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Get integrity and heuristics",
|
||||||
|
"tags": [
|
||||||
|
"Maintenance (admin)"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v9.9.9",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v9.9.9",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/maintenance/login": {
|
"/admin/maintenance/login": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Login with maintenance token or cookie to receive current information and perform further actions.",
|
"description": "Login with maintenance token or cookie to receive current information and perform further actions.",
|
||||||
@@ -16807,6 +16841,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"MaintenanceIntegrityResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"storageHeuristics": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"storageIntegrity": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"storageHeuristics",
|
||||||
|
"storageIntegrity"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"MaintenanceListBackupsResponseDto": {
|
"MaintenanceListBackupsResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"backups": {
|
"backups": {
|
||||||
|
|||||||
@@ -46,11 +46,14 @@ export type SetMaintenanceModeDto = {
|
|||||||
};
|
};
|
||||||
export type MaintenanceListBackupsResponseDto = {
|
export type MaintenanceListBackupsResponseDto = {
|
||||||
backups: string[];
|
backups: string[];
|
||||||
failedBackups: string[];
|
|
||||||
};
|
};
|
||||||
export type MaintenanceUploadBackupDto = {
|
export type MaintenanceUploadBackupDto = {
|
||||||
file?: Blob;
|
file?: Blob;
|
||||||
};
|
};
|
||||||
|
export type MaintenanceIntegrityResponseDto = {
|
||||||
|
storageHeuristics: object;
|
||||||
|
storageIntegrity: object;
|
||||||
|
};
|
||||||
export type MaintenanceLoginDto = {
|
export type MaintenanceLoginDto = {
|
||||||
token?: string;
|
token?: string;
|
||||||
};
|
};
|
||||||
@@ -1901,6 +1904,30 @@ export function deleteBackup({ filename }: {
|
|||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Download backup
|
||||||
|
*/
|
||||||
|
export function downloadBackup({ filename }: {
|
||||||
|
filename: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||||
|
status: 200;
|
||||||
|
data: Blob;
|
||||||
|
}>(`/admin/maintenance/backups/${encodeURIComponent(filename)}`, {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get integrity and heuristics
|
||||||
|
*/
|
||||||
|
export function integrityCheck(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: MaintenanceIntegrityResponseDto;
|
||||||
|
}>("/admin/maintenance/integrity", {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Log into maintenance mode
|
* Log into maintenance mode
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Endpoint, HistoryBuilder } from 'src/decorators';
|
|||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import {
|
import {
|
||||||
MaintenanceAuthDto,
|
MaintenanceAuthDto,
|
||||||
|
MaintenanceIntegrityResponseDto,
|
||||||
MaintenanceListBackupsResponseDto,
|
MaintenanceListBackupsResponseDto,
|
||||||
MaintenanceLoginDto,
|
MaintenanceLoginDto,
|
||||||
MaintenanceStatusResponseDto,
|
MaintenanceStatusResponseDto,
|
||||||
@@ -25,15 +26,20 @@ import {
|
|||||||
} from 'src/dtos/maintenance.dto';
|
} from 'src/dtos/maintenance.dto';
|
||||||
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
|
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
|
||||||
import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { LoginDetails } from 'src/services/auth.service';
|
import { LoginDetails } from 'src/services/auth.service';
|
||||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||||
|
import { integrityCheck } from 'src/utils/maintenance';
|
||||||
import { respondWithCookie } from 'src/utils/response';
|
import { respondWithCookie } from 'src/utils/response';
|
||||||
import { FilenameParamDto } from 'src/validation';
|
import { FilenameParamDto } from 'src/validation';
|
||||||
|
|
||||||
@ApiTags(ApiTag.Maintenance)
|
@ApiTags(ApiTag.Maintenance)
|
||||||
@Controller('admin/maintenance')
|
@Controller('admin/maintenance')
|
||||||
export class MaintenanceController {
|
export class MaintenanceController {
|
||||||
constructor(private service: MaintenanceService) {}
|
constructor(
|
||||||
|
private service: MaintenanceService,
|
||||||
|
private storageRepository: StorageRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
@@ -47,6 +53,16 @@ export class MaintenanceController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('integrity')
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Get integrity and heuristics',
|
||||||
|
description: 'Collect integrity checks and other heuristics about local data.',
|
||||||
|
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
|
||||||
|
})
|
||||||
|
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
|
||||||
|
return integrityCheck(this.storageRepository);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Log into maintenance mode',
|
summary: 'Log into maintenance mode',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { MaintenanceAction } from 'src/enum';
|
import { MaintenanceAction, StorageFolder } from 'src/enum';
|
||||||
import { ValidateEnum, ValidateString } from 'src/validation';
|
import { ValidateEnum, ValidateString } from 'src/validation';
|
||||||
|
|
||||||
export class SetMaintenanceModeDto {
|
export class SetMaintenanceModeDto {
|
||||||
@@ -28,6 +28,22 @@ export class MaintenanceStatusResponseDto {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MaintenanceIntegrityResponseDto {
|
||||||
|
storageIntegrity!: Record<
|
||||||
|
StorageFolder,
|
||||||
|
{
|
||||||
|
readable: boolean;
|
||||||
|
writable: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
storageHeuristics!: Record<
|
||||||
|
StorageFolder,
|
||||||
|
{
|
||||||
|
files: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
export class MaintenanceListBackupsResponseDto {
|
export class MaintenanceListBackupsResponseDto {
|
||||||
backups!: string[];
|
backups!: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import {
|
import {
|
||||||
MaintenanceAuthDto,
|
MaintenanceAuthDto,
|
||||||
|
MaintenanceIntegrityResponseDto,
|
||||||
MaintenanceListBackupsResponseDto,
|
MaintenanceListBackupsResponseDto,
|
||||||
MaintenanceLoginDto,
|
MaintenanceLoginDto,
|
||||||
MaintenanceStatusResponseDto,
|
MaintenanceStatusResponseDto,
|
||||||
@@ -13,13 +14,18 @@ import { ImmichCookie } from 'src/enum';
|
|||||||
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
|
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
|
||||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||||
import { GetLoginDetails } from 'src/middleware/auth.guard';
|
import { GetLoginDetails } from 'src/middleware/auth.guard';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { LoginDetails } from 'src/services/auth.service';
|
import { LoginDetails } from 'src/services/auth.service';
|
||||||
|
import { integrityCheck } from 'src/utils/maintenance';
|
||||||
import { respondWithCookie } from 'src/utils/response';
|
import { respondWithCookie } from 'src/utils/response';
|
||||||
import { FilenameParamDto } from 'src/validation';
|
import { FilenameParamDto } from 'src/validation';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class MaintenanceWorkerController {
|
export class MaintenanceWorkerController {
|
||||||
constructor(private service: MaintenanceWorkerService) {}
|
constructor(
|
||||||
|
private service: MaintenanceWorkerService,
|
||||||
|
private storageRepository: StorageRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('server/config')
|
@Get('server/config')
|
||||||
getServerConfig(): ServerConfigDto {
|
getServerConfig(): ServerConfigDto {
|
||||||
@@ -31,6 +37,11 @@ export class MaintenanceWorkerController {
|
|||||||
return this.service.status(request.cookies[ImmichCookie.MaintenanceToken]);
|
return this.service.status(request.cookies[ImmichCookie.MaintenanceToken]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('admin/maintenance/integrity')
|
||||||
|
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
|
||||||
|
return integrityCheck(this.storageRepository);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('admin/maintenance/login')
|
@Post('admin/maintenance/login')
|
||||||
async maintenanceLogin(
|
async maintenanceLogin(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { createAdapter } from '@socket.io/redis-adapter';
|
|||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { Server as SocketIO } from 'socket.io';
|
import { Server as SocketIO } from 'socket.io';
|
||||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { MaintenanceAuthDto, MaintenanceIntegrityResponseDto } from 'src/dtos/maintenance.dto';
|
||||||
|
import { StorageFolder } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { AppRestartEvent } from 'src/repositories/event.repository';
|
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
|
|
||||||
export function sendOneShotAppRestart(state: AppRestartEvent): void {
|
export function sendOneShotAppRestart(state: AppRestartEvent): void {
|
||||||
const server = new SocketIO();
|
const server = new SocketIO();
|
||||||
@@ -72,3 +76,47 @@ export async function signMaintenanceJwt(secret: string, data: MaintenanceAuthDt
|
|||||||
export function generateMaintenanceSecret(): string {
|
export function generateMaintenanceSecret(): string {
|
||||||
return randomBytes(64).toString('hex');
|
return randomBytes(64).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function integrityCheck(storageRepository: StorageRepository): Promise<MaintenanceIntegrityResponseDto> {
|
||||||
|
return {
|
||||||
|
storageIntegrity: Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(StorageFolder).map(async (folder) => {
|
||||||
|
const path = join(StorageCore.getBaseFolder(folder), '.immich');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storageRepository.readFile(path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storageRepository.overwriteFile(path, Buffer.from(`${Date.now()}`));
|
||||||
|
return [folder, { readable: true, writable: true }];
|
||||||
|
} catch {
|
||||||
|
return [folder, { readable: true, writable: false }];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return [folder, { readable: false, writable: false }];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
storageHeuristics: Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(StorageFolder).map(async (folder) => {
|
||||||
|
const path = StorageCore.getBaseFolder(folder);
|
||||||
|
const files = await storageRepository.readdir(path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return [
|
||||||
|
folder,
|
||||||
|
{
|
||||||
|
files: files.filter((fn) => fn !== '.immich').length,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} catch {
|
||||||
|
return [folder, { files: 0 }];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
111
web/src/lib/components/maintenance/MaintenanceRestoreFlow.svelte
Normal file
111
web/src/lib/components/maintenance/MaintenanceRestoreFlow.svelte
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MaintenanceBackupsList from '$lib/components/maintenance/MaintenanceBackupsList.svelte';
|
||||||
|
import { integrityCheck, type MaintenanceIntegrityResponseDto } from '@immich/sdk';
|
||||||
|
import { Button, Card, CardBody, Heading, HStack, Icon, Scrollable, Stack, Text } from '@immich/ui';
|
||||||
|
import { mdiAlert, mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose, mdiRefresh } from '@mdi/js';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
end?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let props: Props = $props();
|
||||||
|
let stage = $state(0);
|
||||||
|
|
||||||
|
let integrity: MaintenanceIntegrityResponseDto | undefined = $state();
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
integrity = await integrityCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(reload);
|
||||||
|
|
||||||
|
const i18nMap = {
|
||||||
|
'encoded-video': 'Encoded Video',
|
||||||
|
library: 'Library',
|
||||||
|
upload: 'Upload',
|
||||||
|
profile: 'Profile',
|
||||||
|
thumbs: 'Thumbs',
|
||||||
|
backups: 'Backups',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if stage === 0}
|
||||||
|
<Heading size="large" color="primary" tag="h1">Restore Your Library</Heading>
|
||||||
|
<Text
|
||||||
|
>Before restoring a database backup, you must ensure your library has been restored or is otherwise already present.</Text
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<Stack>
|
||||||
|
{#if integrity}
|
||||||
|
{#each Object.entries(integrity.storageIntegrity) as [folder, { readable, writable }]}
|
||||||
|
<HStack>
|
||||||
|
<Icon
|
||||||
|
icon={writable ? mdiCheck : mdiClose}
|
||||||
|
color={`rgb(var(--immich-ui-${writable ? 'success' : 'danger'}))`}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
>{i18nMap[folder as keyof typeof i18nMap]} ({writable
|
||||||
|
? 'readable and writable'
|
||||||
|
: readable
|
||||||
|
? 'not writable'
|
||||||
|
: 'not readable'})</Text
|
||||||
|
>
|
||||||
|
</HStack>
|
||||||
|
{/each}
|
||||||
|
{#each Object.entries(integrity.storageHeuristics) as [folder, { files }]}
|
||||||
|
{#if folder !== 'backups'}
|
||||||
|
<HStack class="items-start">
|
||||||
|
<Icon
|
||||||
|
class="mt-1"
|
||||||
|
icon={files ? mdiCheck : folder === 'profile' || folder === 'upload' ? mdiClose : mdiAlert}
|
||||||
|
color={`rgb(var(--immich-ui-${files ? 'success' : folder === 'profile' || folder === 'upload' ? 'danger' : 'warning'}))`}
|
||||||
|
/>
|
||||||
|
<Stack gap={0} class="items-start">
|
||||||
|
<Text>
|
||||||
|
{#if files}
|
||||||
|
{i18nMap[folder as keyof typeof i18nMap]} has {files} folder(s)
|
||||||
|
{:else}
|
||||||
|
{i18nMap[folder as keyof typeof i18nMap]} is missing files!
|
||||||
|
{/if}
|
||||||
|
</Text>
|
||||||
|
{#if !files && (folder === 'profile' || folder === 'upload')}
|
||||||
|
<Text variant="italic">You may be missing files</Text>
|
||||||
|
{/if}
|
||||||
|
{#if !files && (folder === 'encoded-video' || folder === 'thumbs')}
|
||||||
|
<Text variant="italic">You can regenerate these later in settings</Text>
|
||||||
|
{/if}
|
||||||
|
{#if !files && folder === 'library'}
|
||||||
|
<Text variant="italic">Using storage template? You may be missing files</Text>
|
||||||
|
{/if}
|
||||||
|
</Stack>
|
||||||
|
</HStack>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<Button leadingIcon={mdiRefresh} variant="ghost" onclick={reload}>Refresh</Button>
|
||||||
|
{:else}
|
||||||
|
<HStack>
|
||||||
|
<Icon icon={mdiRefresh} color="rgb(var(--immich-ui-primary))" />
|
||||||
|
<Text>Loading integrity checks and heuristics...</Text>
|
||||||
|
</HStack>
|
||||||
|
{/if}
|
||||||
|
</Stack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Text>If this looks correct, continue to restoring a backup!</Text>
|
||||||
|
<HStack>
|
||||||
|
<Button onclick={props.end} variant="ghost">Cancel</Button>
|
||||||
|
<Button onclick={() => stage++} trailingIcon={mdiArrowRight}>Next</Button>
|
||||||
|
</HStack>
|
||||||
|
{:else}
|
||||||
|
<Heading size="large" color="primary" tag="h1">Restore From Backup</Heading>
|
||||||
|
<Scrollable class="max-h-[320px]">
|
||||||
|
<MaintenanceBackupsList />
|
||||||
|
</Scrollable>
|
||||||
|
<HStack>
|
||||||
|
<Button onclick={props.end} variant="ghost">Cancel</Button>
|
||||||
|
<Button onclick={() => stage--} variant="ghost" leadingIcon={mdiArrowLeft}>Back</Button>
|
||||||
|
</HStack>
|
||||||
|
{/if}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
|
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
|
||||||
import MaintenanceBackupsList from '$lib/components/maintenance/MaintenanceBackupsList.svelte';
|
import MaintenanceRestoreFlow from '$lib/components/maintenance/MaintenanceRestoreFlow.svelte';
|
||||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||||
import { maintenanceStore } from '$lib/stores/maintenance.store';
|
import { maintenanceStore } from '$lib/stores/maintenance.store';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
@@ -37,7 +37,10 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AuthPageLayout withBackdrop={$status?.action === MaintenanceAction.Start}>
|
<AuthPageLayout
|
||||||
|
withHeader={$status?.action !== MaintenanceAction.RestoreDatabase && !$status?.task}
|
||||||
|
withBackdrop={$status?.action === MaintenanceAction.Start}
|
||||||
|
>
|
||||||
<div class="flex flex-col place-items-center text-center gap-4">
|
<div class="flex flex-col place-items-center text-center gap-4">
|
||||||
{#if $status?.action === MaintenanceAction.RestoreDatabase && $status.task}
|
{#if $status?.action === MaintenanceAction.RestoreDatabase && $status.task}
|
||||||
<Heading size="large" color="primary" tag="h1">Restoring Database</Heading>
|
<Heading size="large" color="primary" tag="h1">Restoring Database</Heading>
|
||||||
@@ -57,11 +60,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if $status?.action === MaintenanceAction.RestoreDatabase && $auth}
|
{:else if $status?.action === MaintenanceAction.RestoreDatabase && $auth}
|
||||||
<Heading size="large" color="primary" tag="h1">Restore From Backup</Heading>
|
<MaintenanceRestoreFlow {end} />
|
||||||
<Scrollable class="max-h-[320px]">
|
|
||||||
<MaintenanceBackupsList />
|
|
||||||
</Scrollable>
|
|
||||||
<Button onclick={end}>Cancel</Button>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Heading size="large" color="primary" tag="h1">{$t('maintenance_title')}</Heading>
|
<Heading size="large" color="primary" tag="h1">{$t('maintenance_title')}</Heading>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
Reference in New Issue
Block a user