feat: paginate integrity report results

This commit is contained in:
izzy
2025-12-18 14:08:06 +00:00
parent 31ac88f158
commit 5028c56ad8
10 changed files with 154 additions and 23 deletions

View File

@@ -13,25 +13,59 @@ part of openapi.api;
class IntegrityGetReportDto { class IntegrityGetReportDto {
/// Returns a new [IntegrityGetReportDto] instance. /// Returns a new [IntegrityGetReportDto] instance.
IntegrityGetReportDto({ IntegrityGetReportDto({
this.page,
this.size,
required this.type, 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; IntegrityReportType type;
@override @override
bool operator ==(Object other) => identical(this, other) || other is IntegrityGetReportDto && bool operator ==(Object other) => identical(this, other) || other is IntegrityGetReportDto &&
other.page == page &&
other.size == size &&
other.type == type; other.type == type;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(page == null ? 0 : page!.hashCode) +
(size == null ? 0 : size!.hashCode) +
(type.hashCode); (type.hashCode);
@override @override
String toString() => 'IntegrityGetReportDto[type=$type]'; String toString() => 'IntegrityGetReportDto[page=$page, size=$size, type=$type]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
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; json[r'type'] = this.type;
return json; return json;
} }
@@ -45,6 +79,8 @@ class IntegrityGetReportDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return IntegrityGetReportDto( return IntegrityGetReportDto(
page: num.parse('${json[r'page']}'),
size: num.parse('${json[r'size']}'),
type: IntegrityReportType.fromJson(json[r'type'])!, type: IntegrityReportType.fromJson(json[r'type'])!,
); );
} }

View File

@@ -13,25 +13,31 @@ part of openapi.api;
class IntegrityReportResponseDto { class IntegrityReportResponseDto {
/// Returns a new [IntegrityReportResponseDto] instance. /// Returns a new [IntegrityReportResponseDto] instance.
IntegrityReportResponseDto({ IntegrityReportResponseDto({
required this.hasNextPage,
this.items = const [], this.items = const [],
}); });
bool hasNextPage;
List<IntegrityReportDto> items; List<IntegrityReportDto> items;
@override @override
bool operator ==(Object other) => identical(this, other) || other is IntegrityReportResponseDto && bool operator ==(Object other) => identical(this, other) || other is IntegrityReportResponseDto &&
other.hasNextPage == hasNextPage &&
_deepEquality.equals(other.items, items); _deepEquality.equals(other.items, items);
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(hasNextPage.hashCode) +
(items.hashCode); (items.hashCode);
@override @override
String toString() => 'IntegrityReportResponseDto[items=$items]'; String toString() => 'IntegrityReportResponseDto[hasNextPage=$hasNextPage, items=$items]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'hasNextPage'] = this.hasNextPage;
json[r'items'] = this.items; json[r'items'] = this.items;
return json; return json;
} }
@@ -45,6 +51,7 @@ class IntegrityReportResponseDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return IntegrityReportResponseDto( return IntegrityReportResponseDto(
hasNextPage: mapValueOfType<bool>(json, r'hasNextPage')!,
items: IntegrityReportDto.listFromJson(json[r'items']), items: IntegrityReportDto.listFromJson(json[r'items']),
); );
} }
@@ -93,6 +100,7 @@ class IntegrityReportResponseDto {
/// 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>{
'hasNextPage',
'items', 'items',
}; };
} }

View File

@@ -16864,6 +16864,14 @@
}, },
"IntegrityGetReportDto": { "IntegrityGetReportDto": {
"properties": { "properties": {
"page": {
"minimum": 1,
"type": "number"
},
"size": {
"minimum": 1,
"type": "number"
},
"type": { "type": {
"allOf": [ "allOf": [
{ {
@@ -16902,6 +16910,9 @@
}, },
"IntegrityReportResponseDto": { "IntegrityReportResponseDto": {
"properties": { "properties": {
"hasNextPage": {
"type": "boolean"
},
"items": { "items": {
"items": { "items": {
"$ref": "#/components/schemas/IntegrityReportDto" "$ref": "#/components/schemas/IntegrityReportDto"
@@ -16910,6 +16921,7 @@
} }
}, },
"required": [ "required": [
"hasNextPage",
"items" "items"
], ],
"type": "object" "type": "object"

View File

@@ -41,6 +41,8 @@ export type ActivityStatisticsResponseDto = {
likes: number; likes: number;
}; };
export type IntegrityGetReportDto = { export type IntegrityGetReportDto = {
page?: number;
size?: number;
"type": IntegrityReportType; "type": IntegrityReportType;
}; };
export type IntegrityReportDto = { export type IntegrityReportDto = {
@@ -49,6 +51,7 @@ export type IntegrityReportDto = {
"type": IntegrityReportType; "type": IntegrityReportType;
}; };
export type IntegrityReportResponseDto = { export type IntegrityReportResponseDto = {
hasNextPage: boolean;
items: IntegrityReportDto[]; items: IntegrityReportDto[];
}; };
export type IntegrityReportSummaryResponseDto = { export type IntegrityReportSummaryResponseDto = {

View File

@@ -42,7 +42,7 @@ export class IntegrityController {
}) })
@Authenticated({ permission: Permission.Maintenance, admin: true }) @Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReport(@Body() dto: IntegrityGetReportDto): Promise<IntegrityReportResponseDto> { getIntegrityReport(@Body() dto: IntegrityGetReportDto): Promise<IntegrityReportResponseDto> {
return this.service.getIntegrityReport(dto.type); return this.service.getIntegrityReport(dto);
} }
@Delete('report/:id') @Delete('report/:id')

View File

@@ -1,4 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min } from 'class-validator';
import { IntegrityReportType } from 'src/enum'; import { IntegrityReportType } from 'src/enum';
import { ValidateEnum } from 'src/validation'; import { ValidateEnum } from 'src/validation';
@@ -15,12 +17,17 @@ export class IntegrityGetReportDto {
@ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' }) @ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' })
type!: IntegrityReportType; type!: IntegrityReportType;
// todo: paginate @IsInt()
// @IsInt() @Min(1)
// @Min(1) @IsOptional()
// @Type(() => Number) @Type(() => Number)
// @Optional() page?: number;
// page?: number;
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
size?: number;
} }
export class IntegrityDeleteReportDto { export class IntegrityDeleteReportDto {
@@ -37,4 +44,5 @@ class IntegrityReportDto {
export class IntegrityReportResponseDto { export class IntegrityReportResponseDto {
items!: IntegrityReportDto[]; items!: IntegrityReportDto[];
hasNextPage!: boolean;
} }

View File

@@ -5,6 +5,12 @@ import { DummyValue, GenerateSql } from 'src/decorators';
import { IntegrityReportType } from 'src/enum'; import { IntegrityReportType } from 'src/enum';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table'; import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table';
import { paginationHelper } from 'src/utils/pagination';
export interface ReportPaginationOptions {
page: number;
size: number;
}
@Injectable() @Injectable()
export class IntegrityRepository { export class IntegrityRepository {
@@ -58,14 +64,18 @@ export class IntegrityRepository {
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [{ page: 1, size: 100 }, DummyValue.STRING] })
getIntegrityReports(type: IntegrityReportType) { async getIntegrityReports(pagination: ReportPaginationOptions, type: IntegrityReportType) {
return this.db const items = await this.db
.selectFrom('integrity_report') .selectFrom('integrity_report')
.select(['id', 'type', 'path', 'assetId', 'fileAssetId']) .select(['id', 'type', 'path', 'assetId', 'fileAssetId'])
.where('type', '=', type) .where('type', '=', type)
.orderBy('createdAt', 'desc') .orderBy('createdAt', 'desc')
.limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size)
.execute(); .execute();
return paginationHelper(items, pagination.size);
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })

View File

@@ -24,7 +24,7 @@ describe(IntegrityService.name, () => {
describe('getIntegrityReport', () => { describe('getIntegrityReport', () => {
it('gets report', async () => { it('gets report', async () => {
await expect(sut.getIntegrityReport(IntegrityReportType.ChecksumFail)).resolves.toEqual( await expect(sut.getIntegrityReport({ type: IntegrityReportType.ChecksumFail })).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
items: undefined, items: undefined,
}), }),

View File

@@ -9,7 +9,11 @@ import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; 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 { import {
AssetStatus, AssetStatus,
CacheControl, CacheControl,
@@ -154,10 +158,8 @@ export class IntegrityService extends BaseService {
return this.integrityRepository.getIntegrityReportSummary(); return this.integrityRepository.getIntegrityReportSummary();
} }
async getIntegrityReport(type: IntegrityReportType): Promise<IntegrityReportResponseDto> { async getIntegrityReport(dto: IntegrityGetReportDto): Promise<IntegrityReportResponseDto> {
return { return this.integrityRepository.getIntegrityReports({ page: dto.page || 1, size: dto.size || 100 }, dto.type);
items: await this.integrityRepository.getIntegrityReports(type),
};
} }
getIntegrityReportCsv(type: IntegrityReportType): Readable { getIntegrityReportCsv(type: IntegrityReportType): Readable {

View File

@@ -2,8 +2,16 @@
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte'; import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { createJob, deleteIntegrityReport, getBaseUrl, IntegrityReportType, ManualJobName } from '@immich/sdk';
import { import {
createJob,
deleteIntegrityReport,
getBaseUrl,
getIntegrityReport,
IntegrityReportType,
ManualJobName,
} from '@immich/sdk';
import {
HStack,
IconButton, IconButton,
menuManager, menuManager,
modalManager, modalManager,
@@ -11,7 +19,14 @@
type ContextMenuBaseProps, type ContextMenuBaseProps,
type MenuItems, type MenuItems,
} from '@immich/ui'; } 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 { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -23,7 +38,19 @@
let { data }: Props = $props(); let { data }: Props = $props();
let deleting = new SvelteSet(); 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() { async function removeAll() {
const confirm = await modalManager.showDialog({ const confirm = await modalManager.showDialog({
@@ -68,7 +95,7 @@
await deleteIntegrityReport({ await deleteIntegrityReport({
id, id,
}); });
integrityReport = integrityReport.filter((report) => report.id !== id); integrityReport.items = integrityReport.items.filter((report) => report.id !== id);
} catch (error) { } catch (error) {
handleError(error, 'Failed to delete file!'); handleError(error, 'Failed to delete file!');
} finally { } finally {
@@ -147,7 +174,7 @@
<tbody <tbody
class="block max-h-80 w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg" class="block max-h-80 w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
> >
{#each integrityReport as { id, path } (id)} {#each integrityReport.items as { id, path } (id)}
<tr <tr
class={`flex py-1 w-full place-items-center even:bg-subtle/20 odd:bg-subtle/80 ${deleting.has(id) || deleting.has('all') ? 'text-gray-500' : ''}`} class={`flex py-1 w-full place-items-center even:bg-subtle/20 odd:bg-subtle/80 ${deleting.has(id) || deleting.has('all') ? 'text-gray-500' : ''}`}
> >
@@ -165,6 +192,31 @@
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
<tfoot>
<HStack class="mt-4 items-center justify-end">
<IconButton
disabled={page === 1}
color="primary"
icon={mdiPageFirst}
aria-label="first page"
onclick={() => loadPage(1)}
/>
<IconButton
disabled={page === 1}
color="primary"
icon={mdiChevronLeft}
aria-label="previous page"
onclick={() => loadPage(page - 1)}
/>
<IconButton
disabled={!integrityReport.hasNextPage}
color="primary"
icon={mdiChevronRight}
aria-label="next page"
onclick={() => loadPage(page + 1)}
/>
</HStack>
</tfoot>
</table> </table>
</section> </section>
</section> </section>