feat: download csv report, download file, delete file

This commit is contained in:
izzy
2025-12-01 14:20:38 +00:00
parent fec8923431
commit 06fcd54b9f
10 changed files with 572 additions and 28 deletions

View File

@@ -161,7 +161,10 @@ 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* | [**deleteIntegrityReportFile**](doc//MaintenanceAdminApi.md#deleteintegrityreportfile) | **DELETE** /admin/maintenance/integrity/report/{id}/file | Delete associated file if it exists
*MaintenanceAdminApi* | [**getIntegrityReport**](doc//MaintenanceAdminApi.md#getintegrityreport) | **POST** /admin/maintenance/integrity/report | Get integrity report by type
*MaintenanceAdminApi* | [**getIntegrityReportCsv**](doc//MaintenanceAdminApi.md#getintegrityreportcsv) | **GET** /admin/maintenance/integrity/report/{type}/csv | Export integrity report by type as CSV
*MaintenanceAdminApi* | [**getIntegrityReportFile**](doc//MaintenanceAdminApi.md#getintegrityreportfile) | **GET** /admin/maintenance/integrity/report/{id}/file | Download the orphan/broken file if one exists
*MaintenanceAdminApi* | [**getIntegrityReportSummary**](doc//MaintenanceAdminApi.md#getintegrityreportsummary) | **GET** /admin/maintenance/integrity/summary | Get integrity report summary
*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

View File

@@ -16,6 +16,55 @@ class MaintenanceAdminApi {
final ApiClient apiClient;
/// Delete associated file if it exists
///
/// ...
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> deleteIntegrityReportFileWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/integrity/report/{id}/file'
.replaceAll('{id}', id);
// 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 associated file if it exists
///
/// ...
///
/// Parameters:
///
/// * [String] id (required):
Future<void> deleteIntegrityReportFile(String id,) async {
final response = await deleteIntegrityReportFileWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Get integrity report by type
///
/// ...
@@ -72,6 +121,120 @@ class MaintenanceAdminApi {
return null;
}
/// Export integrity report by type as CSV
///
/// ...
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [IntegrityReportType] type (required):
Future<Response> getIntegrityReportCsvWithHttpInfo(IntegrityReportType type,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/integrity/report/{type}/csv'
.replaceAll('{type}', type.toString());
// 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,
);
}
/// Export integrity report by type as CSV
///
/// ...
///
/// Parameters:
///
/// * [IntegrityReportType] type (required):
Future<MultipartFile?> getIntegrityReportCsv(IntegrityReportType type,) async {
final response = await getIntegrityReportCsvWithHttpInfo(type,);
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;
}
/// Download the orphan/broken file if one exists
///
/// ...
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> getIntegrityReportFileWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/integrity/report/{id}/file'
.replaceAll('{id}', id);
// 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 the orphan/broken file if one exists
///
/// ...
///
/// Parameters:
///
/// * [String] id (required):
Future<MultipartFile?> getIntegrityReportFile(String id,) async {
final response = await getIntegrityReportFileWithHttpInfo(id,);
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 report summary
///
/// ...

View File

@@ -429,6 +429,169 @@
"x-immich-state": "Alpha"
}
},
"/admin/maintenance/integrity/report/{id}/file": {
"delete": {
"description": "...",
"operationId": "deleteIntegrityReportFile",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Delete associated file if it exists",
"tags": [
"Maintenance (admin)"
],
"x-immich-admin-only": true,
"x-immich-history": [
{
"version": "v9.9.9",
"state": "Added"
},
{
"version": "v9.9.9",
"state": "Alpha"
}
],
"x-immich-permission": "maintenance",
"x-immich-state": "Alpha"
},
"get": {
"description": "...",
"operationId": "getIntegrityReportFile",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Download the orphan/broken file if one exists",
"tags": [
"Maintenance (admin)"
],
"x-immich-admin-only": true,
"x-immich-history": [
{
"version": "v9.9.9",
"state": "Added"
},
{
"version": "v9.9.9",
"state": "Alpha"
}
],
"x-immich-permission": "maintenance",
"x-immich-state": "Alpha"
}
},
"/admin/maintenance/integrity/report/{type}/csv": {
"get": {
"description": "...",
"operationId": "getIntegrityReportCsv",
"parameters": [
{
"name": "type",
"required": true,
"in": "path",
"schema": {
"$ref": "#/components/schemas/IntegrityReportType"
}
}
],
"responses": {
"200": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Export integrity report by type as CSV",
"tags": [
"Maintenance (admin)"
],
"x-immich-admin-only": true,
"x-immich-history": [
{
"version": "v9.9.9",
"state": "Added"
},
{
"version": "v9.9.9",
"state": "Alpha"
}
],
"x-immich-permission": "maintenance",
"x-immich-state": "Alpha"
}
},
"/admin/maintenance/integrity/summary": {
"get": {
"description": "...",

View File

@@ -1909,6 +1909,43 @@ export function getIntegrityReport({ maintenanceGetIntegrityReportDto }: {
body: maintenanceGetIntegrityReportDto
})));
}
/**
* Delete associated file if it exists
*/
export function deleteIntegrityReportFile({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/admin/maintenance/integrity/report/${encodeURIComponent(id)}/file`, {
...opts,
method: "DELETE"
}));
}
/**
* Download the orphan/broken file if one exists
*/
export function getIntegrityReportFile({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchBlob<{
status: 200;
data: Blob;
}>(`/admin/maintenance/integrity/report/${encodeURIComponent(id)}/file`, {
...opts
}));
}
/**
* Export integrity report by type as CSV
*/
export function getIntegrityReportCsv({ $type }: {
$type: IntegrityReportType;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchBlob<{
status: 200;
data: Blob;
}>(`/admin/maintenance/integrity/report/${encodeURIComponent($type)}/csv`, {
...opts
}));
}
/**
* Get integrity report summary
*/

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Body, Controller, Get, Post, Res } from '@nestjs/common';
import { BadRequestException, Body, Controller, Delete, Get, Next, Param, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { NextFunction, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -12,15 +12,21 @@ import {
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { LoginDetails } from 'src/services/auth.service';
import { MaintenanceService } from 'src/services/maintenance.service';
import { sendFile } from 'src/utils/file';
import { respondWithCookie } from 'src/utils/response';
import { IntegrityReportTypeParamDto, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Maintenance)
@Controller('admin/maintenance')
export class MaintenanceController {
constructor(private service: MaintenanceService) {}
constructor(
private logger: LoggingRepository,
private service: MaintenanceService,
) {}
@Post('login')
@Endpoint({
@@ -75,4 +81,47 @@ export class MaintenanceController {
getIntegrityReport(@Body() dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return this.service.getIntegrityReport(dto);
}
@Get('integrity/report/:type/csv')
@Endpoint({
summary: 'Export integrity report by type as CSV',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@FileResponse()
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReportCsv(@Param() { type }: IntegrityReportTypeParamDto, @Res() res: Response): void {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Cache-Control', 'private, no-cache, no-transform');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(`${Date.now()}-${type}.csv`)}"`);
this.service.getIntegrityReportCsv(type).pipe(res);
}
@Get('integrity/report/:id/file')
@Endpoint({
summary: 'Download the orphan/broken file if one exists',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@FileResponse()
@Authenticated({ permission: Permission.Maintenance, admin: true })
async getIntegrityReportFile(
@Param() { id }: UUIDParamDto,
@Res() res: Response,
@Next() next: NextFunction,
): Promise<void> {
await sendFile(res, next, () => this.service.getIntegrityReportFile(id), this.logger);
}
@Delete('integrity/report/:id/file')
@Endpoint({
summary: 'Delete associated file if it exists',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
async deleteIntegrityReportFile(@Param() { id }: UUIDParamDto): Promise<void> {
await this.service.deleteIntegrityReportFile(id);
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { Readable } from 'node:stream';
import {
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
@@ -23,8 +24,16 @@ export class IntegrityReportRepository {
.executeTakeFirst();
}
async getIntegrityReportSummary(): Promise<MaintenanceIntegrityReportSummaryResponseDto> {
return await this.db
getById(id: string) {
return this.db
.selectFrom('integrity_report')
.selectAll('integrity_report')
.where('id', '=', id)
.executeTakeFirstOrThrow();
}
getIntegrityReportSummary(): Promise<MaintenanceIntegrityReportSummaryResponseDto> {
return this.db
.selectFrom('integrity_report')
.select((eb) =>
eb.fn
@@ -58,6 +67,28 @@ export class IntegrityReportRepository {
};
}
getIntegrityReportCsv(type: IntegrityReportType): Readable {
const items = this.db
.selectFrom('integrity_report')
.select(['id', 'type', 'path'])
.where('type', '=', type)
.orderBy('createdAt', 'desc')
.stream();
// very rudimentary csv serialiser
async function* generator() {
yield 'id,type,path\n';
for await (const item of items) {
// no expectation of particularly bad filenames
// but they could potentially have a newline or quote character
yield `${item.id},${item.type},"${item.path.replace(/"/g, '\\"')}"\n`;
}
}
return Readable.from(generator());
}
deleteById(id: string) {
return this.db.deleteFrom('integrity_report').where('id', '=', id).execute();
}

View File

@@ -1,4 +1,6 @@
import { Injectable } from '@nestjs/common';
import { basename } from 'node:path';
import { Readable } from 'node:stream';
import { OnEvent } from 'src/decorators';
import {
MaintenanceAuthDto,
@@ -6,9 +8,10 @@ import {
MaintenanceIntegrityReportResponseDto,
MaintenanceIntegrityReportSummaryResponseDto,
} from 'src/dtos/maintenance.dto';
import { SystemMetadataKey } from 'src/enum';
import { CacheControl, IntegrityReportType, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types';
import { ImmichFileResponse } from 'src/utils/file';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
@@ -63,4 +66,25 @@ export class MaintenanceService extends BaseService {
getIntegrityReport(dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return this.integrityReportRepository.getIntegrityReport(dto);
}
getIntegrityReportCsv(type: IntegrityReportType): Readable {
return this.integrityReportRepository.getIntegrityReportCsv(type);
}
async getIntegrityReportFile(id: string): Promise<ImmichFileResponse> {
const { path } = await this.integrityReportRepository.getById(id);
return new ImmichFileResponse({
path,
fileName: basename(path),
contentType: 'application/octet-stream',
cacheControl: CacheControl.PrivateWithoutCache,
});
}
async deleteIntegrityReportFile(id: string): Promise<void> {
const { path } = await this.integrityReportRepository.getById(id);
await this.storageRepository.unlink(path);
await this.integrityReportRepository.deleteById(id);
}
}

View File

@@ -33,6 +33,7 @@ import {
import { CronJob } from 'cron';
import { DateTime } from 'luxon';
import sanitize from 'sanitize-filename';
import { IntegrityReportType } from 'src/enum';
import { isIP, isIPRange } from 'validator';
@Injectable()
@@ -96,6 +97,12 @@ export class UUIDAssetIDParamDto {
assetId!: string;
}
export class IntegrityReportTypeParamDto {
@IsNotEmpty()
@ApiProperty({ enum: IntegrityReportType, enumName: 'IntegrityReportType' })
type!: IntegrityReportType;
}
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => {
const { optional, nullable, emptyToNull, ...apiPropertyOptions } = {

View File

@@ -1,9 +1,21 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import { AppRoute } from '$lib/constants';
import { IconButton } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { handleError } from '$lib/utils/handle-error';
import { deleteIntegrityReportFile, getBaseUrl, IntegrityReportType } from '@immich/sdk';
import {
Button,
HStack,
IconButton,
menuManager,
modalManager,
Text,
type ContextMenuBaseProps,
type MenuItems,
} from '@immich/ui';
import { mdiDotsVertical, mdiDownload, mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types';
interface Props {
@@ -12,17 +24,63 @@
let { data }: Props = $props();
// async function switchToMaintenance() {
// try {
// await setMaintenanceMode({
// setMaintenanceModeDto: {
// action: MaintenanceAction.Start,
// },
// });
// } catch (error) {
// handleError(error, $t('admin.maintenance_start_error'));
// }
// }
let deleting = new SvelteSet();
let integrityReport = $state(data.integrityReport.items);
async function remove(id: string) {
const confirm = await modalManager.showDialog({
confirmText: $t('delete'),
});
if (confirm) {
try {
deleting.add(id);
await deleteIntegrityReportFile({
id,
});
integrityReport = integrityReport.filter((report) => report.id !== id);
} catch (error) {
handleError(error, 'Failed to delete file!');
} finally {
deleting.delete(id);
}
}
}
function download(reportId: string) {
location.href = `${getBaseUrl()}/admin/maintenance/integrity/report/${reportId}/file`;
}
const handleOpen = async (event: Event, props: Partial<ContextMenuBaseProps>, reportId: string) => {
const items: MenuItems = [];
if (data.type === IntegrityReportType.OrphanFile || data.type === IntegrityReportType.ChecksumMismatch) {
items.push({
title: $t('download'),
icon: mdiDownload,
onAction() {
void download(reportId);
},
});
}
if (data.type === IntegrityReportType.OrphanFile) {
items.push({
title: $t('delete'),
icon: mdiTrashCanOutline,
color: 'danger',
onAction() {
void remove(reportId);
},
});
}
await menuManager.show({
...props,
target: event.currentTarget as HTMLElement,
items,
});
};
</script>
<AdminPageLayout
@@ -32,19 +90,18 @@
{ title: data.meta.title },
]}
>
<!-- {#snippet buttons()}
{#snippet buttons()}
<HStack gap={1}>
<Button
leadingIcon={mdiProgressWrench}
size="small"
variant="ghost"
color="secondary"
onclick={switchToMaintenance}
href={`${getBaseUrl()}/admin/maintenance/integrity/report/${data.type}/csv`}
>
<Text class="hidden md:block">{$t('admin.maintenance_start')}</Text>
<Text class="hidden md:block">Download CSV</Text>
</Button>
</HStack>
{/snippet} -->
{/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]">
@@ -60,11 +117,20 @@
<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, path } (id)}
<tr class="flex py-1 w-full place-items-center even:bg-subtle/20 odd:bg-subtle/80">
{#each integrityReport as { id, path } (id)}
<tr
class={`flex py-1 w-full place-items-center even:bg-subtle/20 odd:bg-subtle/80 ${deleting.has(id) ? 'text-gray-500' : ''}`}
>
<td class="w-7/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
<IconButton
color="secondary"
icon={mdiDotsVertical}
variant="ghost"
onclick={(event: Event) => handleOpen(event, { position: 'top-right' }, id)}
aria-label={$t('open')}
disabled={deleting.has(id)}
/></td
>
</tr>
{/each}

View File

@@ -15,6 +15,7 @@ export const load = (async ({ params, url }) => {
const $t = await getFormatter();
return {
type,
integrityReport,
meta: {
title: $t(`admin.maintenance_integrity_${type}`),