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

This commit is contained in:
Zeeshan Khan 2022-10-23 16:54:54 -05:00 committed by GitHub
parent 2c189d5c78
commit a6eea4d096
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1156 additions and 90 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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';

View file

@ -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

View file

@ -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':

View file

@ -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',
};
}

View file

@ -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',
};
}

View file

@ -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',
};
}

View file

@ -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
});
});
}

View file

@ -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
});
});
}

View file

@ -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
});
});
}

View file

@ -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
*/

View file

@ -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');
}

View file

@ -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;
}

View file

@ -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[];
}

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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],
})

View file

@ -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 };
}
}

View file

@ -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);

View file

@ -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);
}
}
}

View file

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

File diff suppressed because one or more lines are too long

View file

@ -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'),
}),
};

View file

@ -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)

View file

@ -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';

View file

@ -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);`,
);
}
}

View file

@ -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);
`);
}
}
}

View file

@ -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`);
}
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`);
}
}
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`,
);
}
}

View file

@ -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"`);
}
}

View file

@ -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")`,
);
}
}

View file

@ -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.

View file

@ -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>

View file

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

View file

@ -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
};
};

View file

@ -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>