feat: view integrity report in maintenance page (cherry picked)

This commit is contained in:
izzy
2025-11-27 17:53:20 +00:00
parent 0fdc7b4448
commit d3abed3414
14 changed files with 489 additions and 1 deletions

View File

@@ -161,6 +161,7 @@ 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* | [**getIntegrityReport**](doc//MaintenanceAdminApi.md#getintegrityreport) | **GET** /admin/maintenance | Get integrity report
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
@@ -416,6 +417,8 @@ Class | Method | HTTP request | Description
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
- [MaintenanceAction](doc//MaintenanceAction.md)
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
- [MaintenanceIntegrityReportDto](doc//MaintenanceIntegrityReportDto.md)
- [MaintenanceIntegrityReportResponseDto](doc//MaintenanceIntegrityReportResponseDto.md)
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
- [ManualJobName](doc//ManualJobName.md)
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)

View File

@@ -168,6 +168,8 @@ 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_integrity_report_dto.dart';
part 'model/maintenance_integrity_report_response_dto.dart';
part 'model/maintenance_login_dto.dart';
part 'model/manual_job_name.dart';
part 'model/map_marker_response_dto.dart';

View File

@@ -16,6 +16,54 @@ class MaintenanceAdminApi {
final ApiClient apiClient;
/// Get integrity report
///
/// ...
///
/// Note: This method returns the HTTP [Response].
Future<Response> getIntegrityReportWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance';
// 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 report
///
/// ...
Future<MaintenanceIntegrityReportResponseDto?> getIntegrityReport() async {
final response = await getIntegrityReportWithHttpInfo();
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), 'MaintenanceIntegrityReportResponseDto',) as MaintenanceIntegrityReportResponseDto;
}
return null;
}
/// Log into maintenance mode
///
/// Login with maintenance token or cookie to receive current information and perform further actions.

View File

@@ -384,6 +384,10 @@ class ApiClient {
return MaintenanceActionTypeTransformer().decode(value);
case 'MaintenanceAuthDto':
return MaintenanceAuthDto.fromJson(value);
case 'MaintenanceIntegrityReportDto':
return MaintenanceIntegrityReportDto.fromJson(value);
case 'MaintenanceIntegrityReportResponseDto':
return MaintenanceIntegrityReportResponseDto.fromJson(value);
case 'MaintenanceLoginDto':
return MaintenanceLoginDto.fromJson(value);
case 'ManualJobName':

View File

@@ -0,0 +1,192 @@
//
// 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 MaintenanceIntegrityReportDto {
/// Returns a new [MaintenanceIntegrityReportDto] instance.
MaintenanceIntegrityReportDto({
required this.id,
required this.path,
required this.type,
});
String id;
String path;
MaintenanceIntegrityReportDtoTypeEnum type;
@override
bool operator ==(Object other) => identical(this, other) || other is MaintenanceIntegrityReportDto &&
other.id == id &&
other.path == path &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode) +
(path.hashCode) +
(type.hashCode);
@override
String toString() => 'MaintenanceIntegrityReportDto[id=$id, path=$path, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'id'] = this.id;
json[r'path'] = this.path;
json[r'type'] = this.type;
return json;
}
/// Returns a new [MaintenanceIntegrityReportDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceIntegrityReportDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceIntegrityReportDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceIntegrityReportDto(
id: mapValueOfType<String>(json, r'id')!,
path: mapValueOfType<String>(json, r'path')!,
type: MaintenanceIntegrityReportDtoTypeEnum.fromJson(json[r'type'])!,
);
}
return null;
}
static List<MaintenanceIntegrityReportDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceIntegrityReportDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceIntegrityReportDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceIntegrityReportDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceIntegrityReportDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceIntegrityReportDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceIntegrityReportDto-objects as value to a dart map
static Map<String, List<MaintenanceIntegrityReportDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceIntegrityReportDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceIntegrityReportDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'id',
'path',
'type',
};
}
class MaintenanceIntegrityReportDtoTypeEnum {
/// Instantiate a new enum with the provided [value].
const MaintenanceIntegrityReportDtoTypeEnum._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const orphanFile = MaintenanceIntegrityReportDtoTypeEnum._(r'orphan_file');
static const missingFile = MaintenanceIntegrityReportDtoTypeEnum._(r'missing_file');
static const checksumMismatch = MaintenanceIntegrityReportDtoTypeEnum._(r'checksum_mismatch');
/// List of all possible values in this [enum][MaintenanceIntegrityReportDtoTypeEnum].
static const values = <MaintenanceIntegrityReportDtoTypeEnum>[
orphanFile,
missingFile,
checksumMismatch,
];
static MaintenanceIntegrityReportDtoTypeEnum? fromJson(dynamic value) => MaintenanceIntegrityReportDtoTypeEnumTypeTransformer().decode(value);
static List<MaintenanceIntegrityReportDtoTypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceIntegrityReportDtoTypeEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceIntegrityReportDtoTypeEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [MaintenanceIntegrityReportDtoTypeEnum] to String,
/// and [decode] dynamic data back to [MaintenanceIntegrityReportDtoTypeEnum].
class MaintenanceIntegrityReportDtoTypeEnumTypeTransformer {
factory MaintenanceIntegrityReportDtoTypeEnumTypeTransformer() => _instance ??= const MaintenanceIntegrityReportDtoTypeEnumTypeTransformer._();
const MaintenanceIntegrityReportDtoTypeEnumTypeTransformer._();
String encode(MaintenanceIntegrityReportDtoTypeEnum data) => data.value;
/// Decodes a [dynamic value][data] to a MaintenanceIntegrityReportDtoTypeEnum.
///
/// 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.
MaintenanceIntegrityReportDtoTypeEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'orphan_file': return MaintenanceIntegrityReportDtoTypeEnum.orphanFile;
case r'missing_file': return MaintenanceIntegrityReportDtoTypeEnum.missingFile;
case r'checksum_mismatch': return MaintenanceIntegrityReportDtoTypeEnum.checksumMismatch;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [MaintenanceIntegrityReportDtoTypeEnumTypeTransformer] instance.
static MaintenanceIntegrityReportDtoTypeEnumTypeTransformer? _instance;
}

View File

@@ -0,0 +1,99 @@
//
// 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 MaintenanceIntegrityReportResponseDto {
/// Returns a new [MaintenanceIntegrityReportResponseDto] instance.
MaintenanceIntegrityReportResponseDto({
this.items = const [],
});
List<MaintenanceIntegrityReportDto> items;
@override
bool operator ==(Object other) => identical(this, other) || other is MaintenanceIntegrityReportResponseDto &&
_deepEquality.equals(other.items, items);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(items.hashCode);
@override
String toString() => 'MaintenanceIntegrityReportResponseDto[items=$items]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'items'] = this.items;
return json;
}
/// Returns a new [MaintenanceIntegrityReportResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceIntegrityReportResponseDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceIntegrityReportResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceIntegrityReportResponseDto(
items: MaintenanceIntegrityReportDto.listFromJson(json[r'items']),
);
}
return null;
}
static List<MaintenanceIntegrityReportResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceIntegrityReportResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceIntegrityReportResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceIntegrityReportResponseDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceIntegrityReportResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceIntegrityReportResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceIntegrityReportResponseDto-objects as value to a dart map
static Map<String, List<MaintenanceIntegrityReportResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceIntegrityReportResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceIntegrityReportResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'items',
};
}

View File

@@ -16967,6 +16967,9 @@
},
"MaintenanceIntegrityReportDto": {
"properties": {
"id": {
"type": "string"
},
"path": {
"type": "string"
},
@@ -16980,6 +16983,7 @@
}
},
"required": [
"id",
"path",
"type"
],

View File

@@ -40,6 +40,14 @@ export type ActivityStatisticsResponseDto = {
comments: number;
likes: number;
};
export type MaintenanceIntegrityReportDto = {
id: string;
path: string;
"type": Type;
};
export type MaintenanceIntegrityReportResponseDto = {
items: MaintenanceIntegrityReportDto[];
};
export type SetMaintenanceModeDto = {
action: MaintenanceAction;
};
@@ -1866,6 +1874,17 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) {
method: "POST"
}));
}
/**
* Get integrity report
*/
export function getIntegrityReport(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: MaintenanceIntegrityReportResponseDto;
}>("/admin/maintenance", {
...opts
}));
}
/**
* Set maintenance mode
*/
@@ -5154,6 +5173,11 @@ export enum UserAvatarColor {
Gray = "gray",
Amber = "amber"
}
export enum Type {
OrphanFile = "orphan_file",
MissingFile = "missing_file",
ChecksumMismatch = "checksum_mismatch"
}
export enum MaintenanceAction {
Start = "start",
End = "end"

View File

@@ -26,6 +26,7 @@ export class MaintenanceGetIntegrityReportDto {
}
class MaintenanceIntegrityReportDto {
id!: string;
@IsEnum(IntegrityReportType)
type!: IntegrityReportType;
path!: string;

View File

@@ -22,7 +22,7 @@ export class IntegrityReportRepository {
return {
items: await this.db
.selectFrom('integrity_report')
.select(['type', 'path'])
.select(['id', 'type', 'path'])
.orderBy('createdAt', 'desc')
.execute(),
};

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_SETTINGS = '/admin/maintenance',
ADMIN_STATS = '/admin/server-status',
ADMIN_JOBS = '/admin/jobs-status',
ADMIN_REPAIR = '/admin/repair',

View File

@@ -11,6 +11,7 @@
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('jobs')} href={AppRoute.ADMIN_JOBS} icon={mdiSync} />
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<NavbarItem title={$t('admin.maintenance_settings')} href={AppRoute.ADMIN_MAINTENANCE_SETTINGS} icon={mdiCog} />
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
</div>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import SettingAccordionState from '$lib/components/shared-components/settings/setting-accordion-state.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import { QueryParameter } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { MaintenanceAction, setMaintenanceMode } from '@immich/sdk';
import { Button, HStack, IconButton, Text } from '@immich/ui';
import { mdiDotsVertical, mdiProgressWrench, mdiRefresh } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
async function switchToMaintenance() {
try {
await setMaintenanceMode({
setMaintenanceModeDto: {
action: MaintenanceAction.Start,
},
});
} catch (error) {
handleError(error, $t('admin.maintenance_start_error'));
}
}
</script>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={1}>
<Button
leadingIcon={mdiProgressWrench}
size="small"
variant="ghost"
color="secondary"
onclick={switchToMaintenance}
>
<Text class="hidden md:block">{$t('admin.maintenance_start')}</Text>
</Button>
</HStack>
{/snippet}
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-[850px]">
<SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
<SettingAccordion
title="Integrity Report"
subtitle={`There are ${data.integrityReport.items.length} unresolved issues!`}
icon={mdiRefresh}
key="integrity-report"
>
<table class="mt-5 w-full text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="w-2/8 text-center text-sm font-medium">Reason</th>
<th class="w-6/8 text-center text-sm font-medium">File</th>
<th class="w-1/8"></th>
</tr>
</thead>
<tbody
class="block max-h-80 w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
{#each data.integrityReport.items as { id, type, path } (id)}
<tr class="flex py-1 w-full place-items-center even:bg-subtle/20 odd:bg-subtle/80">
<td class="w-2/8 text-ellipsis text-center px-2 text-sm">
{#if type === 'orphan_file'}
Orphaned File
{:else if type === 'missing_file'}
Missing File
{:else if type === 'checksum_mismatch'}
Checksum Mismatch
{/if}
</td>
<td class="w-6/8 text-ellipsis text-left px-2 text-sm select-all">{path}</td>
<td class="w-1/8 text-ellipsis text-right flex justify-end px-2">
<IconButton aria-label="Open" color="secondary" icon={mdiDotsVertical} variant="ghost" /></td
>
</tr>
{/each}
</tbody>
</table>
</SettingAccordion>
</SettingAccordionState>
</section>
</section>
</AdminPageLayout>

View File

@@ -0,0 +1,17 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getIntegrityReport } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const integrityReport = await getIntegrityReport();
const $t = await getFormatter();
return {
integrityReport,
meta: {
title: $t('admin.system_settings'),
},
};
}) satisfies PageLoad;