From 368142e79b32d01442a9e255fd67414c1a44de05 Mon Sep 17 00:00:00 2001
From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Date: Sun, 26 Feb 2023 20:57:34 +0100
Subject: [PATCH] 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 7fc2987a77ae8bf3a7381ed3156a7a0c16f27564.
---
 mobile/openapi/doc/ServerStatsResponseDto.md  |  8 +-
 mobile/openapi/doc/UsageByUserDto.md          |  7 +-
 .../lib/model/server_stats_response_dto.dart  | 28 ++-----
 .../openapi/lib/model/usage_by_user_dto.dart  | 42 ++++++----
 .../test/server_stats_response_dto_test.dart  | 16 +---
 .../openapi/test/usage_by_user_dto_test.dart  | 15 ++--
 .../src/api-v1/album/album.service.spec.ts    |  1 +
 .../response-dto/server-stats-response.dto.ts | 24 ++----
 .../usage-by-user-response.dto.ts             | 22 ++---
 .../api-v1/server-info/server-info.module.ts  |  4 +-
 .../api-v1/server-info/server-info.service.ts | 70 ++++++++--------
 .../immich/src/api-v1/tag/tag.service.spec.ts |  1 +
 server/immich-openapi-specs.json              | 38 ++++-----
 .../libs/domain/src/user/user.service.spec.ts |  3 +
 server/libs/domain/test/fixtures.ts           |  2 +
 .../libs/infra/src/db/entities/user.entity.ts |  4 +
 web/src/api/open-api/api.ts                   | 30 +++----
 .../server-stats/server-stats-panel.svelte    | 83 +++++--------------
 .../admin-page/server-stats/stats-card.svelte | 21 ++---
 .../asset-viewer/detail-panel.svelte          |  2 +-
 .../shared-components/status-box.svelte       |  4 +-
 .../upload-asset-preview.svelte               |  3 +-
 web/src/lib/utils/byte-units.ts               |  3 +-
 .../admin/server-status/+page.server.ts       |  6 +-
 .../routes/admin/server-status/+page.svelte   | 22 ++++-
 25 files changed, 199 insertions(+), 260 deletions(-)

diff --git a/mobile/openapi/doc/ServerStatsResponseDto.md b/mobile/openapi/doc/ServerStatsResponseDto.md
index 3356c366f2..96446e1c28 100644
--- a/mobile/openapi/doc/ServerStatsResponseDto.md
+++ b/mobile/openapi/doc/ServerStatsResponseDto.md
@@ -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)
diff --git a/mobile/openapi/doc/UsageByUserDto.md b/mobile/openapi/doc/UsageByUserDto.md
index ffdc2a88ec..1d1bef8858 100644
--- a/mobile/openapi/doc/UsageByUserDto.md
+++ b/mobile/openapi/doc/UsageByUserDto.md
@@ -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)
 
diff --git a/mobile/openapi/lib/model/server_stats_response_dto.dart b/mobile/openapi/lib/model/server_stats_response_dto.dart
index 7dbc1db42a..aeb40c0c7f 100644
--- a/mobile/openapi/lib/model/server_stats_response_dto.dart
+++ b/mobile/openapi/lib/model/server_stats_response_dto.dart
@@ -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',
   };
diff --git a/mobile/openapi/lib/model/usage_by_user_dto.dart b/mobile/openapi/lib/model/usage_by_user_dto.dart
index e18ac81de8..d2cbc4f41b 100644
--- a/mobile/openapi/lib/model/usage_by_user_dto.dart
+++ b/mobile/openapi/lib/model/usage_by_user_dto.dart
@@ -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',
   };
 }
diff --git a/mobile/openapi/test/server_stats_response_dto_test.dart b/mobile/openapi/test/server_stats_response_dto_test.dart
index 6e9fb783ad..f5d2c3dc9b 100644
--- a/mobile/openapi/test/server_stats_response_dto_test.dart
+++ b/mobile/openapi/test/server_stats_response_dto_test.dart
@@ -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
     });
diff --git a/mobile/openapi/test/usage_by_user_dto_test.dart b/mobile/openapi/test/usage_by_user_dto_test.dart
index 68efc27332..a4bec3f71d 100644
--- a/mobile/openapi/test/usage_by_user_dto_test.dart
+++ b/mobile/openapi/test/usage_by_user_dto_test.dart
@@ -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
     });
diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts
index acb74ab44c..66c7d51108 100644
--- a/server/apps/immich/src/api-v1/album/album.service.spec.ts
+++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts
@@ -37,6 +37,7 @@ describe('Album service', () => {
     shouldChangePassword: false,
     oauthId: '',
     tags: [],
+    assets: [],
   });
   const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
   const sharedAlbumOwnerId = '2222';
diff --git a/server/apps/immich/src/api-v1/server-info/response-dto/server-stats-response.dto.ts b/server/apps/immich/src/api-v1/server-info/response-dto/server-stats-response.dto.ts
index 615acfcf05..ed7a071769 100644
--- a/server/apps/immich/src/api-v1/server-info/response-dto/server-stats-response.dto.ts
+++ b/server/apps/immich/src/api-v1/server-info/response-dto/server-stats-response.dto.ts
@@ -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[] = [];
 }
diff --git a/server/apps/immich/src/api-v1/server-info/response-dto/usage-by-user-response.dto.ts b/server/apps/immich/src/api-v1/server-info/response-dto/usage-by-user-response.dto.ts
index 7502d63afd..ac3a829077 100644
--- a/server/apps/immich/src/api-v1/server-info/response-dto/usage-by-user-response.dto.ts
+++ b/server/apps/immich/src/api-v1/server-info/response-dto/usage-by-user-response.dto.ts
@@ -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;
 }
diff --git a/server/apps/immich/src/api-v1/server-info/server-info.module.ts b/server/apps/immich/src/api-v1/server-info/server-info.module.ts
index 1b5154695e..25d8a19e22 100644
--- a/server/apps/immich/src/api-v1/server-info/server-info.module.ts
+++ b/server/apps/immich/src/api-v1/server-info/server-info.module.ts
@@ -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],
 })
diff --git a/server/apps/immich/src/api-v1/server-info/server-info.service.ts b/server/apps/immich/src/api-v1/server-info/server-info.service.ts
index 243d880dfe..779d4163e6 100644
--- a/server/apps/immich/src/api-v1/server-info/server-info.service.ts
+++ b/server/apps/immich/src/api-v1/server-info/server-info.service.ts
@@ -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;
   }
diff --git a/server/apps/immich/src/api-v1/tag/tag.service.spec.ts b/server/apps/immich/src/api-v1/tag/tag.service.spec.ts
index 395a375738..877f60087d 100644
--- a/server/apps/immich/src/api-v1/tag/tag.service.spec.ts
+++ b/server/apps/immich/src/api-v1/tag/tag.service.spec.ts
@@ -25,6 +25,7 @@ describe('TagService', () => {
     deletedAt: undefined,
     updatedAt: '2022-12-02T19:29:23.603Z',
     tags: [],
+    assets: [],
     oauthId: 'oauth-id-1',
   });
 
diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json
index a89e62a3ac..9661eba018 100644
--- a/server/immich-openapi-specs.json
+++ b/server/immich-openapi-specs.json
@@ -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"
         ]
diff --git a/server/libs/domain/src/user/user.service.spec.ts b/server/libs/domain/src/user/user.service.spec.ts
index d0ab69fbf7..bcc327b444 100644
--- a/server/libs/domain/src/user/user.service.spec.ts
+++ b/server/libs/domain/src/user/user.service.spec.ts
@@ -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({
diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts
index dc972337e4..73f822a7ff 100644
--- a/server/libs/domain/test/fixtures.ts
+++ b/server/libs/domain/test/fixtures.ts
@@ -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: [],
   }),
 };
 
diff --git a/server/libs/infra/src/db/entities/user.entity.ts b/server/libs/infra/src/db/entities/user.entity.ts
index d8724aab9a..5fca9a89d0 100644
--- a/server/libs/infra/src/db/entities/user.entity.ts
+++ b/server/libs/infra/src/db/entities/user.entity.ts
@@ -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[];
 }
diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts
index 45571fd946..1f8464367a 100644
--- a/web/src/api/open-api/api.ts
+++ b/web/src/api/open-api/api.ts
@@ -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;
 }
 /**
  * 
diff --git a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte
index b20d897870..2243488c0d 100644
--- a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte
+++ b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte
@@ -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>
diff --git a/web/src/lib/components/admin-page/server-stats/stats-card.svelte b/web/src/lib/components/admin-page/server-stats/stats-card.svelte
index a3dcdab4b6..4a704a787d 100644
--- a/web/src/lib/components/admin-page/server-stats/stats-card.svelte
+++ b/web/src/lib/components/admin-page/server-stats/stats-card.svelte
@@ -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}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index 14d90bea15..1962e08256 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -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>
diff --git a/web/src/lib/components/shared-components/status-box.svelte b/web/src/lib/components/shared-components/status-box.svelte
index a7271b469d..871ec3b8f9 100644
--- a/web/src/lib/components/shared-components/status-box.svelte
+++ b/web/src/lib/components/shared-components/status-box.svelte
@@ -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">
diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte
index 5c891e1792..94497bfa75 100644
--- a/web/src/lib/components/shared-components/upload-asset-preview.svelte
+++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte
@@ -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">
diff --git a/web/src/lib/utils/byte-units.ts b/web/src/lib/utils/byte-units.ts
index 6c3cd882f7..2ab620f5cd 100644
--- a/web/src/lib/utils/byte-units.ts
+++ b/web/src/lib/utils/byte-units.ts
@@ -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}`;
 }
diff --git a/web/src/routes/admin/server-status/+page.server.ts b/web/src/routes/admin/server-status/+page.server.ts
index e688a8a11b..34a804fbd7 100644
--- a/web/src/routes/admin/server-status/+page.server.ts
+++ b/web/src/routes/admin/server-status/+page.server.ts
@@ -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;
diff --git a/web/src/routes/admin/server-status/+page.svelte b/web/src/routes/admin/server-status/+page.svelte
index 57da5c6372..8a930af796 100644
--- a/web/src/routes/admin/server-status/+page.svelte
+++ b/web/src/routes/admin/server-status/+page.svelte
@@ -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} />