mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(web): improved server stats (#1870)
* feat(web): improved server stats * fix(web): don't log unauthorized errors * Revert "fix(web): don't log unauthorized errors" This reverts commit 7fc2987a77ae8bf3a7381ed3156a7a0c16f27564.
This commit is contained in:
		
							
								
								
									
										8
									
								
								mobile/openapi/doc/ServerStatsResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								mobile/openapi/doc/ServerStatsResponseDto.md
									
									
									
										generated
									
									
									
								
							| @@ -8,11 +8,9 @@ import 'package:openapi/api.dart'; | ||||
| ## Properties | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **photos** | **int** |  |  | ||||
| **videos** | **int** |  |  | ||||
| **objects** | **int** |  |  | ||||
| **usageRaw** | **int** |  |  | ||||
| **usage** | **String** |  |  | ||||
| **photos** | **int** |  | [default to 0] | ||||
| **videos** | **int** |  | [default to 0] | ||||
| **usage** | **int** |  | [default to 0] | ||||
| **usageByUser** | [**List<UsageByUserDto>**](UsageByUserDto.md) |  | [default to const []] | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
|   | ||||
							
								
								
									
										7
									
								
								mobile/openapi/doc/UsageByUserDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								mobile/openapi/doc/UsageByUserDto.md
									
									
									
										generated
									
									
									
								
							| @@ -9,10 +9,11 @@ import 'package:openapi/api.dart'; | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **userId** | **String** |  |  | ||||
| **videos** | **int** |  |  | ||||
| **userFirstName** | **String** |  |  | ||||
| **userLastName** | **String** |  |  | ||||
| **photos** | **int** |  |  | ||||
| **usageRaw** | **int** |  |  | ||||
| **usage** | **String** |  |  | ||||
| **videos** | **int** |  |  | ||||
| **usage** | **int** |  |  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
| 
 | ||||
|   | ||||
| @@ -13,11 +13,9 @@ part of openapi.api; | ||||
| class ServerStatsResponseDto { | ||||
|   /// Returns a new [ServerStatsResponseDto] instance. | ||||
|   ServerStatsResponseDto({ | ||||
|     required this.photos, | ||||
|     required this.videos, | ||||
|     required this.objects, | ||||
|     required this.usageRaw, | ||||
|     required this.usage, | ||||
|     this.photos = 0, | ||||
|     this.videos = 0, | ||||
|     this.usage = 0, | ||||
|     this.usageByUser = const [], | ||||
|   }); | ||||
| 
 | ||||
| @@ -25,11 +23,7 @@ class ServerStatsResponseDto { | ||||
| 
 | ||||
|   int videos; | ||||
| 
 | ||||
|   int objects; | ||||
| 
 | ||||
|   int usageRaw; | ||||
| 
 | ||||
|   String usage; | ||||
|   int usage; | ||||
| 
 | ||||
|   List<UsageByUserDto> usageByUser; | ||||
| 
 | ||||
| @@ -37,8 +31,6 @@ class ServerStatsResponseDto { | ||||
|   bool operator ==(Object other) => identical(this, other) || other is ServerStatsResponseDto && | ||||
|      other.photos == photos && | ||||
|      other.videos == videos && | ||||
|      other.objects == objects && | ||||
|      other.usageRaw == usageRaw && | ||||
|      other.usage == usage && | ||||
|      other.usageByUser == usageByUser; | ||||
| 
 | ||||
| @@ -47,20 +39,16 @@ class ServerStatsResponseDto { | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (photos.hashCode) + | ||||
|     (videos.hashCode) + | ||||
|     (objects.hashCode) + | ||||
|     (usageRaw.hashCode) + | ||||
|     (usage.hashCode) + | ||||
|     (usageByUser.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'ServerStatsResponseDto[photos=$photos, videos=$videos, objects=$objects, usageRaw=$usageRaw, usage=$usage, usageByUser=$usageByUser]'; | ||||
|   String toString() => 'ServerStatsResponseDto[photos=$photos, videos=$videos, usage=$usage, usageByUser=$usageByUser]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'photos'] = this.photos; | ||||
|       json[r'videos'] = this.videos; | ||||
|       json[r'objects'] = this.objects; | ||||
|       json[r'usageRaw'] = this.usageRaw; | ||||
|       json[r'usage'] = this.usage; | ||||
|       json[r'usageByUser'] = this.usageByUser; | ||||
|     return json; | ||||
| @@ -87,9 +75,7 @@ class ServerStatsResponseDto { | ||||
|       return ServerStatsResponseDto( | ||||
|         photos: mapValueOfType<int>(json, r'photos')!, | ||||
|         videos: mapValueOfType<int>(json, r'videos')!, | ||||
|         objects: mapValueOfType<int>(json, r'objects')!, | ||||
|         usageRaw: mapValueOfType<int>(json, r'usageRaw')!, | ||||
|         usage: mapValueOfType<String>(json, r'usage')!, | ||||
|         usage: mapValueOfType<int>(json, r'usage')!, | ||||
|         usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser'])!, | ||||
|       ); | ||||
|     } | ||||
| @@ -142,8 +128,6 @@ class ServerStatsResponseDto { | ||||
|   static const requiredKeys = <String>{ | ||||
|     'photos', | ||||
|     'videos', | ||||
|     'objects', | ||||
|     'usageRaw', | ||||
|     'usage', | ||||
|     'usageByUser', | ||||
|   }; | ||||
|   | ||||
							
								
								
									
										42
									
								
								mobile/openapi/lib/model/usage_by_user_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										42
									
								
								mobile/openapi/lib/model/usage_by_user_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -14,48 +14,54 @@ class UsageByUserDto { | ||||
|   /// Returns a new [UsageByUserDto] instance. | ||||
|   UsageByUserDto({ | ||||
|     required this.userId, | ||||
|     required this.videos, | ||||
|     required this.userFirstName, | ||||
|     required this.userLastName, | ||||
|     required this.photos, | ||||
|     required this.usageRaw, | ||||
|     required this.videos, | ||||
|     required this.usage, | ||||
|   }); | ||||
| 
 | ||||
|   String userId; | ||||
| 
 | ||||
|   int videos; | ||||
|   String userFirstName; | ||||
| 
 | ||||
|   String userLastName; | ||||
| 
 | ||||
|   int photos; | ||||
| 
 | ||||
|   int usageRaw; | ||||
|   int videos; | ||||
| 
 | ||||
|   String usage; | ||||
|   int usage; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is UsageByUserDto && | ||||
|      other.userId == userId && | ||||
|      other.videos == videos && | ||||
|      other.userFirstName == userFirstName && | ||||
|      other.userLastName == userLastName && | ||||
|      other.photos == photos && | ||||
|      other.usageRaw == usageRaw && | ||||
|      other.videos == videos && | ||||
|      other.usage == usage; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (userId.hashCode) + | ||||
|     (videos.hashCode) + | ||||
|     (userFirstName.hashCode) + | ||||
|     (userLastName.hashCode) + | ||||
|     (photos.hashCode) + | ||||
|     (usageRaw.hashCode) + | ||||
|     (videos.hashCode) + | ||||
|     (usage.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'UsageByUserDto[userId=$userId, videos=$videos, photos=$photos, usageRaw=$usageRaw, usage=$usage]'; | ||||
|   String toString() => 'UsageByUserDto[userId=$userId, userFirstName=$userFirstName, userLastName=$userLastName, photos=$photos, videos=$videos, usage=$usage]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'userId'] = this.userId; | ||||
|       json[r'videos'] = this.videos; | ||||
|       json[r'userFirstName'] = this.userFirstName; | ||||
|       json[r'userLastName'] = this.userLastName; | ||||
|       json[r'photos'] = this.photos; | ||||
|       json[r'usageRaw'] = this.usageRaw; | ||||
|       json[r'videos'] = this.videos; | ||||
|       json[r'usage'] = this.usage; | ||||
|     return json; | ||||
|   } | ||||
| @@ -80,10 +86,11 @@ class UsageByUserDto { | ||||
| 
 | ||||
|       return UsageByUserDto( | ||||
|         userId: mapValueOfType<String>(json, r'userId')!, | ||||
|         videos: mapValueOfType<int>(json, r'videos')!, | ||||
|         userFirstName: mapValueOfType<String>(json, r'userFirstName')!, | ||||
|         userLastName: mapValueOfType<String>(json, r'userLastName')!, | ||||
|         photos: mapValueOfType<int>(json, r'photos')!, | ||||
|         usageRaw: mapValueOfType<int>(json, r'usageRaw')!, | ||||
|         usage: mapValueOfType<String>(json, r'usage')!, | ||||
|         videos: mapValueOfType<int>(json, r'videos')!, | ||||
|         usage: mapValueOfType<int>(json, r'usage')!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
| @@ -134,9 +141,10 @@ class UsageByUserDto { | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'userId', | ||||
|     'videos', | ||||
|     'userFirstName', | ||||
|     'userLastName', | ||||
|     'photos', | ||||
|     'usageRaw', | ||||
|     'videos', | ||||
|     'usage', | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -16,27 +16,17 @@ void main() { | ||||
|   // final instance = ServerStatsResponseDto(); | ||||
| 
 | ||||
|   group('test ServerStatsResponseDto', () { | ||||
|     // int photos | ||||
|     // int photos (default value: 0) | ||||
|     test('to test the property `photos`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // int videos | ||||
|     // int videos (default value: 0) | ||||
|     test('to test the property `videos`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // int objects | ||||
|     test('to test the property `objects`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // int usageRaw | ||||
|     test('to test the property `usageRaw`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String usage | ||||
|     // int usage (default value: 0) | ||||
|     test('to test the property `usage`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|   | ||||
							
								
								
									
										15
									
								
								mobile/openapi/test/usage_by_user_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										15
									
								
								mobile/openapi/test/usage_by_user_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -21,8 +21,13 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // int videos | ||||
|     test('to test the property `videos`', () async { | ||||
|     // String userFirstName | ||||
|     test('to test the property `userFirstName`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String userLastName | ||||
|     test('to test the property `userLastName`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
| @@ -31,12 +36,12 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // int usageRaw | ||||
|     test('to test the property `usageRaw`', () async { | ||||
|     // int videos | ||||
|     test('to test the property `videos`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String usage | ||||
|     // int usage | ||||
|     test('to test the property `usage`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|   | ||||
| @@ -37,6 +37,7 @@ describe('Album service', () => { | ||||
|     shouldChangePassword: false, | ||||
|     oauthId: '', | ||||
|     tags: [], | ||||
|     assets: [], | ||||
|   }); | ||||
|   const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; | ||||
|   const sharedAlbumOwnerId = '2222'; | ||||
|   | ||||
| @@ -2,28 +2,14 @@ import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { UsageByUserDto } from './usage-by-user-response.dto'; | ||||
|  | ||||
| export class ServerStatsResponseDto { | ||||
|   constructor() { | ||||
|     this.photos = 0; | ||||
|     this.videos = 0; | ||||
|     this.usageByUser = []; | ||||
|     this.usageRaw = 0; | ||||
|     this.usage = ''; | ||||
|   } | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   photos = 0; | ||||
|  | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   photos!: number; | ||||
|  | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   videos!: number; | ||||
|  | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   objects!: number; | ||||
|   videos = 0; | ||||
|  | ||||
|   @ApiProperty({ type: 'integer', format: 'int64' }) | ||||
|   usageRaw!: number; | ||||
|  | ||||
|   @ApiProperty({ type: 'string' }) | ||||
|   usage!: string; | ||||
|   usage = 0; | ||||
|  | ||||
|   @ApiProperty({ | ||||
|     isArray: true, | ||||
| @@ -37,5 +23,5 @@ export class ServerStatsResponseDto { | ||||
|       }, | ||||
|     ], | ||||
|   }) | ||||
|   usageByUser!: UsageByUserDto[]; | ||||
|   usageByUser: UsageByUserDto[] = []; | ||||
| } | ||||
|   | ||||
| @@ -1,22 +1,16 @@ | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
|  | ||||
| export class UsageByUserDto { | ||||
|   constructor(userId: string) { | ||||
|     this.userId = userId; | ||||
|     this.videos = 0; | ||||
|     this.photos = 0; | ||||
|     this.usageRaw = 0; | ||||
|     this.usage = '0B'; | ||||
|   } | ||||
|  | ||||
|   @ApiProperty({ type: 'string' }) | ||||
|   userId: string; | ||||
|   userId!: string; | ||||
|   @ApiProperty({ type: 'string' }) | ||||
|   userFirstName!: string; | ||||
|   @ApiProperty({ type: 'string' }) | ||||
|   userLastName!: string; | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   videos: number; | ||||
|   photos!: number; | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   photos: number; | ||||
|   videos!: number; | ||||
|   @ApiProperty({ type: 'integer', format: 'int64' }) | ||||
|   usageRaw!: number; | ||||
|   @ApiProperty({ type: 'string' }) | ||||
|   usage!: string; | ||||
|   usage!: number; | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { ServerInfoService } from './server-info.service'; | ||||
| import { ServerInfoController } from './server-info.controller'; | ||||
| import { AssetEntity } from '@app/infra'; | ||||
| import { UserEntity } from '@app/infra'; | ||||
| import { TypeOrmModule } from '@nestjs/typeorm'; | ||||
|  | ||||
| @Module({ | ||||
|   imports: [TypeOrmModule.forFeature([AssetEntity])], | ||||
|   imports: [TypeOrmModule.forFeature([UserEntity])], | ||||
|   controllers: [ServerInfoController], | ||||
|   providers: [ServerInfoService], | ||||
| }) | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import { ServerInfoResponseDto } from './response-dto/server-info-response.dto'; | ||||
| import diskusage from 'diskusage'; | ||||
| import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto'; | ||||
| import { UsageByUserDto } from './response-dto/usage-by-user-response.dto'; | ||||
| import { AssetEntity } from '@app/infra'; | ||||
| import { UserEntity } from '@app/infra'; | ||||
| import { Repository } from 'typeorm'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { asHumanReadable } from '../../utils/human-readable.util'; | ||||
| @@ -12,8 +12,8 @@ import { asHumanReadable } from '../../utils/human-readable.util'; | ||||
| @Injectable() | ||||
| export class ServerInfoService { | ||||
|   constructor( | ||||
|     @InjectRepository(AssetEntity) | ||||
|     private assetRepository: Repository<AssetEntity>, | ||||
|     @InjectRepository(UserEntity) | ||||
|     private userRepository: Repository<UserEntity>, | ||||
|   ) {} | ||||
|  | ||||
|   async getServerInfo(): Promise<ServerInfoResponseDto> { | ||||
| @@ -33,44 +33,48 @@ export class ServerInfoService { | ||||
|   } | ||||
|  | ||||
|   async getStats(): Promise<ServerStatsResponseDto> { | ||||
|     const serverStats = new ServerStatsResponseDto(); | ||||
|  | ||||
|     type UserStatsQueryResponse = { | ||||
|       assetType: string; | ||||
|       assetCount: string; | ||||
|       totalSizeInBytes: string; | ||||
|       ownerId: string; | ||||
|       userId: string; | ||||
|       userFirstName: string; | ||||
|       userLastName: string; | ||||
|       photos: string; | ||||
|       videos: string; | ||||
|       usage: string; | ||||
|     }; | ||||
|  | ||||
|     const userStatsQueryResponse: UserStatsQueryResponse[] = await this.assetRepository | ||||
|       .createQueryBuilder('a') | ||||
|       .select('COUNT(a.id)', 'assetCount') | ||||
|       .addSelect('SUM(ei.fileSizeInByte)', 'totalSizeInBytes') | ||||
|       .addSelect('a."ownerId"') | ||||
|       .addSelect('a.type', 'assetType') | ||||
|       .where('a.isVisible = true') | ||||
|       .leftJoin('a.exifInfo', 'ei') | ||||
|       .groupBy('a."ownerId"') | ||||
|       .addGroupBy('a.type') | ||||
|     const userStatsQueryResponse: UserStatsQueryResponse[] = await this.userRepository | ||||
|       .createQueryBuilder('users') | ||||
|       .select('users.id', 'userId') | ||||
|       .addSelect('users.firstName', 'userFirstName') | ||||
|       .addSelect('users.lastName', 'userLastName') | ||||
|       .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), 0)', 'usage') | ||||
|       .leftJoin('users.assets', 'assets') | ||||
|       .leftJoin('assets.exifInfo', 'exif') | ||||
|       .groupBy('users.id') | ||||
|       .orderBy('users.createdAt', 'ASC') | ||||
|       .getRawMany(); | ||||
|  | ||||
|     const tmpMap = new Map<string, UsageByUserDto>(); | ||||
|     const getUsageByUser = (id: string) => tmpMap.get(id) || new UsageByUserDto(id); | ||||
|     userStatsQueryResponse.forEach((r) => { | ||||
|       const usageByUser = getUsageByUser(r.ownerId); | ||||
|       usageByUser.photos += r.assetType === 'IMAGE' ? parseInt(r.assetCount) : 0; | ||||
|       usageByUser.videos += r.assetType === 'VIDEO' ? parseInt(r.assetCount) : 0; | ||||
|       usageByUser.usageRaw += parseInt(r.totalSizeInBytes); | ||||
|       usageByUser.usage = asHumanReadable(usageByUser.usageRaw); | ||||
|     const usageByUser = userStatsQueryResponse.map((userStats) => { | ||||
|       const usage = new UsageByUserDto(); | ||||
|       usage.userId = userStats.userId; | ||||
|       usage.userFirstName = userStats.userFirstName; | ||||
|       usage.userLastName = userStats.userLastName; | ||||
|       usage.photos = Number(userStats.photos); | ||||
|       usage.videos = Number(userStats.videos); | ||||
|       usage.usage = Number(userStats.usage); | ||||
|  | ||||
|       serverStats.photos += r.assetType === 'IMAGE' ? parseInt(r.assetCount) : 0; | ||||
|       serverStats.videos += r.assetType === 'VIDEO' ? parseInt(r.assetCount) : 0; | ||||
|       serverStats.usageRaw += parseInt(r.totalSizeInBytes); | ||||
|       serverStats.usage = asHumanReadable(serverStats.usageRaw); | ||||
|       tmpMap.set(r.ownerId, usageByUser); | ||||
|       return usage; | ||||
|     }); | ||||
|  | ||||
|     serverStats.usageByUser = Array.from(tmpMap.values()); | ||||
|     const serverStats = new ServerStatsResponseDto(); | ||||
|     usageByUser.forEach((user) => { | ||||
|       serverStats.photos += user.photos; | ||||
|       serverStats.videos += user.videos; | ||||
|       serverStats.usage += user.usage; | ||||
|     }); | ||||
|     serverStats.usageByUser = usageByUser; | ||||
|  | ||||
|     return serverStats; | ||||
|   } | ||||
|   | ||||
| @@ -25,6 +25,7 @@ describe('TagService', () => { | ||||
|     deletedAt: undefined, | ||||
|     updatedAt: '2022-12-02T19:29:23.603Z', | ||||
|     tags: [], | ||||
|     assets: [], | ||||
|     oauthId: 'oauth-id-1', | ||||
|   }); | ||||
|  | ||||
|   | ||||
| @@ -4883,25 +4883,29 @@ | ||||
|           "userId": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "videos": { | ||||
|             "type": "integer" | ||||
|           "userFirstName": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "userLastName": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "photos": { | ||||
|             "type": "integer" | ||||
|           }, | ||||
|           "usageRaw": { | ||||
|             "type": "integer", | ||||
|             "format": "int64" | ||||
|           "videos": { | ||||
|             "type": "integer" | ||||
|           }, | ||||
|           "usage": { | ||||
|             "type": "string" | ||||
|             "type": "integer", | ||||
|             "format": "int64" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "userId", | ||||
|           "videos", | ||||
|           "userFirstName", | ||||
|           "userLastName", | ||||
|           "photos", | ||||
|           "usageRaw", | ||||
|           "videos", | ||||
|           "usage" | ||||
|         ] | ||||
|       }, | ||||
| @@ -4909,22 +4913,20 @@ | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "photos": { | ||||
|             "type": "integer" | ||||
|             "type": "integer", | ||||
|             "default": 0 | ||||
|           }, | ||||
|           "videos": { | ||||
|             "type": "integer" | ||||
|           }, | ||||
|           "objects": { | ||||
|             "type": "integer" | ||||
|           }, | ||||
|           "usageRaw": { | ||||
|             "type": "integer", | ||||
|             "format": "int64" | ||||
|             "default": 0 | ||||
|           }, | ||||
|           "usage": { | ||||
|             "type": "string" | ||||
|             "type": "integer", | ||||
|             "default": 0, | ||||
|             "format": "int64" | ||||
|           }, | ||||
|           "usageByUser": { | ||||
|             "default": [], | ||||
|             "title": "Array of usage for each user", | ||||
|             "example": [ | ||||
|               { | ||||
| @@ -4942,8 +4944,6 @@ | ||||
|         "required": [ | ||||
|           "photos", | ||||
|           "videos", | ||||
|           "objects", | ||||
|           "usageRaw", | ||||
|           "usage", | ||||
|           "usageByUser" | ||||
|         ] | ||||
|   | ||||
| @@ -54,6 +54,7 @@ const adminUser: UserEntity = Object.freeze({ | ||||
|   createdAt: '2021-01-01', | ||||
|   updatedAt: '2021-01-01', | ||||
|   tags: [], | ||||
|   assets: [], | ||||
| }); | ||||
|  | ||||
| const immichUser: UserEntity = Object.freeze({ | ||||
| @@ -69,6 +70,7 @@ const immichUser: UserEntity = Object.freeze({ | ||||
|   createdAt: '2021-01-01', | ||||
|   updatedAt: '2021-01-01', | ||||
|   tags: [], | ||||
|   assets: [], | ||||
| }); | ||||
|  | ||||
| const updatedImmichUser: UserEntity = Object.freeze({ | ||||
| @@ -84,6 +86,7 @@ const updatedImmichUser: UserEntity = Object.freeze({ | ||||
|   createdAt: '2021-01-01', | ||||
|   updatedAt: '2021-01-01', | ||||
|   tags: [], | ||||
|   assets: [], | ||||
| }); | ||||
|  | ||||
| const adminUserResponse = Object.freeze({ | ||||
|   | ||||
| @@ -76,6 +76,7 @@ export const userEntityStub = { | ||||
|     createdAt: '2021-01-01', | ||||
|     updatedAt: '2021-01-01', | ||||
|     tags: [], | ||||
|     assets: [], | ||||
|   }), | ||||
|   user1: Object.freeze<UserEntity>({ | ||||
|     ...authStub.user1, | ||||
| @@ -88,6 +89,7 @@ export const userEntityStub = { | ||||
|     createdAt: '2021-01-01', | ||||
|     updatedAt: '2021-01-01', | ||||
|     tags: [], | ||||
|     assets: [], | ||||
|   }), | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import { | ||||
|   PrimaryGeneratedColumn, | ||||
|   UpdateDateColumn, | ||||
| } from 'typeorm'; | ||||
| import { AssetEntity } from './asset.entity'; | ||||
| import { TagEntity } from './tag.entity'; | ||||
|  | ||||
| @Entity('users') | ||||
| @@ -49,4 +50,7 @@ export class UserEntity { | ||||
|  | ||||
|   @OneToMany(() => TagEntity, (tag) => tag.user) | ||||
|   tags!: TagEntity[]; | ||||
|  | ||||
|   @OneToMany(() => AssetEntity, (asset) => asset.owner) | ||||
|   assets!: AssetEntity[]; | ||||
| } | ||||
|   | ||||
							
								
								
									
										30
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										30
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -1549,19 +1549,7 @@ export interface ServerStatsResponseDto { | ||||
|      * @type {number} | ||||
|      * @memberof ServerStatsResponseDto | ||||
|      */ | ||||
|     'objects': number; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {number} | ||||
|      * @memberof ServerStatsResponseDto | ||||
|      */ | ||||
|     'usageRaw': number; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof ServerStatsResponseDto | ||||
|      */ | ||||
|     'usage': string; | ||||
|     'usage': number; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {Array<UsageByUserDto>} | ||||
| @@ -2184,10 +2172,16 @@ export interface UsageByUserDto { | ||||
|     'userId': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {number} | ||||
|      * @type {string} | ||||
|      * @memberof UsageByUserDto | ||||
|      */ | ||||
|     'videos': number; | ||||
|     'userFirstName': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof UsageByUserDto | ||||
|      */ | ||||
|     'userLastName': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {number} | ||||
| @@ -2199,13 +2193,13 @@ export interface UsageByUserDto { | ||||
|      * @type {number} | ||||
|      * @memberof UsageByUserDto | ||||
|      */ | ||||
|     'usageRaw': number; | ||||
|     'videos': number; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @type {number} | ||||
|      * @memberof UsageByUserDto | ||||
|      */ | ||||
|     'usage': string; | ||||
|     'usage': number; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|   | ||||
| @@ -1,43 +1,20 @@ | ||||
| <script lang="ts"> | ||||
| 	import { api, ServerStatsResponseDto, UserResponseDto } from '@api'; | ||||
| 	import { ServerStatsResponseDto } from '@api'; | ||||
| 	import CameraIris from 'svelte-material-icons/CameraIris.svelte'; | ||||
| 	import PlayCircle from 'svelte-material-icons/PlayCircle.svelte'; | ||||
| 	import Memory from 'svelte-material-icons/Memory.svelte'; | ||||
| 	import StatsCard from './stats-card.svelte'; | ||||
| 	import { getBytesWithUnit, asByteUnitString } from '../../../utils/byte-units'; | ||||
| 	import { onMount, onDestroy } from 'svelte'; | ||||
| 	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; | ||||
| 	import { asByteUnitString, getBytesWithUnit } from '../../../utils/byte-units'; | ||||
| 	import { locale } from '$lib/stores/preferences.store'; | ||||
|  | ||||
| 	export let allUsers: Array<UserResponseDto>; | ||||
|  | ||||
| 	let stats: ServerStatsResponseDto; | ||||
| 	let setIntervalHandler: NodeJS.Timer; | ||||
|  | ||||
| 	onMount(async () => { | ||||
| 		const { data } = await api.serverInfoApi.getStats(); | ||||
| 		stats = data; | ||||
|  | ||||
| 		setIntervalHandler = setInterval(async () => { | ||||
| 			const { data } = await api.serverInfoApi.getStats(); | ||||
| 			stats = data; | ||||
| 		}, 5000); | ||||
| 	}); | ||||
|  | ||||
| 	onDestroy(() => { | ||||
| 		clearInterval(setIntervalHandler); | ||||
| 	}); | ||||
|  | ||||
| 	const getFullName = (userId: string) => { | ||||
| 		let name = 'Admin'; // since we do not have admin user in allUsers | ||||
| 		allUsers.forEach((user) => { | ||||
| 			if (user.id === userId) name = `${user.firstName} ${user.lastName}`; | ||||
| 		}); | ||||
| 		return name; | ||||
| 	export let stats: ServerStatsResponseDto = { | ||||
| 		photos: 0, | ||||
| 		videos: 0, | ||||
| 		usage: 0, | ||||
| 		usageByUser: [] | ||||
| 	}; | ||||
|  | ||||
| 	// Stats are unavailable if data is not loaded yet | ||||
| 	$: [spaceUsage, spaceUnit] = getBytesWithUnit(stats ? stats.usageRaw : 0); | ||||
| 	$: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, 0); | ||||
| </script> | ||||
|  | ||||
| <div class="flex flex-col gap-5"> | ||||
| @@ -45,14 +22,9 @@ | ||||
| 		<p class="text-sm dark:text-immich-dark-fg">TOTAL USAGE</p> | ||||
|  | ||||
| 		<div class="flex mt-5 justify-between"> | ||||
| 			<StatsCard logo={CameraIris} title={'PHOTOS'} value={stats && stats.photos.toString()} /> | ||||
| 			<StatsCard logo={PlayCircle} title={'VIDEOS'} value={stats && stats.videos.toString()} /> | ||||
| 			<StatsCard | ||||
| 				logo={Memory} | ||||
| 				title={'STORAGE'} | ||||
| 				value={stats && spaceUsage.toString()} | ||||
| 				unit={spaceUnit} | ||||
| 			/> | ||||
| 			<StatsCard logo={CameraIris} title="PHOTOS" value={stats.photos} /> | ||||
| 			<StatsCard logo={PlayCircle} title="VIDEOS" value={stats.videos} /> | ||||
| 			<StatsCard logo={Memory} title="STORAGE" value={statsUsage} unit={statsUsageUnit} /> | ||||
| 		</div> | ||||
| 	</div> | ||||
|  | ||||
| @@ -72,32 +44,19 @@ | ||||
| 			<tbody | ||||
| 				class="overflow-y-auto rounded-md w-full max-h-[320px] block border dark:border-immich-dark-gray dark:text-immich-dark-fg" | ||||
| 			> | ||||
| 				{#if stats} | ||||
| 					{#each stats.usageByUser as user, i} | ||||
| 						<tr | ||||
| 							class={`text-center flex place-items-center w-full h-[50px] ${ | ||||
| 								i % 2 == 0 | ||||
| 									? 'bg-immich-gray dark:bg-immich-dark-gray/75' | ||||
| 									: 'bg-immich-bg dark:bg-immich-dark-gray/50' | ||||
| 							}`} | ||||
| 						> | ||||
| 							<td class="text-sm px-2 w-1/4 text-ellipsis">{getFullName(user.userId)}</td> | ||||
| 							<td class="text-sm px-2 w-1/4 text-ellipsis">{user.photos.toLocaleString($locale)}</td | ||||
| 							> | ||||
| 							<td class="text-sm px-2 w-1/4 text-ellipsis">{user.videos.toLocaleString($locale)}</td | ||||
| 							> | ||||
| 							<td class="text-sm px-2 w-1/4 text-ellipsis">{asByteUnitString(user.usageRaw)}</td> | ||||
| 						</tr> | ||||
| 					{/each} | ||||
| 				{:else} | ||||
| 				{#each stats.usageByUser as user (user.userId)} | ||||
| 					<tr | ||||
| 						class="text-center flex place-items-center w-full h-[50px] bg-immich-gray dark:bg-immich-dark-gray/75" | ||||
| 						class="text-center flex place-items-center w-full h-[50px] even:bg-immich-bg even:dark:bg-immich-dark-gray/50 odd:bg-immich-gray odd:dark:bg-immich-dark-gray/75" | ||||
| 					> | ||||
| 						<td class="w-full flex justify-center"> | ||||
| 							<LoadingSpinner /> | ||||
| 						</td> | ||||
| 						<td class="text-sm px-2 w-1/4 text-ellipsis" | ||||
| 							>{user.userFirstName} {user.userLastName}</td | ||||
| 						> | ||||
| 						<td class="text-sm px-2 w-1/4 text-ellipsis">{user.photos.toLocaleString($locale)}</td> | ||||
| 						<td class="text-sm px-2 w-1/4 text-ellipsis">{user.videos.toLocaleString($locale)}</td> | ||||
| 						<td class="text-sm px-2 w-1/4 text-ellipsis">{asByteUnitString(user.usage, $locale)}</td | ||||
| 						> | ||||
| 					</tr> | ||||
| 				{/if} | ||||
| 				{/each} | ||||
| 			</tbody> | ||||
| 		</table> | ||||
| 	</div> | ||||
|   | ||||
| @@ -1,19 +1,14 @@ | ||||
| <script lang="ts"> | ||||
| 	import type Icon from 'svelte-material-icons/AbTesting.svelte'; | ||||
| 	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; | ||||
|  | ||||
| 	export let logo: typeof Icon; | ||||
| 	export let title: string; | ||||
| 	export let value: string; | ||||
| 	export let value: number; | ||||
| 	export let unit: string | undefined = undefined; | ||||
|  | ||||
| 	$: zeros = () => { | ||||
| 		if (!value) { | ||||
| 			return ''; | ||||
| 		} | ||||
|  | ||||
| 		const maxLength = 13; | ||||
| 		const valueLength = parseInt(value).toString().length; | ||||
| 		const valueLength = value.toString().length; | ||||
| 		const zeroLength = maxLength - valueLength; | ||||
|  | ||||
| 		return '0'.repeat(zeroLength); | ||||
| @@ -29,15 +24,9 @@ | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="relative text-center font-mono font-semibold text-2xl"> | ||||
| 		{#if value !== undefined} | ||||
| 			<span class="text-[#DCDADA] dark:text-[#525252]">{zeros()}</span><span | ||||
| 				class="text-immich-primary dark:text-immich-dark-primary">{parseInt(value)}</span | ||||
| 			> | ||||
| 		{:else} | ||||
| 			<div class="flex justify-end pr-2"> | ||||
| 				<LoadingSpinner /> | ||||
| 			</div> | ||||
| 		{/if} | ||||
| 		<span class="text-[#DCDADA] dark:text-[#525252]">{zeros()}</span><span | ||||
| 			class="text-immich-primary dark:text-immich-dark-primary">{value}</span | ||||
| 		> | ||||
| 		{#if unit} | ||||
| 			<span class="absolute -top-5 right-2 text-base font-light text-gray-400">{unit}</span> | ||||
| 		{/if} | ||||
|   | ||||
| @@ -135,7 +135,7 @@ | ||||
|  | ||||
| 							<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p> | ||||
| 						{/if} | ||||
| 						<p>{asByteUnitString(asset.exifInfo.fileSizeInByte)}</p> | ||||
| 						<p>{asByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
|   | ||||
| @@ -5,6 +5,7 @@ | ||||
| 	import LoadingSpinner from './loading-spinner.svelte'; | ||||
| 	import { api, ServerInfoResponseDto } from '@api'; | ||||
| 	import { asByteUnitString } from '../../utils/byte-units'; | ||||
| 	import { locale } from '$lib/stores/preferences.store'; | ||||
|  | ||||
| 	let isServerOk = true; | ||||
| 	let serverVersion = ''; | ||||
| @@ -63,7 +64,8 @@ | ||||
| 					/> | ||||
| 				</div> | ||||
| 				<p class="text-xs"> | ||||
| 					{asByteUnitString(serverInfo?.diskUseRaw)} of {asByteUnitString(serverInfo?.diskSizeRaw)} used | ||||
| 					{asByteUnitString(serverInfo?.diskUseRaw, $locale)} of | ||||
| 					{asByteUnitString(serverInfo?.diskSizeRaw, $locale)} used | ||||
| 				</p> | ||||
| 			{:else} | ||||
| 				<div class="mt-2"> | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
| 	import { asByteUnitString } from '$lib/utils/byte-units'; | ||||
| 	import { UploadAsset } from '$lib/models/upload-asset'; | ||||
| 	import ImmichLogo from './immich-logo.svelte'; | ||||
| 	import { locale } from '$lib/stores/preferences.store'; | ||||
|  | ||||
| 	export let uploadAsset: UploadAsset; | ||||
|  | ||||
| @@ -50,7 +51,7 @@ | ||||
| 		<input | ||||
| 			disabled | ||||
| 			class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2" | ||||
| 			value={`[${asByteUnitString(uploadAsset.file.size)}] ${uploadAsset.file.name}`} | ||||
| 			value={`[${asByteUnitString(uploadAsset.file.size, $locale)}] ${uploadAsset.file.name}`} | ||||
| 		/> | ||||
|  | ||||
| 		<div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative"> | ||||
|   | ||||
| @@ -38,8 +38,7 @@ export function getBytesWithUnit(bytes: number, maxPrecision = 1): [number, stri | ||||
|  * @param maxPrecision maximum number of decimal places, default is `1` | ||||
|  * @returns localized bytes with unit as string | ||||
|  */ | ||||
| export function asByteUnitString(bytes: number, maxPrecision = 1): string { | ||||
| 	const locale = Array.from(navigator.languages); | ||||
| export function asByteUnitString(bytes: number, locale?: string, maxPrecision = 1): string { | ||||
| 	const [size, unit] = getBytesWithUnit(bytes, maxPrecision); | ||||
| 	return `${size.toLocaleString(locale)} ${unit}`; | ||||
| } | ||||
|   | ||||
| @@ -10,12 +10,12 @@ export const load = (async ({ parent, locals: { api } }) => { | ||||
| 		throw redirect(302, '/photos'); | ||||
| 	} | ||||
|  | ||||
| 	const { data: allUsers } = await api.userApi.getAllUsers(false); | ||||
| 	const { data: stats } = await api.serverInfoApi.getStats(); | ||||
|  | ||||
| 	return { | ||||
| 		allUsers, | ||||
| 		stats, | ||||
| 		meta: { | ||||
| 			title: 'Server Status' | ||||
| 			title: 'Server Stats' | ||||
| 		} | ||||
| 	}; | ||||
| }) satisfies PageServerLoad; | ||||
|   | ||||
| @@ -1,8 +1,22 @@ | ||||
| <script lang="ts"> | ||||
| 	import { onMount, onDestroy } from 'svelte'; | ||||
| 	import { api } from '@api'; | ||||
| 	import ServerStatsPanel from '$lib/components/admin-page/server-stats/server-stats-panel.svelte'; | ||||
| 	import { page } from '$app/stores'; | ||||
| 	import type { PageData } from './$types'; | ||||
|  | ||||
| 	export let data: PageData; | ||||
| 	let setIntervalHandler: NodeJS.Timer; | ||||
|  | ||||
| 	onMount(async () => { | ||||
| 		setIntervalHandler = setInterval(async () => { | ||||
| 			const { data: stats } = await api.serverInfoApi.getStats(); | ||||
| 			data.stats = stats; | ||||
| 		}, 5000); | ||||
| 	}); | ||||
|  | ||||
| 	onDestroy(() => { | ||||
| 		clearInterval(setIntervalHandler); | ||||
| 	}); | ||||
| </script> | ||||
|  | ||||
| {#if $page.data.allUsers} | ||||
| 	<ServerStatsPanel allUsers={$page.data.allUsers} /> | ||||
| {/if} | ||||
| <ServerStatsPanel stats={data.stats} /> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user