feat: sync status to web app

This commit is contained in:
izzy
2025-11-20 15:47:30 +00:00
parent f69c49a60f
commit 56a4159295
14 changed files with 300 additions and 105 deletions

View File

@@ -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)

View File

@@ -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';

View File

@@ -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<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
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<void> 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<Response> listBackupsWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/admin/maintenance/backups/list';
// 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,
);
}
/// List backups
///
/// Get the list of the successful and failed backups
Future<MaintenanceListBackupsResponseDto?> 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.

View File

@@ -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':

View File

@@ -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<String> backups;
List<String> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return MaintenanceListBackupsResponseDto(
backups: json[r'backups'] is Iterable
? (json[r'backups'] as Iterable).cast<String>().toList(growable: false)
: const [],
failedBackups: json[r'failedBackups'] is Iterable
? (json[r'failedBackups'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<MaintenanceListBackupsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceListBackupsResponseDto>[];
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<String, MaintenanceListBackupsResponseDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceListBackupsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<MaintenanceListBackupsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceListBackupsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'backups',
'failedBackups',
};
}

View File

@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return MaintenanceStatusResponseDto(
action: MaintenanceStatusResponseDtoActionEnum.fromJson(json[r'action']),
action: MaintenanceAction.fromJson(json[r'action'])!,
error: mapValueOfType<String>(json, r'error'),
progress: num.parse('${json[r'progress']}'),
task: mapValueOfType<String>(json, r'task'),
@@ -148,83 +144,7 @@ class MaintenanceStatusResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'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 = <MaintenanceStatusResponseDtoActionEnum>[
start,
end,
restoreDatabase,
];
static MaintenanceStatusResponseDtoActionEnum? fromJson(dynamic value) => MaintenanceStatusResponseDtoActionEnumTypeTransformer().decode(value);
static List<MaintenanceStatusResponseDtoActionEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceStatusResponseDtoActionEnum>[];
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;
}

View File

@@ -16705,12 +16705,11 @@
"MaintenanceStatusResponseDto": {
"properties": {
"action": {
"enum": [
"start",
"end",
"restore_database"
],
"type": "string"
"allOf": [
{
"$ref": "#/components/schemas/MaintenanceAction"
}
]
},
"error": {
"type": "string"

View File

@@ -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"
}

View File

@@ -19,6 +19,7 @@ export class MaintenanceAuthDto {
}
export class MaintenanceStatusResponseDto {
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' })
action!: MaintenanceAction;
progress?: number;

View File

@@ -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',

View File

@@ -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<MaintenanceAuthDto>(),
status: writable<MaintenanceStatusResponseDto>(),
};

View File

@@ -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<Events> = 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) {

View File

@@ -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();
}
}
};

View File

@@ -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;