mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat(web) add asset count stats on admin page (#843)
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ import { UserRepository, USER_REPOSITORY } from './user-repository';
|
||||
ImmichJwtService,
|
||||
{
|
||||
provide: USER_REPOSITORY,
|
||||
useClass: UserRepository
|
||||
}
|
||||
useClass: UserRepository,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
Reference in New Issue
Block a user