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