diff --git a/mobile/openapi/lib/model/integrity_get_report_dto.dart b/mobile/openapi/lib/model/integrity_get_report_dto.dart index 0d57e3b5b1..57cf5c2c20 100644 --- a/mobile/openapi/lib/model/integrity_get_report_dto.dart +++ b/mobile/openapi/lib/model/integrity_get_report_dto.dart @@ -13,19 +13,18 @@ part of openapi.api; class IntegrityGetReportDto { /// Returns a new [IntegrityGetReportDto] instance. IntegrityGetReportDto({ - this.page, - this.size, + this.cursor, + this.limit, 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; + DateTime? cursor; /// Minimum value: 1 /// @@ -34,37 +33,37 @@ class IntegrityGetReportDto { /// 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; + num? limit; IntegrityReportType type; @override bool operator ==(Object other) => identical(this, other) || other is IntegrityGetReportDto && - other.page == page && - other.size == size && + other.cursor == cursor && + other.limit == limit && other.type == type; @override int get hashCode => // ignore: unnecessary_parenthesis - (page == null ? 0 : page!.hashCode) + - (size == null ? 0 : size!.hashCode) + + (cursor == null ? 0 : cursor!.hashCode) + + (limit == null ? 0 : limit!.hashCode) + (type.hashCode); @override - String toString() => 'IntegrityGetReportDto[page=$page, size=$size, type=$type]'; + String toString() => 'IntegrityGetReportDto[cursor=$cursor, limit=$limit, type=$type]'; Map toJson() { final json = {}; - if (this.page != null) { - json[r'page'] = this.page; + if (this.cursor != null) { + json[r'cursor'] = this.cursor!.toUtc().toIso8601String(); } else { - // json[r'page'] = null; + // json[r'cursor'] = null; } - if (this.size != null) { - json[r'size'] = this.size; + if (this.limit != null) { + json[r'limit'] = this.limit; } else { - // json[r'size'] = null; + // json[r'limit'] = null; } json[r'type'] = this.type; return json; @@ -79,8 +78,8 @@ class IntegrityGetReportDto { final json = value.cast(); return IntegrityGetReportDto( - page: num.parse('${json[r'page']}'), - size: num.parse('${json[r'size']}'), + cursor: mapDateTime(json, r'cursor', r''), + limit: num.parse('${json[r'limit']}'), 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 083ef88c63..ab72fb88bd 100644 --- a/mobile/openapi/lib/model/integrity_report_response_dto.dart +++ b/mobile/openapi/lib/model/integrity_report_response_dto.dart @@ -13,32 +13,42 @@ part of openapi.api; class IntegrityReportResponseDto { /// Returns a new [IntegrityReportResponseDto] instance. IntegrityReportResponseDto({ - required this.hasNextPage, this.items = const [], + this.nextCursor, }); - bool hasNextPage; - List items; + /// + /// 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. + /// + DateTime? nextCursor; + @override bool operator ==(Object other) => identical(this, other) || other is IntegrityReportResponseDto && - other.hasNextPage == hasNextPage && - _deepEquality.equals(other.items, items); + _deepEquality.equals(other.items, items) && + other.nextCursor == nextCursor; @override int get hashCode => // ignore: unnecessary_parenthesis - (hasNextPage.hashCode) + - (items.hashCode); + (items.hashCode) + + (nextCursor == null ? 0 : nextCursor!.hashCode); @override - String toString() => 'IntegrityReportResponseDto[hasNextPage=$hasNextPage, items=$items]'; + String toString() => 'IntegrityReportResponseDto[items=$items, nextCursor=$nextCursor]'; Map toJson() { final json = {}; - json[r'hasNextPage'] = this.hasNextPage; json[r'items'] = this.items; + if (this.nextCursor != null) { + json[r'nextCursor'] = this.nextCursor!.toUtc().toIso8601String(); + } else { + // json[r'nextCursor'] = null; + } return json; } @@ -51,8 +61,8 @@ class IntegrityReportResponseDto { final json = value.cast(); return IntegrityReportResponseDto( - hasNextPage: mapValueOfType(json, r'hasNextPage')!, items: IntegrityReportDto.listFromJson(json[r'items']), + nextCursor: mapDateTime(json, r'nextCursor', r''), ); } return null; @@ -100,7 +110,6 @@ 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 e7615b49cf..2a9ff2b2c4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16928,11 +16928,11 @@ }, "IntegrityGetReportDto": { "properties": { - "page": { - "minimum": 1, - "type": "number" + "cursor": { + "format": "uuid", + "type": "string" }, - "size": { + "limit": { "minimum": 1, "type": "number" }, @@ -16974,18 +16974,17 @@ }, "IntegrityReportResponseDto": { "properties": { - "hasNextPage": { - "type": "boolean" - }, "items": { "items": { "$ref": "#/components/schemas/IntegrityReportDto" }, "type": "array" + }, + "nextCursor": { + "type": "string" } }, "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 b25b3c178e..2f51bfb397 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -41,8 +41,8 @@ export type ActivityStatisticsResponseDto = { likes: number; }; export type IntegrityGetReportDto = { - page?: number; - size?: number; + cursor?: string; + limit?: number; "type": IntegrityReportType; }; export type IntegrityReportDto = { @@ -51,8 +51,8 @@ export type IntegrityReportDto = { "type": IntegrityReportType; }; export type IntegrityReportResponseDto = { - hasNextPage: boolean; items: IntegrityReportDto[]; + nextCursor?: string; }; export type IntegrityReportSummaryResponseDto = { checksum_mismatch: number; diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts index 588f358023..34e6ddb4f4 100644 --- a/server/src/bin/migrations.ts +++ b/server/src/bin/migrations.ts @@ -130,6 +130,14 @@ const create = (path: string, up: string[], down: string[]) => { const compare = async () => { const configRepository = new ConfigRepository(); const { database } = configRepository.getEnv(); + database.config = { + connectionType: 'parts', + database: 'immich', + host: 'database', + password: 'postgres', + username: 'postgres', + port: 5432, + }; const db = postgres(asPostgresConnectionConfig(database.config)); const source = schemaFromCode({ overrides: true, namingStrategy: 'default' }); diff --git a/server/src/dtos/integrity.dto.ts b/server/src/dtos/integrity.dto.ts index f049482c29..1264201147 100644 --- a/server/src/dtos/integrity.dto.ts +++ b/server/src/dtos/integrity.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsOptional, Min } from 'class-validator'; +import { IsInt, IsOptional, IsUUID, Min } from 'class-validator'; import { IntegrityReportType } from 'src/enum'; import { ValidateEnum } from 'src/validation'; @@ -17,17 +17,15 @@ export class IntegrityGetReportDto { @ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' }) type!: IntegrityReportType; - @IsInt() - @Min(1) @IsOptional() - @Type(() => Number) - page?: number; + @IsUUID() + cursor?: string; @IsInt() @Min(1) @IsOptional() @Type(() => Number) - size?: number; + limit?: number; } export class IntegrityDeleteReportDto { @@ -44,5 +42,5 @@ class IntegrityReportDto { export class IntegrityReportResponseDto { items!: IntegrityReportDto[]; - hasNextPage!: boolean; + nextCursor?: string; } diff --git a/server/src/queries/integrity.repository.sql b/server/src/queries/integrity.repository.sql index 92f235b1c6..a381a1abc6 100644 --- a/server/src/queries/integrity.repository.sql +++ b/server/src/queries/integrity.repository.sql @@ -31,16 +31,16 @@ select "type", "path", "assetId", - "fileAssetId" + "fileAssetId", + "createdAt" from "integrity_report" where "type" = $1 + and "createdAt" <= $2 order by "createdAt" desc limit - $2 -offset $3 -- IntegrityRepository.getAssetPathsByPaths diff --git a/server/src/repositories/integrity.repository.ts b/server/src/repositories/integrity.repository.ts index 5cebef5b0e..dde3f0fffa 100644 --- a/server/src/repositories/integrity.repository.ts +++ b/server/src/repositories/integrity.repository.ts @@ -5,11 +5,10 @@ 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; + cursor?: string; + limit: number; } @Injectable() @@ -64,18 +63,21 @@ export class IntegrityRepository { .executeTakeFirstOrThrow(); } - @GenerateSql({ params: [{ page: 1, size: 100 }, DummyValue.STRING] }) + @GenerateSql({ params: [{ cursor: DummyValue.NUMBER, limit: 100 }, DummyValue.STRING] }) async getIntegrityReports(pagination: ReportPaginationOptions, type: IntegrityReportType) { const items = await this.db .selectFrom('integrity_report') - .select(['id', 'type', 'path', 'assetId', 'fileAssetId']) + .select(['id', 'type', 'path', 'assetId', 'fileAssetId', 'createdAt']) .where('type', '=', type) - .orderBy('createdAt', 'desc') - .limit(pagination.size + 1) - .offset((pagination.page - 1) * pagination.size) + .$if(pagination.cursor !== undefined, (eb) => eb.where('id', '<=', pagination.cursor!)) + .orderBy('id', 'desc') + .limit(pagination.limit + 1) .execute(); - return paginationHelper(items, pagination.size); + return { + items: items.slice(0, pagination.limit), + nextCursor: items[pagination.limit]?.id, + }; } @GenerateSql({ params: [DummyValue.STRING] }) diff --git a/server/src/schema/migrations/1764255490085-CreateIntegrityReportTable.ts b/server/src/schema/migrations/1764255490085-CreateIntegrityReportTable.ts index bbff9184a1..25830c43b8 100644 --- a/server/src/schema/migrations/1764255490085-CreateIntegrityReportTable.ts +++ b/server/src/schema/migrations/1764255490085-CreateIntegrityReportTable.ts @@ -2,7 +2,7 @@ import { Kysely, sql } from 'kysely'; export async function up(db: Kysely): Promise { await sql`CREATE TABLE "integrity_report" ( - "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), "type" character varying NOT NULL, "path" character varying NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), diff --git a/server/src/schema/tables/integrity-report.table.ts b/server/src/schema/tables/integrity-report.table.ts index 34ae50ab8e..ffef9ab6eb 100644 --- a/server/src/schema/tables/integrity-report.table.ts +++ b/server/src/schema/tables/integrity-report.table.ts @@ -1,21 +1,13 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; import { IntegrityReportType } from 'src/enum'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { - Column, - CreateDateColumn, - ForeignKeyColumn, - Generated, - PrimaryGeneratedColumn, - Table, - Timestamp, - Unique, -} from 'src/sql-tools'; +import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp, Unique } from 'src/sql-tools'; @Table('integrity_report') @Unique({ columns: ['type', 'path'] }) export class IntegrityReportTable { - @PrimaryGeneratedColumn() + @PrimaryGeneratedUuidV7Column() id!: Generated; @Column() diff --git a/server/src/services/integrity.service.ts b/server/src/services/integrity.service.ts index 13650e9d9a..32493e3f39 100644 --- a/server/src/services/integrity.service.ts +++ b/server/src/services/integrity.service.ts @@ -140,7 +140,7 @@ export class IntegrityService extends BaseService { } async getIntegrityReport(dto: IntegrityGetReportDto): Promise { - return this.integrityRepository.getIntegrityReports({ page: dto.page || 1, size: dto.size || 100 }, dto.type); + return this.integrityRepository.getIntegrityReports({ cursor: dto.cursor, limit: dto.limit || 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 873c047463..59256c1513 100644 --- a/web/src/routes/admin/maintenance/integrity-report/[type]/+page.svelte +++ b/web/src/routes/admin/maintenance/integrity-report/[type]/+page.svelte @@ -13,6 +13,7 @@ ManualJobName, } from '@immich/sdk'; import { + Button, HStack, IconButton, menuManager, @@ -21,14 +22,7 @@ type ContextMenuBaseProps, type MenuItems, } from '@immich/ui'; - import { - mdiChevronLeft, - mdiChevronRight, - mdiDotsVertical, - mdiDownload, - mdiPageFirst, - mdiTrashCanOutline, - } from '@mdi/js'; + import { mdiDotsVertical, mdiDownload, mdiTrashCanOutline } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; @@ -41,18 +35,18 @@ let { data }: Props = $props(); let deleting = new SvelteSet(); - let page = $state(1); let integrityReport = $state(data.integrityReport); - async function loadPage(target: number) { - integrityReport = await getIntegrityReport({ + async function loadMore() { + const { items, nextCursor } = await getIntegrityReport({ integrityGetReportDto: { type: data.type, - page: target, + cursor: integrityReport.nextCursor, }, }); - page = target; + integrityReport.items.push(...items); + integrityReport.nextCursor = nextCursor; } async function removeAll() { @@ -108,7 +102,7 @@ } function download(reportId: string) { - location.href = `${getBaseUrl()}/admin/maintenance/integrity/report/${reportId}/file`; + location.href = `${getBaseUrl()}/admin/integrity/report/${reportId}/file`; } const handleOpen = async (event: Event, props: Partial, reportId: string) => { @@ -150,7 +144,11 @@ if (jobs.integrityCheck.queueStatus.isActive) { expectingUpdate = true; } else if (expectingUpdate) { - await loadPage(page); + integrityReport = await getIntegrityReport({ + integrityGetReportDto: { + type: data.type, + }, + }); expectingUpdate = false; } @@ -195,9 +193,7 @@ - + {#each integrityReport.items as { id, path } (id)} {/each} - - - loadPage(1)} - /> - loadPage(page - 1)} - /> - loadPage(page + 1)} - /> - - + {#if integrityReport.nextCursor} + + + + + + {/if}