Просмотр исходного кода

feat(web) add asset count stats on admin page (#843)

Zeeshan Khan 2 лет назад
Родитель
Сommit
a6eea4d096
40 измененных файлов с 1155 добавлено и 89 удалено
  1. 4 0
      mobile/openapi/.openapi-generator/FILES
  2. 3 0
      mobile/openapi/README.md
  3. 16 0
      mobile/openapi/doc/AssetCountResponseDto.md
  4. 38 0
      mobile/openapi/doc/ServerInfoApi.md
  5. 20 0
      mobile/openapi/doc/ServerStatsResponseDto.md
  6. 20 0
      mobile/openapi/doc/UsageByUserDto.md
  7. 2 0
      mobile/openapi/lib/api.dart
  8. 41 0
      mobile/openapi/lib/api/server_info_api.dart
  9. 4 0
      mobile/openapi/lib/api_client.dart
  10. 119 0
      mobile/openapi/lib/model/asset_count_response_dto.dart
  11. 151 0
      mobile/openapi/lib/model/server_stats_response_dto.dart
  12. 151 0
      mobile/openapi/lib/model/usage_by_user_dto.dart
  13. 32 0
      mobile/openapi/test/asset_count_response_dto_test.dart
  14. 42 0
      mobile/openapi/test/server_stats_response_dto_test.dart
  15. 42 0
      mobile/openapi/test/usage_by_user_dto_test.dart
  16. 1 0
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  17. 1 1
      server/apps/immich/src/api-v1/auth/auth.service.ts
  18. 6 6
      server/apps/immich/src/api-v1/auth/response-dto/logout-response.dto.ts
  19. 43 0
      server/apps/immich/src/api-v1/server-info/response-dto/server-stats-response.dto.ts
  20. 23 0
      server/apps/immich/src/api-v1/server-info/response-dto/usage-by-user-response.dto.ts
  21. 6 0
      server/apps/immich/src/api-v1/server-info/server-info.controller.ts
  22. 3 0
      server/apps/immich/src/api-v1/server-info/server-info.module.ts
  23. 69 1
      server/apps/immich/src/api-v1/server-info/server-info.service.ts
  24. 2 2
      server/apps/immich/src/api-v1/user/dto/create-user.dto.spec.ts
  25. 2 2
      server/apps/immich/src/api-v1/user/user-repository.ts
  26. 2 2
      server/apps/immich/src/api-v1/user/user.module.ts
  27. 0 0
      server/immich-openapi-specs.json
  28. 9 9
      server/libs/common/src/config/app.config.ts
  29. 1 1
      server/libs/database/src/entities/album.entity.ts
  30. 1 1
      server/libs/database/src/entities/asset-album.entity.ts
  31. 13 11
      server/libs/database/src/migrations/1656888591977-RenameAssetAlbumIdSequence.ts
  32. 7 9
      server/libs/database/src/migrations/1656888918620-DropExifTextSearchableColumn.ts
  33. 62 36
      server/libs/database/src/migrations/1656889061566-MatchMigrationsWithTypeORMEntities.ts
  34. 4 3
      server/libs/database/src/migrations/1661881837496-AddAssetChecksum.ts
  35. 4 3
      server/libs/database/src/migrations/1661971370662-UpdateAssetTableWithNewUniqueConstraint.ts
  36. 142 0
      web/src/api/open-api/api.ts
  37. 52 0
      web/src/lib/components/admin-page/server-stats.svelte
  38. 2 1
      web/src/lib/models/admin-sidebar-selection.ts
  39. 3 1
      web/src/routes/admin/+page.server.ts
  40. 12 0
      web/src/routes/admin/+page.svelte

+ 4 - 0
mobile/openapi/.openapi-generator/FILES

@@ -48,6 +48,7 @@ doc/SearchAssetDto.md
 doc/ServerInfoApi.md
 doc/ServerInfoResponseDto.md
 doc/ServerPingResponse.md
+doc/ServerStatsResponseDto.md
 doc/ServerVersionReponseDto.md
 doc/SignUpDto.md
 doc/SmartInfoResponseDto.md
@@ -56,6 +57,7 @@ doc/TimeGroupEnum.md
 doc/UpdateAlbumDto.md
 doc/UpdateDeviceInfoDto.md
 doc/UpdateUserDto.md
+doc/UsageByUserDto.md
 doc/UserApi.md
 doc/UserCountResponseDto.md
 doc/UserResponseDto.md
@@ -117,6 +119,7 @@ lib/model/remove_assets_dto.dart
 lib/model/search_asset_dto.dart
 lib/model/server_info_response_dto.dart
 lib/model/server_ping_response.dart
+lib/model/server_stats_response_dto.dart
 lib/model/server_version_reponse_dto.dart
 lib/model/sign_up_dto.dart
 lib/model/smart_info_response_dto.dart
@@ -125,6 +128,7 @@ lib/model/time_group_enum.dart
 lib/model/update_album_dto.dart
 lib/model/update_device_info_dto.dart
 lib/model/update_user_dto.dart
+lib/model/usage_by_user_dto.dart
 lib/model/user_count_response_dto.dart
 lib/model/user_response_dto.dart
 lib/model/validate_access_token_response_dto.dart

+ 3 - 0
mobile/openapi/README.md

@@ -102,6 +102,7 @@ Class | Method | HTTP request | Description
 *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} | 
 *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
+*ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats | 
 *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | 
 *UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /user/profile-image | 
 *UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /user | 
@@ -155,6 +156,7 @@ Class | Method | HTTP request | Description
  - [SearchAssetDto](doc//SearchAssetDto.md)
  - [ServerInfoResponseDto](doc//ServerInfoResponseDto.md)
  - [ServerPingResponse](doc//ServerPingResponse.md)
+ - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
  - [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)
  - [SignUpDto](doc//SignUpDto.md)
  - [SmartInfoResponseDto](doc//SmartInfoResponseDto.md)
@@ -163,6 +165,7 @@ Class | Method | HTTP request | Description
  - [UpdateAlbumDto](doc//UpdateAlbumDto.md)
  - [UpdateDeviceInfoDto](doc//UpdateDeviceInfoDto.md)
  - [UpdateUserDto](doc//UpdateUserDto.md)
+ - [UsageByUserDto](doc//UsageByUserDto.md)
  - [UserCountResponseDto](doc//UserCountResponseDto.md)
  - [UserResponseDto](doc//UserResponseDto.md)
  - [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)

+ 16 - 0
mobile/openapi/doc/AssetCountResponseDto.md

@@ -0,0 +1,16 @@
+# openapi.model.AssetCountResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**photos** | **int** |  | 
+**videos** | **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)
+
+

+ 38 - 0
mobile/openapi/doc/ServerInfoApi.md

@@ -11,6 +11,7 @@ Method | HTTP request | Description
 ------------- | ------------- | -------------
 [**getServerInfo**](ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 [**getServerVersion**](ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
+[**getStats**](ServerInfoApi.md#getstats) | **GET** /server-info/stats | 
 [**pingServer**](ServerInfoApi.md#pingserver) | **GET** /server-info/ping | 
 
 
@@ -88,6 +89,43 @@ No authorization required
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
+# **getStats**
+> ServerStatsResponseDto getStats()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+
+final api_instance = ServerInfoApi();
+
+try {
+    final result = api_instance.getStats();
+    print(result);
+} catch (e) {
+    print('Exception when calling ServerInfoApi->getStats: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**ServerStatsResponseDto**](ServerStatsResponseDto.md)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **pingServer**
 > ServerPingResponse pingServer()
 

+ 20 - 0
mobile/openapi/doc/ServerStatsResponseDto.md

@@ -0,0 +1,20 @@
+# openapi.model.ServerStatsResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**photos** | **int** |  | 
+**videos** | **int** |  | 
+**objects** | **int** |  | 
+**usageRaw** | **int** |  | 
+**usage** | **String** |  | 
+**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)
+
+

+ 20 - 0
mobile/openapi/doc/UsageByUserDto.md

@@ -0,0 +1,20 @@
+# openapi.model.UsageByUserDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**userId** | **String** |  | 
+**objects** | **int** |  | 
+**videos** | **int** |  | 
+**photos** | **int** |  | 
+**usageRaw** | **int** |  | 
+**usage** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 2 - 0
mobile/openapi/lib/api.dart

@@ -75,6 +75,7 @@ part 'model/remove_assets_dto.dart';
 part 'model/search_asset_dto.dart';
 part 'model/server_info_response_dto.dart';
 part 'model/server_ping_response.dart';
+part 'model/server_stats_response_dto.dart';
 part 'model/server_version_reponse_dto.dart';
 part 'model/sign_up_dto.dart';
 part 'model/smart_info_response_dto.dart';
@@ -83,6 +84,7 @@ part 'model/time_group_enum.dart';
 part 'model/update_album_dto.dart';
 part 'model/update_device_info_dto.dart';
 part 'model/update_user_dto.dart';
+part 'model/usage_by_user_dto.dart';
 part 'model/user_count_response_dto.dart';
 part 'model/user_response_dto.dart';
 part 'model/validate_access_token_response_dto.dart';

+ 41 - 0
mobile/openapi/lib/api/server_info_api.dart

@@ -98,6 +98,47 @@ class ServerInfoApi {
     return null;
   }
 
+  /// Performs an HTTP 'GET /server-info/stats' operation and returns the [Response].
+  Future<Response> getStatsWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/server-info/stats';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<ServerStatsResponseDto?> getStats() async {
+    final response = await getStatsWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerStatsResponseDto',) as ServerStatsResponseDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /server-info/ping' operation and returns the [Response].
   Future<Response> pingServerWithHttpInfo() async {
     // ignore: prefer_const_declarations

+ 4 - 0
mobile/openapi/lib/api_client.dart

@@ -272,6 +272,8 @@ class ApiClient {
           return ServerInfoResponseDto.fromJson(value);
         case 'ServerPingResponse':
           return ServerPingResponse.fromJson(value);
+        case 'ServerStatsResponseDto':
+          return ServerStatsResponseDto.fromJson(value);
         case 'ServerVersionReponseDto':
           return ServerVersionReponseDto.fromJson(value);
         case 'SignUpDto':
@@ -288,6 +290,8 @@ class ApiClient {
           return UpdateDeviceInfoDto.fromJson(value);
         case 'UpdateUserDto':
           return UpdateUserDto.fromJson(value);
+        case 'UsageByUserDto':
+          return UsageByUserDto.fromJson(value);
         case 'UserCountResponseDto':
           return UserCountResponseDto.fromJson(value);
         case 'UserResponseDto':

+ 119 - 0
mobile/openapi/lib/model/asset_count_response_dto.dart

@@ -0,0 +1,119 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class AssetCountResponseDto {
+  /// Returns a new [AssetCountResponseDto] instance.
+  AssetCountResponseDto({
+    required this.photos,
+    required this.videos,
+  });
+
+  int photos;
+
+  int videos;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is AssetCountResponseDto &&
+     other.photos == photos &&
+     other.videos == videos;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (photos.hashCode) +
+    (videos.hashCode);
+
+  @override
+  String toString() => 'AssetCountResponseDto[photos=$photos, videos=$videos]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'photos'] = photos;
+      _json[r'videos'] = videos;
+    return _json;
+  }
+
+  /// Returns a new [AssetCountResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static AssetCountResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "AssetCountResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "AssetCountResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return AssetCountResponseDto(
+        photos: mapValueOfType<int>(json, r'photos')!,
+        videos: mapValueOfType<int>(json, r'videos')!,
+      );
+    }
+    return null;
+  }
+
+  static List<AssetCountResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AssetCountResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AssetCountResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, AssetCountResponseDto> mapFromJson(dynamic json) {
+    final map = <String, AssetCountResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AssetCountResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of AssetCountResponseDto-objects as value to a dart map
+  static Map<String, List<AssetCountResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AssetCountResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AssetCountResponseDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'photos',
+    'videos',
+  };
+}
+

+ 151 - 0
mobile/openapi/lib/model/server_stats_response_dto.dart

@@ -0,0 +1,151 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+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.usageByUser = const [],
+  });
+
+  int photos;
+
+  int videos;
+
+  int objects;
+
+  int usageRaw;
+
+  String usage;
+
+  List<UsageByUserDto> usageByUser;
+
+  @override
+  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;
+
+  @override
+  int get hashCode =>
+    // 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]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'photos'] = photos;
+      _json[r'videos'] = videos;
+      _json[r'objects'] = objects;
+      _json[r'usageRaw'] = usageRaw;
+      _json[r'usage'] = usage;
+      _json[r'usageByUser'] = usageByUser;
+    return _json;
+  }
+
+  /// Returns a new [ServerStatsResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static ServerStatsResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "ServerStatsResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "ServerStatsResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      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')!,
+        usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<ServerStatsResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <ServerStatsResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = ServerStatsResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, ServerStatsResponseDto> mapFromJson(dynamic json) {
+    final map = <String, ServerStatsResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = ServerStatsResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of ServerStatsResponseDto-objects as value to a dart map
+  static Map<String, List<ServerStatsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<ServerStatsResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = ServerStatsResponseDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'photos',
+    'videos',
+    'objects',
+    'usageRaw',
+    'usage',
+    'usageByUser',
+  };
+}
+

+ 151 - 0
mobile/openapi/lib/model/usage_by_user_dto.dart

@@ -0,0 +1,151 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class UsageByUserDto {
+  /// Returns a new [UsageByUserDto] instance.
+  UsageByUserDto({
+    required this.userId,
+    required this.objects,
+    required this.videos,
+    required this.photos,
+    required this.usageRaw,
+    required this.usage,
+  });
+
+  String userId;
+
+  int objects;
+
+  int videos;
+
+  int photos;
+
+  int usageRaw;
+
+  String usage;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is UsageByUserDto &&
+     other.userId == userId &&
+     other.objects == objects &&
+     other.videos == videos &&
+     other.photos == photos &&
+     other.usageRaw == usageRaw &&
+     other.usage == usage;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (userId.hashCode) +
+    (objects.hashCode) +
+    (videos.hashCode) +
+    (photos.hashCode) +
+    (usageRaw.hashCode) +
+    (usage.hashCode);
+
+  @override
+  String toString() => 'UsageByUserDto[userId=$userId, objects=$objects, videos=$videos, photos=$photos, usageRaw=$usageRaw, usage=$usage]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'userId'] = userId;
+      _json[r'objects'] = objects;
+      _json[r'videos'] = videos;
+      _json[r'photos'] = photos;
+      _json[r'usageRaw'] = usageRaw;
+      _json[r'usage'] = usage;
+    return _json;
+  }
+
+  /// Returns a new [UsageByUserDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static UsageByUserDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "UsageByUserDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "UsageByUserDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return UsageByUserDto(
+        userId: mapValueOfType<String>(json, r'userId')!,
+        objects: mapValueOfType<int>(json, r'objects')!,
+        videos: mapValueOfType<int>(json, r'videos')!,
+        photos: mapValueOfType<int>(json, r'photos')!,
+        usageRaw: mapValueOfType<int>(json, r'usageRaw')!,
+        usage: mapValueOfType<String>(json, r'usage')!,
+      );
+    }
+    return null;
+  }
+
+  static List<UsageByUserDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <UsageByUserDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = UsageByUserDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, UsageByUserDto> mapFromJson(dynamic json) {
+    final map = <String, UsageByUserDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = UsageByUserDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of UsageByUserDto-objects as value to a dart map
+  static Map<String, List<UsageByUserDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<UsageByUserDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = UsageByUserDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'userId',
+    'objects',
+    'videos',
+    'photos',
+    'usageRaw',
+    'usage',
+  };
+}
+

+ 32 - 0
mobile/openapi/test/asset_count_response_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for AssetCountResponseDto
+void main() {
+  // final instance = AssetCountResponseDto();
+
+  group('test AssetCountResponseDto', () {
+    // int photos
+    test('to test the property `photos`', () async {
+      // TODO
+    });
+
+    // int videos
+    test('to test the property `videos`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 42 - 0
mobile/openapi/test/server_stats_response_dto_test.dart

@@ -0,0 +1,42 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for ServerStatsResponseDto
+void main() {
+  // final instance = ServerStatsResponseDto();
+
+  group('test ServerStatsResponseDto', () {
+    // int photos
+    test('to test the property `photos`', () async {
+      // TODO
+    });
+
+    // int videos
+    test('to test the property `videos`', () async {
+      // TODO
+    });
+
+    // int objects
+    test('to test the property `objects`', () async {
+      // TODO
+    });
+
+    // UsagePerUser diskUsagesByUser
+    test('to test the property `diskUsagesByUser`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 42 - 0
mobile/openapi/test/usage_by_user_dto_test.dart

@@ -0,0 +1,42 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for UsageByUserDto
+void main() {
+  // final instance = UsageByUserDto();
+
+  group('test UsageByUserDto', () {
+    // int usageRaw
+    test('to test the property `usageRaw`', () async {
+      // TODO
+    });
+
+    // num objects
+    test('to test the property `objects`', () async {
+      // TODO
+    });
+
+    // num videos
+    test('to test the property `videos`', () async {
+      // TODO
+    });
+
+    // num images
+    test('to test the property `images`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 1 - 0
server/apps/immich/src/api-v1/asset/asset.controller.ts

@@ -182,6 +182,7 @@ export class AssetController {
   async getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
     return this.assetService.getAssetCountByUserId(authUser);
   }
+
   /**
    * Get all AssetEntity belong to the user
    */

+ 1 - 1
server/apps/immich/src/api-v1/auth/auth.service.ts

@@ -54,7 +54,7 @@ export class AuthService {
     const validatedUser = await this.validateUser(loginCredential);
 
     if (!validatedUser) {
-      Logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`)
+      Logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
       throw new BadRequestException('Incorrect email or password');
     }
 

+ 6 - 6
server/apps/immich/src/api-v1/auth/response-dto/logout-response.dto.ts

@@ -1,10 +1,10 @@
 import { ApiResponseProperty } from '@nestjs/swagger';
 
 export class LogoutResponseDto {
-    constructor (successful: boolean) {
-        this.successful = successful;
-    }
+  constructor(successful: boolean) {
+    this.successful = successful;
+  }
 
-    @ApiResponseProperty()
-    successful!: boolean;
-};
+  @ApiResponseProperty()
+  successful!: boolean;
+}

+ 43 - 0
server/apps/immich/src/api-v1/server-info/response-dto/server-stats-response.dto.ts

@@ -0,0 +1,43 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { UsageByUserDto } from './usage-by-user-response.dto';
+
+export class ServerStatsResponseDto {
+  constructor() {
+    this.photos = 0;
+    this.videos = 0;
+    this.objects = 0;
+    this.usageByUser = [];
+    this.usageRaw = 0;
+    this.usage = '';
+  }
+
+  @ApiProperty({ type: 'integer' })
+  photos!: number;
+
+  @ApiProperty({ type: 'integer' })
+  videos!: number;
+
+  @ApiProperty({ type: 'integer' })
+  objects!: number;
+
+  @ApiProperty({ type: 'integer', format: 'int64' })
+  usageRaw!: number;
+
+  @ApiProperty({ type: 'string' })
+  usage!: string;
+
+  @ApiProperty({
+    isArray: true,
+    type: UsageByUserDto,
+    title: 'Array of usage for each user',
+    example: [
+      {
+        photos: 1,
+        videos: 1,
+        objects: 1,
+        diskUsageRaw: 1,
+      },
+    ],
+  })
+  usageByUser!: UsageByUserDto[];
+}

+ 23 - 0
server/apps/immich/src/api-v1/server-info/response-dto/usage-by-user-response.dto.ts

@@ -0,0 +1,23 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class UsageByUserDto {
+  constructor(userId: string) {
+    this.userId = userId;
+    this.objects = 0;
+    this.videos = 0;
+    this.photos = 0;
+  }
+
+  @ApiProperty({ type: 'string' })
+  userId: string;
+  @ApiProperty({ type: 'integer' })
+  objects: number;
+  @ApiProperty({ type: 'integer' })
+  videos: number;
+  @ApiProperty({ type: 'integer' })
+  photos: number;
+  @ApiProperty({ type: 'integer', format: 'int64' })
+  usageRaw!: number;
+  @ApiProperty({ type: 'string' })
+  usage!: string;
+}

+ 6 - 0
server/apps/immich/src/api-v1/server-info/server-info.controller.ts

@@ -5,6 +5,7 @@ import { ApiTags } from '@nestjs/swagger';
 import { ServerPingResponse } from './response-dto/server-ping-response.dto';
 import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
 import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
+import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
 
 @ApiTags('Server Info')
 @Controller('server-info')
@@ -25,4 +26,9 @@ export class ServerInfoController {
   async getServerVersion(): Promise<ServerVersionReponseDto> {
     return serverVersion;
   }
+
+  @Get('/stats')
+  async getStats(): Promise<ServerStatsResponseDto> {
+    return await this.serverInfoService.getStats();
+  }
 }

+ 3 - 0
server/apps/immich/src/api-v1/server-info/server-info.module.ts

@@ -1,8 +1,11 @@
 import { Module } from '@nestjs/common';
 import { ServerInfoService } from './server-info.service';
 import { ServerInfoController } from './server-info.controller';
+import { AssetEntity } from '@app/database/entities/asset.entity';
+import { TypeOrmModule } from '@nestjs/typeorm';
 
 @Module({
+  imports: [TypeOrmModule.forFeature([AssetEntity])],
   controllers: [ServerInfoController],
   providers: [ServerInfoService],
 })

+ 69 - 1
server/apps/immich/src/api-v1/server-info/server-info.service.ts

@@ -2,9 +2,21 @@ import { APP_UPLOAD_LOCATION } from '@app/common/constants';
 import { Injectable } from '@nestjs/common';
 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/database/entities/asset.entity';
+import { Repository } from 'typeorm';
+import { InjectRepository } from '@nestjs/typeorm';
+import path from 'path';
+import { readdirSync, statSync } from 'fs';
 
 @Injectable()
 export class ServerInfoService {
+  constructor(
+    @InjectRepository(AssetEntity)
+    private assetRepository: Repository<AssetEntity>,
+  ) {}
+
   async getServerInfo(): Promise<ServerInfoResponseDto> {
     const diskInfo = await diskusage.check(APP_UPLOAD_LOCATION);
 
@@ -18,7 +30,6 @@ export class ServerInfoService {
     serverInfo.diskSizeRaw = diskInfo.total;
     serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
     serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
-
     return serverInfo;
   }
 
@@ -48,4 +59,61 @@ export class ServerInfoService {
       return `${sizeInByte}B`;
     }
   }
+
+  async getStats(): Promise<ServerStatsResponseDto> {
+    const res = await this.assetRepository
+      .createQueryBuilder('asset')
+      .select(`COUNT(asset.id)`, 'count')
+      .addSelect(`asset.type`, 'type')
+      .addSelect(`asset.userId`, 'userId')
+      .groupBy('asset.type, asset.userId')
+      .addGroupBy('asset.type')
+      .getRawMany();
+
+    const serverStats = new ServerStatsResponseDto();
+    const tmpMap = new Map<string, UsageByUserDto>();
+    const getUsageByUser = (id: string) => tmpMap.get(id) || new UsageByUserDto(id);
+    res.map((item) => {
+      const usage: UsageByUserDto = getUsageByUser(item.userId);
+      if (item.type === 'IMAGE') {
+        usage.photos = parseInt(item.count);
+        serverStats.photos += usage.photos;
+      } else if (item.type === 'VIDEO') {
+        usage.videos = parseInt(item.count);
+        serverStats.videos += usage.videos;
+      }
+      tmpMap.set(item.userId, usage);
+    });
+
+    for (const userId of tmpMap.keys()) {
+      const usage = getUsageByUser(userId);
+      const userDiskUsage = await ServerInfoService.getDirectoryStats(path.join(APP_UPLOAD_LOCATION, userId));
+      usage.usageRaw = userDiskUsage.size;
+      usage.objects = userDiskUsage.fileCount;
+      usage.usage = ServerInfoService.getHumanReadableString(usage.usageRaw);
+      serverStats.usageRaw += usage.usageRaw;
+      serverStats.objects += usage.objects;
+    }
+    serverStats.usage = ServerInfoService.getHumanReadableString(serverStats.usageRaw);
+    serverStats.usageByUser = Array.from(tmpMap.values());
+    return serverStats;
+  }
+
+  private static async getDirectoryStats(dirPath: string) {
+    let size = 0;
+    let fileCount = 0;
+    for (const filename of readdirSync(dirPath)) {
+      const absFilename = path.join(dirPath, filename);
+      const fileStat = statSync(absFilename);
+      if (fileStat.isFile()) {
+        size += fileStat.size;
+        fileCount += 1;
+      } else if (fileStat.isDirectory()) {
+        const subDirStat = await ServerInfoService.getDirectoryStats(absFilename);
+        size += subDirStat.size;
+        fileCount += subDirStat.fileCount;
+      }
+    }
+    return { size, fileCount };
+  }
 }

+ 2 - 2
server/apps/immich/src/api-v1/user/dto/create-user.dto.spec.ts

@@ -3,13 +3,13 @@ import { validate } from 'class-validator';
 import { CreateUserDto } from './create-user.dto';
 
 describe('create user DTO', () => {
-  it('validates the email', async() => {
+  it('validates the email', async () => {
     const params: Partial<CreateUserDto> = {
       email: undefined,
       password: 'password',
       firstName: 'first name',
       lastName: 'last name',
-    }
+    };
     let dto: CreateUserDto = plainToInstance(CreateUserDto, params);
     let errors = await validate(dto);
     expect(errors).toHaveLength(1);

+ 2 - 2
server/apps/immich/src/api-v1/user/user-repository.ts

@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
 import { Not, Repository } from 'typeorm';
 import { CreateUserDto } from './dto/create-user.dto';
 import * as bcrypt from 'bcrypt';
-import { UpdateUserDto } from './dto/update-user.dto'
+import { UpdateUserDto } from './dto/update-user.dto';
 
 export interface IUserRepository {
   get(userId: string): Promise<UserEntity | null>;
@@ -92,4 +92,4 @@ export class UserRepository implements IUserRepository {
     user.profileImagePath = fileInfo.path;
     return this.userRepository.save(user);
   }
-}
+}

+ 2 - 2
server/apps/immich/src/api-v1/user/user.module.ts

@@ -17,8 +17,8 @@ import { UserRepository, USER_REPOSITORY } from './user-repository';
     ImmichJwtService,
     {
       provide: USER_REPOSITORY,
-      useClass: UserRepository
-    }
+      useClass: UserRepository,
+    },
   ],
 })
 export class UserModule {}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
server/immich-openapi-specs.json


+ 9 - 9
server/libs/common/src/config/app.config.ts

@@ -1,20 +1,20 @@
 import { Logger } from '@nestjs/common';
 import { ConfigModuleOptions } from '@nestjs/config';
 import Joi from 'joi';
-import { createSecretKey, generateKeySync } from 'node:crypto'
+import { createSecretKey, generateKeySync } from 'node:crypto';
 
-const jwtSecretValidator: Joi.CustomValidator<string> = (value, ) => {
-  const key = createSecretKey(value, "base64")
-  const keySizeBits = (key.symmetricKeySize ?? 0) * 8
+const jwtSecretValidator: Joi.CustomValidator<string> = (value) => {
+  const key = createSecretKey(value, 'base64');
+  const keySizeBits = (key.symmetricKeySize ?? 0) * 8;
 
   if (keySizeBits < 128) {
-    const newKey = generateKeySync('hmac', { length: 256 }).export().toString('base64')
-    Logger.warn("The current JWT_SECRET key is insecure. It should be at least 128 bits long!")
-    Logger.warn(`Here is a new, securely generated key that you can use instead: ${newKey}`)
+    const newKey = generateKeySync('hmac', { length: 256 }).export().toString('base64');
+    Logger.warn('The current JWT_SECRET key is insecure. It should be at least 128 bits long!');
+    Logger.warn(`Here is a new, securely generated key that you can use instead: ${newKey}`);
   }
 
   return value;
-}
+};
 
 export const immichAppConfig: ConfigModuleOptions = {
   envFilePath: '.env',
@@ -26,7 +26,7 @@ export const immichAppConfig: ConfigModuleOptions = {
     DB_DATABASE_NAME: Joi.string().required(),
     JWT_SECRET: Joi.string().required().custom(jwtSecretValidator),
     DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
-    REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
+    REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
     LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
   }),
 };

+ 1 - 1
server/libs/database/src/entities/album.entity.ts

@@ -16,7 +16,7 @@ export class AlbumEntity {
   @CreateDateColumn({ type: 'timestamptz' })
   createdAt!: string;
 
-  @Column({ comment: 'Asset ID to be used as thumbnail', type: 'varchar', nullable: true})
+  @Column({ comment: 'Asset ID to be used as thumbnail', type: 'varchar', nullable: true })
   albumThumbnailAssetId!: string | null;
 
   @OneToMany(() => UserAlbumEntity, (userAlbums) => userAlbums.albumInfo)

+ 1 - 1
server/libs/database/src/entities/asset-album.entity.ts

@@ -1,4 +1,4 @@
-import {Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique} from 'typeorm';
+import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
 import { AlbumEntity } from './album.entity';
 import { AssetEntity } from './asset.entity';
 

+ 13 - 11
server/libs/database/src/migrations/1656888591977-RenameAssetAlbumIdSequence.ts

@@ -1,15 +1,17 @@
-import { MigrationInterface, QueryRunner } from "typeorm"
+import { MigrationInterface, QueryRunner } from 'typeorm';
 
 export class RenameAssetAlbumIdSequence1656888591977 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`alter sequence asset_shared_album_id_seq rename to asset_album_id_seq;`);
+    await queryRunner.query(
+      `alter table asset_album alter column id set default nextval('asset_album_id_seq'::regclass);`,
+    );
+  }
 
-    public async up(queryRunner: QueryRunner): Promise<void> {
-        await queryRunner.query(`alter sequence asset_shared_album_id_seq rename to asset_album_id_seq;`);
-        await queryRunner.query(`alter table asset_album alter column id set default nextval('asset_album_id_seq'::regclass);`);
-    }
-
-    public async down(queryRunner: QueryRunner): Promise<void> {
-        await queryRunner.query(`alter sequence asset_album_id_seq rename to asset_shared_album_id_seq;`);
-        await queryRunner.query(`alter table asset_album alter column id set default nextval('asset_shared_album_id_seq'::regclass);`);
-    }
-
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`alter sequence asset_album_id_seq rename to asset_shared_album_id_seq;`);
+    await queryRunner.query(
+      `alter table asset_album alter column id set default nextval('asset_shared_album_id_seq'::regclass);`,
+    );
+  }
 }

+ 7 - 9
server/libs/database/src/migrations/1656888918620-DropExifTextSearchableColumn.ts

@@ -1,13 +1,12 @@
-import { MigrationInterface, QueryRunner } from "typeorm";
+import { MigrationInterface, QueryRunner } from 'typeorm';
 
 export class DropExifTextSearchableColumns1656888918620 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exif_text_searchable_column"`);
+  }
 
-    public async up(queryRunner: QueryRunner): Promise<void> {
-        await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exif_text_searchable_column"`);
-    }
-
-    public async down(queryRunner: QueryRunner): Promise<void> {
-        await queryRunner.query(`
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
       ALTER TABLE exif 
       DROP COLUMN IF EXISTS exif_text_searchable_column;
 
@@ -29,6 +28,5 @@ export class DropExifTextSearchableColumns1656888918620 implements MigrationInte
         ON exif 
         USING GIN (exif_text_searchable_column);
     `);
-    }
-
+  }
 }

+ 62 - 36
server/libs/database/src/migrations/1656889061566-MatchMigrationsWithTypeORMEntities.ts

@@ -1,9 +1,8 @@
-import { MigrationInterface, QueryRunner } from "typeorm";
+import { MigrationInterface, QueryRunner } from 'typeorm';
 
 export class MatchMigrationsWithTypeORMEntities1656889061566 implements MigrationInterface {
-
-    public async up(queryRunner: QueryRunner): Promise<void> {
-        await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english',
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english',
                          COALESCE(make, '') || ' ' ||
                          COALESCE(model, '') || ' ' ||
                          COALESCE(orientation, '') || ' ' ||
@@ -11,36 +10,63 @@ export class MatchMigrationsWithTypeORMEntities1656889061566 implements Migratio
                          COALESCE("city", '') || ' ' ||
                          COALESCE("state", '') || ' ' ||
                          COALESCE("country", ''))) STORED`);
-        await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`);
-        await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","exifTextSearchableColumn","postgres","public","exif"]);
-        await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, ["postgres","public","exif","GENERATED_COLUMN","exifTextSearchableColumn","TO_TSVECTOR('english',\n                         COALESCE(make, '') || ' ' ||\n                         COALESCE(model, '') || ' ' ||\n                         COALESCE(orientation, '') || ' ' ||\n                         COALESCE(\"lensModel\", '') || ' ' ||\n                         COALESCE(\"city\", '') || ' ' ||\n                         COALESCE(\"state\", '') || ' ' ||\n                         COALESCE(\"country\", ''))"]);
-        await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" SET NOT NULL`);
-        await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" SET NOT NULL`);
-        await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" SET NOT NULL`);
-        await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" SET NOT NULL`);
-        await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" SET NOT NULL`);
-        await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d"`);
-        await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a"`);
-        await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288"`);
-        await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_unique_asset_in_album" UNIQUE ("albumId", "assetId")`);
-        await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_256a30a03a4a0aff0394051397d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
-        await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_7ae4e03729895bf87e056d7b598" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
-    }
-
-    public async down(queryRunner: QueryRunner): Promise<void> {
-        await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" DROP NOT NULL`);
-        await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" DROP NOT NULL`);
-        await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" DROP NOT NULL`);
-        await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" DROP NOT NULL`);
-        await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" DROP NOT NULL`);
-        await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","exifTextSearchableColumn","immich","public","exif"]);
-        await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`);
-        await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_7ae4e03729895bf87e056d7b598"`);
-        await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_256a30a03a4a0aff0394051397d"`);
-        await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_unique_asset_in_album"`);
-        await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288" UNIQUE ("albumId", "assetId")`);
-        await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
-        await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
-    }
+    await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`);
+    await queryRunner.query(
+      `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`,
+      ['GENERATED_COLUMN', 'exifTextSearchableColumn', 'postgres', 'public', 'exif'],
+    );
+    await queryRunner.query(
+      `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`,
+      [
+        'postgres',
+        'public',
+        'exif',
+        'GENERATED_COLUMN',
+        'exifTextSearchableColumn',
+        "TO_TSVECTOR('english',\n                         COALESCE(make, '') || ' ' ||\n                         COALESCE(model, '') || ' ' ||\n                         COALESCE(orientation, '') || ' ' ||\n                         COALESCE(\"lensModel\", '') || ' ' ||\n                         COALESCE(\"city\", '') || ' ' ||\n                         COALESCE(\"state\", '') || ' ' ||\n                         COALESCE(\"country\", ''))",
+      ],
+    );
+    await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" SET NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" SET NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" SET NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" SET NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" SET NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d"`);
+    await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a"`);
+    await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288"`);
+    await queryRunner.query(
+      `ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_unique_asset_in_album" UNIQUE ("albumId", "assetId")`,
+    );
+    await queryRunner.query(
+      `ALTER TABLE "asset_album" ADD CONSTRAINT "FK_256a30a03a4a0aff0394051397d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
+    );
+    await queryRunner.query(
+      `ALTER TABLE "asset_album" ADD CONSTRAINT "FK_7ae4e03729895bf87e056d7b598" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
+    );
+  }
 
-}
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" DROP NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" DROP NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" DROP NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" DROP NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" DROP NOT NULL`);
+    await queryRunner.query(
+      `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`,
+      ['GENERATED_COLUMN', 'exifTextSearchableColumn', 'immich', 'public', 'exif'],
+    );
+    await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`);
+    await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_7ae4e03729895bf87e056d7b598"`);
+    await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_256a30a03a4a0aff0394051397d"`);
+    await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_unique_asset_in_album"`);
+    await queryRunner.query(
+      `ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288" UNIQUE ("albumId", "assetId")`,
+    );
+    await queryRunner.query(
+      `ALTER TABLE "asset_album" ADD CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
+    );
+    await queryRunner.query(
+      `ALTER TABLE "asset_album" ADD CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
+    );
+  }
+}

+ 4 - 3
server/libs/database/src/migrations/1661881837496-AddAssetChecksum.ts

@@ -1,16 +1,17 @@
 import { MigrationInterface, QueryRunner } from 'typeorm';
 
 export class AddAssetChecksum1661881837496 implements MigrationInterface {
-  name = 'AddAssetChecksum1661881837496'
+  name = 'AddAssetChecksum1661881837496';
 
   public async up(queryRunner: QueryRunner): Promise<void> {
     await queryRunner.query(`ALTER TABLE "assets" ADD "checksum" bytea`);
-    await queryRunner.query(`CREATE INDEX "IDX_64c507300988dd1764f9a6530c" ON "assets" ("checksum") WHERE 'checksum' IS NOT NULL`);
+    await queryRunner.query(
+      `CREATE INDEX "IDX_64c507300988dd1764f9a6530c" ON "assets" ("checksum") WHERE 'checksum' IS NOT NULL`,
+    );
   }
 
   public async down(queryRunner: QueryRunner): Promise<void> {
     await queryRunner.query(`DROP INDEX "public"."IDX_64c507300988dd1764f9a6530c"`);
     await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "checksum"`);
   }
-
 }

+ 4 - 3
server/libs/database/src/migrations/1661971370662-UpdateAssetTableWithNewUniqueConstraint.ts

@@ -1,7 +1,7 @@
 import { MigrationInterface, QueryRunner } from 'typeorm';
 
 export class UpdateAssetTableWithNewUniqueConstraint1661971370662 implements MigrationInterface {
-  name = 'UpdateAssetTableWithNewUniqueConstraint1661971370662'
+  name = 'UpdateAssetTableWithNewUniqueConstraint1661971370662';
 
   public async up(queryRunner: QueryRunner): Promise<void> {
     await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e"`);
@@ -10,7 +10,8 @@ export class UpdateAssetTableWithNewUniqueConstraint1661971370662 implements Mig
 
   public async down(queryRunner: QueryRunner): Promise<void> {
     await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_userid_checksum"`);
-    await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e" UNIQUE ("deviceAssetId", "userId", "deviceId")`);
+    await queryRunner.query(
+      `ALTER TABLE "assets" ADD CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e" UNIQUE ("deviceAssetId", "userId", "deviceId")`,
+    );
   }
-
 }

+ 142 - 0
web/src/api/open-api/api.ts

@@ -1157,6 +1157,49 @@ export interface ServerPingResponse {
      */
     'res': string;
 }
+/**
+ * 
+ * @export
+ * @interface ServerStatsResponseDto
+ */
+export interface ServerStatsResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof ServerStatsResponseDto
+     */
+    'photos': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof ServerStatsResponseDto
+     */
+    'videos': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof ServerStatsResponseDto
+     */
+    'objects': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof ServerStatsResponseDto
+     */
+    'usageRaw': number;
+    /**
+     * 
+     * @type {string}
+     * @memberof ServerStatsResponseDto
+     */
+    'usage': string;
+    /**
+     * 
+     * @type {Array<UsageByUserDto>}
+     * @memberof ServerStatsResponseDto
+     */
+    'usageByUser': Array<UsageByUserDto>;
+}
 /**
  * 
  * @export
@@ -1365,6 +1408,49 @@ export interface UpdateUserDto {
      */
     'profileImagePath'?: string;
 }
+/**
+ * 
+ * @export
+ * @interface UsageByUserDto
+ */
+export interface UsageByUserDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof UsageByUserDto
+     */
+    'userId': string;
+    /**
+     * 
+     * @type {number}
+     * @memberof UsageByUserDto
+     */
+    'objects': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof UsageByUserDto
+     */
+    'videos': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof UsageByUserDto
+     */
+    'photos': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof UsageByUserDto
+     */
+    'usageRaw': number;
+    /**
+     * 
+     * @type {string}
+     * @memberof UsageByUserDto
+     */
+    'usage': string;
+}
 /**
  * 
  * @export
@@ -4132,6 +4218,35 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur
 
 
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getStats: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/server-info/stats`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+
+    
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -4198,6 +4313,15 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getServerVersion(options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getStats(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerStatsResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getStats(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -4233,6 +4357,14 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
         getServerVersion(options?: any): AxiosPromise<ServerVersionReponseDto> {
             return localVarFp.getServerVersion(options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getStats(options?: any): AxiosPromise<ServerStatsResponseDto> {
+            return localVarFp.getStats(options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -4271,6 +4403,16 @@ export class ServerInfoApi extends BaseAPI {
         return ServerInfoApiFp(this.configuration).getServerVersion(options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof ServerInfoApi
+     */
+    public getStats(options?: AxiosRequestConfig) {
+        return ServerInfoApiFp(this.configuration).getStats(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {*} [options] Override http request option.

+ 52 - 0
web/src/lib/components/admin-page/server-stats.svelte

@@ -0,0 +1,52 @@
+<script lang="ts">
+	import { ServerStatsResponseDto, UserResponseDto } from '@api';
+	export let stats: ServerStatsResponseDto;
+	export let allUsers: Array<UserResponseDto>;
+
+	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;
+	};
+</script>
+
+<div class="flex flex-col gap-6">
+	<div class="border p-6 rounded-2xl bg-white text-center">
+		<h1 class="font-medium text-immich-primary">Server Usage</h1>
+		<div class="flex flex-row gap-6 mt-4 font-medium">
+			<p class="grow">Photos: {stats.photos}</p>
+			<p class="grow">Videos: {stats.videos}</p>
+			<p class="grow">Objects: {stats.objects}</p>
+			<p class="grow">Size: {stats.usage}</p>
+		</div>
+	</div>
+
+	<div class="border p-6 rounded-2xl bg-white">
+		<h1 class="font-medium text-immich-primary">Usage by User</h1>
+		<table class="text-left w-full mt-4">
+			<!-- table header -->
+			<thead class="border rounded-md mb-2 bg-gray-50 flex text-immich-primary w-full h-12">
+				<tr class="flex w-full place-items-center">
+					<th class="text-center w-1/5 font-medium text-sm">User</th>
+					<th class="text-center w-1/5 font-medium text-sm">Photos</th>
+					<th class="text-center w-1/5 font-medium text-sm">Videos</th>
+					<th class="text-center w-1/5 font-medium text-sm">Objects</th>
+					<th class="text-center w-1/5 font-medium text-sm">Size</th>
+				</tr>
+			</thead>
+			<tbody class="overflow-y-auto rounded-md w-full max-h-[320px] block border">
+				{#each stats.usageByUser as user}
+					<tr class="text-center flex place-items-center w-full h-[40px]">
+						<td class="text-sm px-2 w-1/5 text-ellipsis">{getFullName(user.userId)}</td>
+						<td class="text-sm px-2 w-1/5 text-ellipsis">{user.photos}</td>
+						<td class="text-sm px-2 w-1/5 text-ellipsis">{user.videos}</td>
+						<td class="text-sm px-2 w-1/5 text-ellipsis">{user.objects}</td>
+						<td class="text-sm px-2 w-1/5 text-ellipsis">{user.usage}</td>
+					</tr>
+				{/each}
+			</tbody>
+		</table>
+	</div>
+</div>

+ 2 - 1
web/src/lib/models/admin-sidebar-selection.ts

@@ -1,7 +1,8 @@
 export enum AdminSideBarSelection {
 	USER_MANAGEMENT = 'User management',
 	JOBS = 'Jobs',
-	SETTINGS = 'Settings'
+	SETTINGS = 'Settings',
+	STATS = 'Server Stats'
 }
 
 export enum AppSideBarSelection {

+ 3 - 1
web/src/routes/admin/+page.server.ts

@@ -12,8 +12,10 @@ export const load: PageServerLoad = async ({ parent }) => {
 	}
 
 	const { data: allUsers } = await serverApi.userApi.getAllUsers(false);
+	const { data: stats } = await serverApi.serverInfoApi.getStats();
 	return {
 		user: user,
-		allUsers: allUsers
+		allUsers: allUsers,
+		stats: stats
 	};
 };

+ 12 - 0
web/src/routes/admin/+page.svelte

@@ -5,6 +5,7 @@
 	import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
 	import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
 	import Cog from 'svelte-material-icons/Cog.svelte';
+	import Server from 'svelte-material-icons/Server.svelte';
 	import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
 	import UserManagement from '$lib/components/admin-page/user-management.svelte';
 	import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
@@ -14,6 +15,7 @@
 	import type { PageData } from './$types';
 	import { api, UserResponseDto } from '@api';
 	import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
+	import ServerStats from '$lib/components/admin-page/server-stats.svelte';
 
 	let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
 
@@ -121,6 +123,13 @@
 			isSelected={selectedAction === AdminSideBarSelection.JOBS}
 			on:selected={onButtonClicked}
 		/>
+		<SideBarButton
+			title="Server Stats"
+			logo={Server}
+			actionType={AdminSideBarSelection.STATS}
+			isSelected={selectedAction === AdminSideBarSelection.STATS}
+			on:selected={onButtonClicked}
+		/>
 
 		<div class="mb-6 mt-auto">
 			<StatusBox />
@@ -144,6 +153,9 @@
 				{#if selectedAction === AdminSideBarSelection.JOBS}
 					<JobsPanel />
 				{/if}
+				{#if selectedAction === AdminSideBarSelection.STATS}
+					<ServerStats stats={data.stats} allUsers={data.allUsers} />
+				{/if}
 			</section>
 		</section>
 	</section>

Некоторые файлы не были показаны из-за большого количества измененных файлов