From ea6ac572c840d21e4f7cb0f3c311d57dd902e3b3 Mon Sep 17 00:00:00 2001 From: weathondev Date: Fri, 15 Nov 2024 23:38:57 +0100 Subject: [PATCH] feat: adding photo & video storage space to server stats (#14125) * expose detailed user storage stats + display them in the storage per user table * chore: openapi & sql * fix: fix test stubs * fix: formatting errors, e2e test and server test * fix: upper lower case typo in spec file --------- Co-authored-by: Alex Tran --- e2e/src/api/specs/server.e2e-spec.ts | 6 +++++ .../lib/model/server_stats_response_dto.dart | 18 ++++++++++++- .../openapi/lib/model/usage_by_user_dto.dart | 18 ++++++++++++- open-api/immich-openapi-specs.json | 26 ++++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 4 +++ server/src/dtos/server.dto.ts | 14 +++++++++- server/src/interfaces/user.interface.ts | 2 ++ server/src/queries/user.repository.sql | 18 ++++++++++++- server/src/repositories/user.repository.ts | 10 +++++++ server/src/services/server.service.spec.ts | 14 ++++++++++ server/src/services/server.service.ts | 5 ++++ .../server-stats/server-stats-panel.svelte | 10 +++++-- 12 files changed, 138 insertions(+), 7 deletions(-) diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts index 3133460adaf2a..4bff4b3dea678 100644 --- a/e2e/src/api/specs/server.e2e-spec.ts +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -163,11 +163,15 @@ describe('/server', () => { expect(body).toEqual({ photos: 0, usage: 0, + usagePhotos: 0, + usageVideos: 0, usageByUser: [ { quotaSizeInBytes: null, photos: 0, usage: 0, + usagePhotos: 0, + usageVideos: 0, userName: 'Immich Admin', userId: admin.userId, videos: 0, @@ -176,6 +180,8 @@ describe('/server', () => { quotaSizeInBytes: null, photos: 0, usage: 0, + usagePhotos: 0, + usageVideos: 0, userName: 'User 1', userId: nonAdmin.userId, videos: 0, diff --git a/mobile/openapi/lib/model/server_stats_response_dto.dart b/mobile/openapi/lib/model/server_stats_response_dto.dart index 654a34ee6b0e7..531fa8f03e16a 100644 --- a/mobile/openapi/lib/model/server_stats_response_dto.dart +++ b/mobile/openapi/lib/model/server_stats_response_dto.dart @@ -16,6 +16,8 @@ class ServerStatsResponseDto { this.photos = 0, this.usage = 0, this.usageByUser = const [], + this.usagePhotos = 0, + this.usageVideos = 0, this.videos = 0, }); @@ -25,6 +27,10 @@ class ServerStatsResponseDto { List usageByUser; + int usagePhotos; + + int usageVideos; + int videos; @override @@ -32,6 +38,8 @@ class ServerStatsResponseDto { other.photos == photos && other.usage == usage && _deepEquality.equals(other.usageByUser, usageByUser) && + other.usagePhotos == usagePhotos && + other.usageVideos == usageVideos && other.videos == videos; @override @@ -40,16 +48,20 @@ class ServerStatsResponseDto { (photos.hashCode) + (usage.hashCode) + (usageByUser.hashCode) + + (usagePhotos.hashCode) + + (usageVideos.hashCode) + (videos.hashCode); @override - String toString() => 'ServerStatsResponseDto[photos=$photos, usage=$usage, usageByUser=$usageByUser, videos=$videos]'; + String toString() => 'ServerStatsResponseDto[photos=$photos, usage=$usage, usageByUser=$usageByUser, usagePhotos=$usagePhotos, usageVideos=$usageVideos, videos=$videos]'; Map toJson() { final json = {}; json[r'photos'] = this.photos; json[r'usage'] = this.usage; json[r'usageByUser'] = this.usageByUser; + json[r'usagePhotos'] = this.usagePhotos; + json[r'usageVideos'] = this.usageVideos; json[r'videos'] = this.videos; return json; } @@ -66,6 +78,8 @@ class ServerStatsResponseDto { photos: mapValueOfType(json, r'photos')!, usage: mapValueOfType(json, r'usage')!, usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser']), + usagePhotos: mapValueOfType(json, r'usagePhotos')!, + usageVideos: mapValueOfType(json, r'usageVideos')!, videos: mapValueOfType(json, r'videos')!, ); } @@ -117,6 +131,8 @@ class ServerStatsResponseDto { 'photos', 'usage', 'usageByUser', + 'usagePhotos', + 'usageVideos', 'videos', }; } diff --git a/mobile/openapi/lib/model/usage_by_user_dto.dart b/mobile/openapi/lib/model/usage_by_user_dto.dart index e6f9216d74572..80235915fea8e 100644 --- a/mobile/openapi/lib/model/usage_by_user_dto.dart +++ b/mobile/openapi/lib/model/usage_by_user_dto.dart @@ -16,6 +16,8 @@ class UsageByUserDto { required this.photos, required this.quotaSizeInBytes, required this.usage, + required this.usagePhotos, + required this.usageVideos, required this.userId, required this.userName, required this.videos, @@ -27,6 +29,10 @@ class UsageByUserDto { int usage; + int usagePhotos; + + int usageVideos; + String userId; String userName; @@ -38,6 +44,8 @@ class UsageByUserDto { other.photos == photos && other.quotaSizeInBytes == quotaSizeInBytes && other.usage == usage && + other.usagePhotos == usagePhotos && + other.usageVideos == usageVideos && other.userId == userId && other.userName == userName && other.videos == videos; @@ -48,12 +56,14 @@ class UsageByUserDto { (photos.hashCode) + (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + (usage.hashCode) + + (usagePhotos.hashCode) + + (usageVideos.hashCode) + (userId.hashCode) + (userName.hashCode) + (videos.hashCode); @override - String toString() => 'UsageByUserDto[photos=$photos, quotaSizeInBytes=$quotaSizeInBytes, usage=$usage, userId=$userId, userName=$userName, videos=$videos]'; + String toString() => 'UsageByUserDto[photos=$photos, quotaSizeInBytes=$quotaSizeInBytes, usage=$usage, usagePhotos=$usagePhotos, usageVideos=$usageVideos, userId=$userId, userName=$userName, videos=$videos]'; Map toJson() { final json = {}; @@ -64,6 +74,8 @@ class UsageByUserDto { // json[r'quotaSizeInBytes'] = null; } json[r'usage'] = this.usage; + json[r'usagePhotos'] = this.usagePhotos; + json[r'usageVideos'] = this.usageVideos; json[r'userId'] = this.userId; json[r'userName'] = this.userName; json[r'videos'] = this.videos; @@ -82,6 +94,8 @@ class UsageByUserDto { photos: mapValueOfType(json, r'photos')!, quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), usage: mapValueOfType(json, r'usage')!, + usagePhotos: mapValueOfType(json, r'usagePhotos')!, + usageVideos: mapValueOfType(json, r'usageVideos')!, userId: mapValueOfType(json, r'userId')!, userName: mapValueOfType(json, r'userName')!, videos: mapValueOfType(json, r'videos')!, @@ -135,6 +149,8 @@ class UsageByUserDto { 'photos', 'quotaSizeInBytes', 'usage', + 'usagePhotos', + 'usageVideos', 'userId', 'userName', 'videos', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2d3f2fa6c22e0..5bddf2f3d22f7 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10966,7 +10966,9 @@ { "photos": 1, "videos": 1, - "diskUsageRaw": 1 + "diskUsageRaw": 2, + "usagePhotos": 1, + "usageVideos": 1 } ], "items": { @@ -10975,6 +10977,16 @@ "title": "Array of usage for each user", "type": "array" }, + "usagePhotos": { + "default": 0, + "format": "int64", + "type": "integer" + }, + "usageVideos": { + "default": 0, + "format": "int64", + "type": "integer" + }, "videos": { "default": 0, "type": "integer" @@ -10984,6 +10996,8 @@ "photos", "usage", "usageByUser", + "usagePhotos", + "usageVideos", "videos" ], "type": "object" @@ -12503,6 +12517,14 @@ "format": "int64", "type": "integer" }, + "usagePhotos": { + "format": "int64", + "type": "integer" + }, + "usageVideos": { + "format": "int64", + "type": "integer" + }, "userId": { "type": "string" }, @@ -12517,6 +12539,8 @@ "photos", "quotaSizeInBytes", "usage", + "usagePhotos", + "usageVideos", "userId", "userName", "videos" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c2906ff6e0031..332814424d1d2 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -969,6 +969,8 @@ export type UsageByUserDto = { photos: number; quotaSizeInBytes: number | null; usage: number; + usagePhotos: number; + usageVideos: number; userId: string; userName: string; videos: number; @@ -977,6 +979,8 @@ export type ServerStatsResponseDto = { photos: number; usage: number; usageByUser: UsageByUserDto[]; + usagePhotos: number; + usageVideos: number; videos: number; }; export type ServerStorageResponseDto = { diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index e54048335129f..cbabfa7aed661 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -86,6 +86,10 @@ export class UsageByUserDto { @ApiProperty({ type: 'integer', format: 'int64' }) usage!: number; @ApiProperty({ type: 'integer', format: 'int64' }) + usagePhotos!: number; + @ApiProperty({ type: 'integer', format: 'int64' }) + usageVideos!: number; + @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes!: number | null; } @@ -99,6 +103,12 @@ export class ServerStatsResponseDto { @ApiProperty({ type: 'integer', format: 'int64' }) usage = 0; + @ApiProperty({ type: 'integer', format: 'int64' }) + usagePhotos = 0; + + @ApiProperty({ type: 'integer', format: 'int64' }) + usageVideos = 0; + @ApiProperty({ isArray: true, type: UsageByUserDto, @@ -107,7 +117,9 @@ export class ServerStatsResponseDto { { photos: 1, videos: 1, - diskUsageRaw: 1, + diskUsageRaw: 2, + usagePhotos: 1, + usageVideos: 1, }, ], }) diff --git a/server/src/interfaces/user.interface.ts b/server/src/interfaces/user.interface.ts index 3353d45dce215..385a4d3d50e91 100644 --- a/server/src/interfaces/user.interface.ts +++ b/server/src/interfaces/user.interface.ts @@ -11,6 +11,8 @@ export interface UserStatsQueryResponse { photos: number; videos: number; usage: number; + usagePhotos: number; + usageVideos: number; quotaSizeInBytes: number | null; } diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index ab0a6cc534bd0..c35dc540cef42 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -140,7 +140,23 @@ SELECT "assets"."libraryId" IS NULL ), 0 - ) AS "usage" + ) AS "usage", + COALESCE( + SUM("exif"."fileSizeInByte") FILTER ( + WHERE + "assets"."libraryId" IS NULL + AND "assets"."type" = 'IMAGE' + ), + 0 + ) AS "usagePhotos", + COALESCE( + SUM("exif"."fileSizeInByte") FILTER ( + WHERE + "assets"."libraryId" IS NULL + AND "assets"."type" = 'VIDEO' + ), + 0 + ) AS "usageVideos" FROM "users" "users" LEFT JOIN "assets" "assets" ON "assets"."ownerId" = "users"."id" diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 6ac8536ef8a79..a2e4375701a2a 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -108,6 +108,14 @@ export class UserRepository implements IUserRepository { .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos') .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos') .addSelect('COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL), 0)', 'usage') + .addSelect( + `COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'IMAGE'), 0)`, + 'usagePhotos', + ) + .addSelect( + `COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'VIDEO'), 0)`, + 'usageVideos', + ) .addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes') .leftJoin('users.assets', 'assets') .leftJoin('assets.exifInfo', 'exif') @@ -119,6 +127,8 @@ export class UserRepository implements IUserRepository { stat.photos = Number(stat.photos); stat.videos = Number(stat.videos); stat.usage = Number(stat.usage); + stat.usagePhotos = Number(stat.usagePhotos); + stat.usageVideos = Number(stat.usageVideos); stat.quotaSizeInBytes = stat.quotaSizeInBytes; } diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index ab6eb3b1a4f05..475d1d6193258 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -185,6 +185,8 @@ describe(ServerService.name, () => { photos: 10, videos: 11, usage: 12_345, + usagePhotos: 1, + usageVideos: 11_345, quotaSizeInBytes: 0, }, { @@ -193,6 +195,8 @@ describe(ServerService.name, () => { photos: 10, videos: 20, usage: 123_456, + usagePhotos: 100, + usageVideos: 23_456, quotaSizeInBytes: 0, }, { @@ -201,6 +205,8 @@ describe(ServerService.name, () => { photos: 100, videos: 0, usage: 987_654, + usagePhotos: 900, + usageVideos: 87_654, quotaSizeInBytes: 0, }, ]); @@ -209,11 +215,15 @@ describe(ServerService.name, () => { photos: 120, videos: 31, usage: 1_123_455, + usagePhotos: 1001, + usageVideos: 122_455, usageByUser: [ { photos: 10, quotaSizeInBytes: 0, usage: 12_345, + usagePhotos: 1, + usageVideos: 11_345, userName: '1 User', userId: 'user1', videos: 11, @@ -222,6 +232,8 @@ describe(ServerService.name, () => { photos: 10, quotaSizeInBytes: 0, usage: 123_456, + usagePhotos: 100, + usageVideos: 23_456, userName: '2 User', userId: 'user2', videos: 20, @@ -230,6 +242,8 @@ describe(ServerService.name, () => { photos: 100, quotaSizeInBytes: 0, usage: 987_654, + usagePhotos: 900, + usageVideos: 87_654, userName: '3 User', userId: 'user3', videos: 0, diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 3fc319a2fd938..7df322a84e5d9 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -126,11 +126,16 @@ export class ServerService extends BaseService { usage.photos = user.photos; usage.videos = user.videos; usage.usage = user.usage; + usage.usagePhotos = user.usagePhotos; + usage.usageVideos = user.usageVideos; usage.quotaSizeInBytes = user.quotaSizeInBytes; serverStats.photos += usage.photos; serverStats.videos += usage.videos; serverStats.usage += usage.usage; + serverStats.usagePhotos += usage.usagePhotos; + serverStats.usageVideos += usage.usageVideos; + serverStats.usageByUser.push(usage); } diff --git a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte index feab6a9c6d072..bb288511accf7 100644 --- a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte +++ b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte @@ -16,6 +16,8 @@ photos: 0, videos: 0, usage: 0, + usagePhotos: 0, + usageVideos: 0, usageByUser: [], }, }: Props = $props(); @@ -105,8 +107,12 @@ class="flex h-[50px] w-full place-items-center text-center odd:bg-immich-gray even:bg-immich-bg odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50" > {user.userName} - {user.photos.toLocaleString($locale)} - {user.videos.toLocaleString($locale)} + {user.photos.toLocaleString($locale)} ({getByteUnitString(user.usagePhotos, $locale, 0)}) + {user.videos.toLocaleString($locale)} ({getByteUnitString(user.usageVideos, $locale, 0)}) {getByteUnitString(user.usage, $locale, 0)} {#if user.quotaSizeInBytes}