From 5028c56ad8c06c121c2a107e7eca3ce28fdf7be5 Mon Sep 17 00:00:00 2001 From: izzy Date: Thu, 18 Dec 2025 14:08:06 +0000 Subject: [PATCH] feat: paginate integrity report results --- .../lib/model/integrity_get_report_dto.dart | 38 +++++++++++- .../model/integrity_report_response_dto.dart | 10 ++- open-api/immich-openapi-specs.json | 12 ++++ open-api/typescript-sdk/src/fetch-client.ts | 3 + .../src/controllers/integrity.controller.ts | 2 +- server/src/dtos/integrity.dto.ts | 20 ++++-- .../src/repositories/integrity.repository.ts | 16 ++++- server/src/services/integrity.service.spec.ts | 2 +- server/src/services/integrity.service.ts | 12 ++-- .../integrity-report/[type]/+page.svelte | 62 +++++++++++++++++-- 10 files changed, 154 insertions(+), 23 deletions(-) diff --git a/mobile/openapi/lib/model/integrity_get_report_dto.dart b/mobile/openapi/lib/model/integrity_get_report_dto.dart index 75fb7bb952..0d57e3b5b1 100644 --- a/mobile/openapi/lib/model/integrity_get_report_dto.dart +++ b/mobile/openapi/lib/model/integrity_get_report_dto.dart @@ -13,25 +13,59 @@ part of openapi.api; class IntegrityGetReportDto { /// Returns a new [IntegrityGetReportDto] instance. IntegrityGetReportDto({ + this.page, + this.size, required this.type, }); + /// Minimum value: 1 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? page; + + /// Minimum value: 1 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? size; + IntegrityReportType type; @override bool operator ==(Object other) => identical(this, other) || other is IntegrityGetReportDto && + other.page == page && + other.size == size && other.type == type; @override int get hashCode => // ignore: unnecessary_parenthesis + (page == null ? 0 : page!.hashCode) + + (size == null ? 0 : size!.hashCode) + (type.hashCode); @override - String toString() => 'IntegrityGetReportDto[type=$type]'; + String toString() => 'IntegrityGetReportDto[page=$page, size=$size, type=$type]'; Map toJson() { final json = {}; + if (this.page != null) { + json[r'page'] = this.page; + } else { + // json[r'page'] = null; + } + if (this.size != null) { + json[r'size'] = this.size; + } else { + // json[r'size'] = null; + } json[r'type'] = this.type; return json; } @@ -45,6 +79,8 @@ class IntegrityGetReportDto { final json = value.cast(); return IntegrityGetReportDto( + page: num.parse('${json[r'page']}'), + size: num.parse('${json[r'size']}'), type: IntegrityReportType.fromJson(json[r'type'])!, ); } diff --git a/mobile/openapi/lib/model/integrity_report_response_dto.dart b/mobile/openapi/lib/model/integrity_report_response_dto.dart index 45213f39e0..083ef88c63 100644 --- a/mobile/openapi/lib/model/integrity_report_response_dto.dart +++ b/mobile/openapi/lib/model/integrity_report_response_dto.dart @@ -13,25 +13,31 @@ part of openapi.api; class IntegrityReportResponseDto { /// Returns a new [IntegrityReportResponseDto] instance. IntegrityReportResponseDto({ + required this.hasNextPage, this.items = const [], }); + bool hasNextPage; + List items; @override bool operator ==(Object other) => identical(this, other) || other is IntegrityReportResponseDto && + other.hasNextPage == hasNextPage && _deepEquality.equals(other.items, items); @override int get hashCode => // ignore: unnecessary_parenthesis + (hasNextPage.hashCode) + (items.hashCode); @override - String toString() => 'IntegrityReportResponseDto[items=$items]'; + String toString() => 'IntegrityReportResponseDto[hasNextPage=$hasNextPage, items=$items]'; Map toJson() { final json = {}; + json[r'hasNextPage'] = this.hasNextPage; json[r'items'] = this.items; return json; } @@ -45,6 +51,7 @@ class IntegrityReportResponseDto { final json = value.cast(); return IntegrityReportResponseDto( + hasNextPage: mapValueOfType(json, r'hasNextPage')!, items: IntegrityReportDto.listFromJson(json[r'items']), ); } @@ -93,6 +100,7 @@ class IntegrityReportResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'hasNextPage', 'items', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6aaf2bb6bc..a479c29370 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16864,6 +16864,14 @@ }, "IntegrityGetReportDto": { "properties": { + "page": { + "minimum": 1, + "type": "number" + }, + "size": { + "minimum": 1, + "type": "number" + }, "type": { "allOf": [ { @@ -16902,6 +16910,9 @@ }, "IntegrityReportResponseDto": { "properties": { + "hasNextPage": { + "type": "boolean" + }, "items": { "items": { "$ref": "#/components/schemas/IntegrityReportDto" @@ -16910,6 +16921,7 @@ } }, "required": [ + "hasNextPage", "items" ], "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 6fcbff9dae..7df795c4c4 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -41,6 +41,8 @@ export type ActivityStatisticsResponseDto = { likes: number; }; export type IntegrityGetReportDto = { + page?: number; + size?: number; "type": IntegrityReportType; }; export type IntegrityReportDto = { @@ -49,6 +51,7 @@ export type IntegrityReportDto = { "type": IntegrityReportType; }; export type IntegrityReportResponseDto = { + hasNextPage: boolean; items: IntegrityReportDto[]; }; export type IntegrityReportSummaryResponseDto = { diff --git a/server/src/controllers/integrity.controller.ts b/server/src/controllers/integrity.controller.ts index 8baf4d5fe8..84299899ab 100644 --- a/server/src/controllers/integrity.controller.ts +++ b/server/src/controllers/integrity.controller.ts @@ -42,7 +42,7 @@ export class IntegrityController { }) @Authenticated({ permission: Permission.Maintenance, admin: true }) getIntegrityReport(@Body() dto: IntegrityGetReportDto): Promise { - return this.service.getIntegrityReport(dto.type); + return this.service.getIntegrityReport(dto); } @Delete('report/:id') diff --git a/server/src/dtos/integrity.dto.ts b/server/src/dtos/integrity.dto.ts index 9a51adbca0..f049482c29 100644 --- a/server/src/dtos/integrity.dto.ts +++ b/server/src/dtos/integrity.dto.ts @@ -1,4 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Min } from 'class-validator'; import { IntegrityReportType } from 'src/enum'; import { ValidateEnum } from 'src/validation'; @@ -15,12 +17,17 @@ export class IntegrityGetReportDto { @ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' }) type!: IntegrityReportType; - // todo: paginate - // @IsInt() - // @Min(1) - // @Type(() => Number) - // @Optional() - // page?: number; + @IsInt() + @Min(1) + @IsOptional() + @Type(() => Number) + page?: number; + + @IsInt() + @Min(1) + @IsOptional() + @Type(() => Number) + size?: number; } export class IntegrityDeleteReportDto { @@ -37,4 +44,5 @@ class IntegrityReportDto { export class IntegrityReportResponseDto { items!: IntegrityReportDto[]; + hasNextPage!: boolean; } diff --git a/server/src/repositories/integrity.repository.ts b/server/src/repositories/integrity.repository.ts index 793c27798c..72223d9d62 100644 --- a/server/src/repositories/integrity.repository.ts +++ b/server/src/repositories/integrity.repository.ts @@ -5,6 +5,12 @@ import { DummyValue, GenerateSql } from 'src/decorators'; import { IntegrityReportType } from 'src/enum'; import { DB } from 'src/schema'; import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table'; +import { paginationHelper } from 'src/utils/pagination'; + +export interface ReportPaginationOptions { + page: number; + size: number; +} @Injectable() export class IntegrityRepository { @@ -58,14 +64,18 @@ export class IntegrityRepository { .executeTakeFirstOrThrow(); } - @GenerateSql({ params: [DummyValue.STRING] }) - getIntegrityReports(type: IntegrityReportType) { - return this.db + @GenerateSql({ params: [{ page: 1, size: 100 }, DummyValue.STRING] }) + async getIntegrityReports(pagination: ReportPaginationOptions, type: IntegrityReportType) { + const items = await this.db .selectFrom('integrity_report') .select(['id', 'type', 'path', 'assetId', 'fileAssetId']) .where('type', '=', type) .orderBy('createdAt', 'desc') + .limit(pagination.size + 1) + .offset((pagination.page - 1) * pagination.size) .execute(); + + return paginationHelper(items, pagination.size); } @GenerateSql({ params: [DummyValue.STRING] }) diff --git a/server/src/services/integrity.service.spec.ts b/server/src/services/integrity.service.spec.ts index e4aa9ae34d..b58b9b1851 100644 --- a/server/src/services/integrity.service.spec.ts +++ b/server/src/services/integrity.service.spec.ts @@ -24,7 +24,7 @@ describe(IntegrityService.name, () => { describe('getIntegrityReport', () => { it('gets report', async () => { - await expect(sut.getIntegrityReport(IntegrityReportType.ChecksumFail)).resolves.toEqual( + await expect(sut.getIntegrityReport({ type: IntegrityReportType.ChecksumFail })).resolves.toEqual( expect.objectContaining({ items: undefined, }), diff --git a/server/src/services/integrity.service.ts b/server/src/services/integrity.service.ts index 1b48d5cdfe..09c18c2344 100644 --- a/server/src/services/integrity.service.ts +++ b/server/src/services/integrity.service.ts @@ -9,7 +9,11 @@ import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { IntegrityReportResponseDto, IntegrityReportSummaryResponseDto } from 'src/dtos/integrity.dto'; +import { + IntegrityGetReportDto, + IntegrityReportResponseDto, + IntegrityReportSummaryResponseDto, +} from 'src/dtos/integrity.dto'; import { AssetStatus, CacheControl, @@ -154,10 +158,8 @@ export class IntegrityService extends BaseService { return this.integrityRepository.getIntegrityReportSummary(); } - async getIntegrityReport(type: IntegrityReportType): Promise { - return { - items: await this.integrityRepository.getIntegrityReports(type), - }; + async getIntegrityReport(dto: IntegrityGetReportDto): Promise { + return this.integrityRepository.getIntegrityReports({ page: dto.page || 1, size: dto.size || 100 }, dto.type); } getIntegrityReportCsv(type: IntegrityReportType): Readable { diff --git a/web/src/routes/admin/maintenance/integrity-report/[type]/+page.svelte b/web/src/routes/admin/maintenance/integrity-report/[type]/+page.svelte index 182cc61323..359e42ada4 100644 --- a/web/src/routes/admin/maintenance/integrity-report/[type]/+page.svelte +++ b/web/src/routes/admin/maintenance/integrity-report/[type]/+page.svelte @@ -2,8 +2,16 @@ import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte'; import { AppRoute } from '$lib/constants'; import { handleError } from '$lib/utils/handle-error'; - import { createJob, deleteIntegrityReport, getBaseUrl, IntegrityReportType, ManualJobName } from '@immich/sdk'; import { + createJob, + deleteIntegrityReport, + getBaseUrl, + getIntegrityReport, + IntegrityReportType, + ManualJobName, + } from '@immich/sdk'; + import { + HStack, IconButton, menuManager, modalManager, @@ -11,7 +19,14 @@ type ContextMenuBaseProps, type MenuItems, } from '@immich/ui'; - import { mdiDotsVertical, mdiDownload, mdiTrashCanOutline } from '@mdi/js'; + import { + mdiChevronLeft, + mdiChevronRight, + mdiDotsVertical, + mdiDownload, + mdiPageFirst, + mdiTrashCanOutline, + } from '@mdi/js'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; import type { PageData } from './$types'; @@ -23,7 +38,19 @@ let { data }: Props = $props(); let deleting = new SvelteSet(); - let integrityReport = $state(data.integrityReport.items); + let page = $state(1); + let integrityReport = $state(data.integrityReport); + + async function loadPage(target: number) { + integrityReport = await getIntegrityReport({ + integrityGetReportDto: { + type: data.type, + page: target, + }, + }); + + page = target; + } async function removeAll() { const confirm = await modalManager.showDialog({ @@ -68,7 +95,7 @@ await deleteIntegrityReport({ id, }); - integrityReport = integrityReport.filter((report) => report.id !== id); + integrityReport.items = integrityReport.items.filter((report) => report.id !== id); } catch (error) { handleError(error, 'Failed to delete file!'); } finally { @@ -147,7 +174,7 @@ - {#each integrityReport as { id, path } (id)} + {#each integrityReport.items as { id, path } (id)} @@ -165,6 +192,31 @@ {/each} + + + loadPage(1)} + /> + loadPage(page - 1)} + /> + loadPage(page + 1)} + /> + +