1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-26 10:50:29 +02:00

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 475 additions and 90 deletions

View File

@ -48,6 +48,7 @@ doc/SearchAssetDto.md
doc/ServerInfoApi.md doc/ServerInfoApi.md
doc/ServerInfoResponseDto.md doc/ServerInfoResponseDto.md
doc/ServerPingResponse.md doc/ServerPingResponse.md
doc/ServerStatsResponseDto.md
doc/ServerVersionReponseDto.md doc/ServerVersionReponseDto.md
doc/SignUpDto.md doc/SignUpDto.md
doc/SmartInfoResponseDto.md doc/SmartInfoResponseDto.md
@ -56,6 +57,7 @@ doc/TimeGroupEnum.md
doc/UpdateAlbumDto.md doc/UpdateAlbumDto.md
doc/UpdateDeviceInfoDto.md doc/UpdateDeviceInfoDto.md
doc/UpdateUserDto.md doc/UpdateUserDto.md
doc/UsageByUserDto.md
doc/UserApi.md doc/UserApi.md
doc/UserCountResponseDto.md doc/UserCountResponseDto.md
doc/UserResponseDto.md doc/UserResponseDto.md
@ -117,6 +119,7 @@ lib/model/remove_assets_dto.dart
lib/model/search_asset_dto.dart lib/model/search_asset_dto.dart
lib/model/server_info_response_dto.dart lib/model/server_info_response_dto.dart
lib/model/server_ping_response.dart lib/model/server_ping_response.dart
lib/model/server_stats_response_dto.dart
lib/model/server_version_reponse_dto.dart lib/model/server_version_reponse_dto.dart
lib/model/sign_up_dto.dart lib/model/sign_up_dto.dart
lib/model/smart_info_response_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_album_dto.dart
lib/model/update_device_info_dto.dart lib/model/update_device_info_dto.dart
lib/model/update_user_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_count_response_dto.dart
lib/model/user_response_dto.dart lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart lib/model/validate_access_token_response_dto.dart

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -54,7 +54,7 @@ export class AuthService {
const validatedUser = await this.validateUser(loginCredential); const validatedUser = await this.validateUser(loginCredential);
if (!validatedUser) { 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'); throw new BadRequestException('Incorrect email or password');
} }

View File

@ -1,10 +1,10 @@
import { ApiResponseProperty } from '@nestjs/swagger'; import { ApiResponseProperty } from '@nestjs/swagger';
export class LogoutResponseDto { export class LogoutResponseDto {
constructor (successful: boolean) { constructor(successful: boolean) {
this.successful = successful; this.successful = successful;
} }
@ApiResponseProperty() @ApiResponseProperty()
successful!: boolean; 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 { ServerPingResponse } from './response-dto/server-ping-response.dto';
import { ServerVersionReponseDto } from './response-dto/server-version-response.dto'; import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto'; import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
@ApiTags('Server Info') @ApiTags('Server Info')
@Controller('server-info') @Controller('server-info')
@ -25,4 +26,9 @@ export class ServerInfoController {
async getServerVersion(): Promise<ServerVersionReponseDto> { async getServerVersion(): Promise<ServerVersionReponseDto> {
return serverVersion; 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 { Module } from '@nestjs/common';
import { ServerInfoService } from './server-info.service'; import { ServerInfoService } from './server-info.service';
import { ServerInfoController } from './server-info.controller'; import { ServerInfoController } from './server-info.controller';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([AssetEntity])],
controllers: [ServerInfoController], controllers: [ServerInfoController],
providers: [ServerInfoService], providers: [ServerInfoService],
}) })

View File

@ -2,9 +2,21 @@ import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto'; import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
import diskusage from 'diskusage'; 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() @Injectable()
export class ServerInfoService { export class ServerInfoService {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {}
async getServerInfo(): Promise<ServerInfoResponseDto> { async getServerInfo(): Promise<ServerInfoResponseDto> {
const diskInfo = await diskusage.check(APP_UPLOAD_LOCATION); const diskInfo = await diskusage.check(APP_UPLOAD_LOCATION);
@ -18,7 +30,6 @@ export class ServerInfoService {
serverInfo.diskSizeRaw = diskInfo.total; serverInfo.diskSizeRaw = diskInfo.total;
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free; serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
serverInfo.diskUsagePercentage = parseFloat(usagePercentage); serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
return serverInfo; return serverInfo;
} }
@ -48,4 +59,61 @@ export class ServerInfoService {
return `${sizeInByte}B`; 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'; import { CreateUserDto } from './create-user.dto';
describe('create user DTO', () => { describe('create user DTO', () => {
it('validates the email', async() => { it('validates the email', async () => {
const params: Partial<CreateUserDto> = { const params: Partial<CreateUserDto> = {
email: undefined, email: undefined,
password: 'password', password: 'password',
firstName: 'first name', firstName: 'first name',
lastName: 'last name', lastName: 'last name',
} };
let dto: CreateUserDto = plainToInstance(CreateUserDto, params); let dto: CreateUserDto = plainToInstance(CreateUserDto, params);
let errors = await validate(dto); let errors = await validate(dto);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);

View File

@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm'; import { Not, Repository } from 'typeorm';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { UpdateUserDto } from './dto/update-user.dto' import { UpdateUserDto } from './dto/update-user.dto';
export interface IUserRepository { export interface IUserRepository {
get(userId: string): Promise<UserEntity | null>; get(userId: string): Promise<UserEntity | null>;

View File

@ -17,8 +17,8 @@ import { UserRepository, USER_REPOSITORY } from './user-repository';
ImmichJwtService, ImmichJwtService,
{ {
provide: USER_REPOSITORY, provide: USER_REPOSITORY,
useClass: UserRepository useClass: UserRepository,
} },
], ],
}) })
export class UserModule {} 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 { Logger } from '@nestjs/common';
import { ConfigModuleOptions } from '@nestjs/config'; import { ConfigModuleOptions } from '@nestjs/config';
import Joi from 'joi'; import Joi from 'joi';
import { createSecretKey, generateKeySync } from 'node:crypto' import { createSecretKey, generateKeySync } from 'node:crypto';
const jwtSecretValidator: Joi.CustomValidator<string> = (value, ) => { const jwtSecretValidator: Joi.CustomValidator<string> = (value) => {
const key = createSecretKey(value, "base64") const key = createSecretKey(value, 'base64');
const keySizeBits = (key.symmetricKeySize ?? 0) * 8 const keySizeBits = (key.symmetricKeySize ?? 0) * 8;
if (keySizeBits < 128) { if (keySizeBits < 128) {
const newKey = generateKeySync('hmac', { length: 256 }).export().toString('base64') 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('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}`) Logger.warn(`Here is a new, securely generated key that you can use instead: ${newKey}`);
} }
return value; return value;
} };
export const immichAppConfig: ConfigModuleOptions = { export const immichAppConfig: ConfigModuleOptions = {
envFilePath: '.env', envFilePath: '.env',
@ -26,7 +26,7 @@ export const immichAppConfig: ConfigModuleOptions = {
DB_DATABASE_NAME: Joi.string().required(), DB_DATABASE_NAME: Joi.string().required(),
JWT_SECRET: Joi.string().required().custom(jwtSecretValidator), JWT_SECRET: Joi.string().required().custom(jwtSecretValidator),
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false), 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'), LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
}), }),
}; };

View File

@ -16,7 +16,7 @@ export class AlbumEntity {
@CreateDateColumn({ type: 'timestamptz' }) @CreateDateColumn({ type: 'timestamptz' })
createdAt!: string; 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; albumThumbnailAssetId!: string | null;
@OneToMany(() => UserAlbumEntity, (userAlbums) => userAlbums.albumInfo) @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 { AlbumEntity } from './album.entity';
import { AssetEntity } from './asset.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 { export class RenameAssetAlbumIdSequence1656888591977 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> { 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 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);`); 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> { 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 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);`); await queryRunner.query(
`alter table asset_album alter column id set default nextval('asset_shared_album_id_seq'::regclass);`,
);
} }
} }

View File

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

View File

@ -1,7 +1,6 @@
import { MigrationInterface, QueryRunner } from "typeorm"; import { MigrationInterface, QueryRunner } from 'typeorm';
export class MatchMigrationsWithTypeORMEntities1656889061566 implements MigrationInterface { export class MatchMigrationsWithTypeORMEntities1656889061566 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english', await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english',
COALESCE(make, '') || ' ' || COALESCE(make, '') || ' ' ||
@ -12,8 +11,21 @@ export class MatchMigrationsWithTypeORMEntities1656889061566 implements Migratio
COALESCE("state", '') || ' ' || COALESCE("state", '') || ' ' ||
COALESCE("country", ''))) STORED`); COALESCE("country", ''))) STORED`);
await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`); 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(
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\", ''))"]); `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 "firstName" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" 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 "isAdmin" SET NOT NULL`);
@ -22,9 +34,15 @@ export class MatchMigrationsWithTypeORMEntities1656889061566 implements Migratio
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d"`); 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 "FK_64f2e7d68d1d1d8417acc844a4a"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288"`); 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(
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_256a30a03a4a0aff0394051397d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); `ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_unique_asset_in_album" UNIQUE ("albumId", "assetId")`,
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 "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> { public async down(queryRunner: QueryRunner): Promise<void> {
@ -33,14 +51,22 @@ export class MatchMigrationsWithTypeORMEntities1656889061566 implements Migratio
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" 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 "lastName" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" 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(
`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 "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_7ae4e03729895bf87e056d7b598"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_256a30a03a4a0aff0394051397d"`); 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" DROP CONSTRAINT "UQ_unique_asset_in_album"`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288" UNIQUE ("albumId", "assetId")`); await queryRunner.query(
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); `ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288" UNIQUE ("albumId", "assetId")`,
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); );
await queryRunner.query(
`ALTER TABLE "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'; import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAssetChecksum1661881837496 implements MigrationInterface { export class AddAssetChecksum1661881837496 implements MigrationInterface {
name = 'AddAssetChecksum1661881837496' name = 'AddAssetChecksum1661881837496';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "checksum" bytea`); 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> { public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."IDX_64c507300988dd1764f9a6530c"`); await queryRunner.query(`DROP INDEX "public"."IDX_64c507300988dd1764f9a6530c"`);
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "checksum"`); await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "checksum"`);
} }
} }

View File

@ -1,7 +1,7 @@
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateAssetTableWithNewUniqueConstraint1661971370662 implements MigrationInterface { export class UpdateAssetTableWithNewUniqueConstraint1661971370662 implements MigrationInterface {
name = 'UpdateAssetTableWithNewUniqueConstraint1661971370662' name = 'UpdateAssetTableWithNewUniqueConstraint1661971370662';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e"`); 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> { public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_userid_checksum"`); 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; '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 * @export
@ -1365,6 +1408,49 @@ export interface UpdateUserDto {
*/ */
'profileImagePath'?: string; '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 * @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); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -4198,6 +4313,15 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getServerVersion(options); const localVarAxiosArgs = await localVarAxiosParamCreator.getServerVersion(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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. * @param {*} [options] Override http request option.
@ -4233,6 +4357,14 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
getServerVersion(options?: any): AxiosPromise<ServerVersionReponseDto> { getServerVersion(options?: any): AxiosPromise<ServerVersionReponseDto> {
return localVarFp.getServerVersion(options).then((request) => request(axios, basePath)); 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. * @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)); 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. * @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 { export enum AdminSideBarSelection {
USER_MANAGEMENT = 'User management', USER_MANAGEMENT = 'User management',
JOBS = 'Jobs', JOBS = 'Jobs',
SETTINGS = 'Settings' SETTINGS = 'Settings',
STATS = 'Server Stats'
} }
export enum AppSideBarSelection { 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: allUsers } = await serverApi.userApi.getAllUsers(false);
const { data: stats } = await serverApi.serverInfoApi.getStats();
return { return {
user: user, 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 SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte'; import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
import Cog from 'svelte-material-icons/Cog.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 NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import UserManagement from '$lib/components/admin-page/user-management.svelte'; import UserManagement from '$lib/components/admin-page/user-management.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
@ -14,6 +15,7 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { api, UserResponseDto } from '@api'; import { api, UserResponseDto } from '@api';
import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte'; 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; let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
@ -121,6 +123,13 @@
isSelected={selectedAction === AdminSideBarSelection.JOBS} isSelected={selectedAction === AdminSideBarSelection.JOBS}
on:selected={onButtonClicked} 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"> <div class="mb-6 mt-auto">
<StatusBox /> <StatusBox />
@ -144,6 +153,9 @@
{#if selectedAction === AdminSideBarSelection.JOBS} {#if selectedAction === AdminSideBarSelection.JOBS}
<JobsPanel /> <JobsPanel />
{/if} {/if}
{#if selectedAction === AdminSideBarSelection.STATS}
<ServerStats stats={data.stats} allUsers={data.allUsers} />
{/if}
</section> </section>
</section> </section>
</section> </section>