1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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:
Michel Heusschen 2023-02-26 20:57:34 +01:00 committed by GitHub
parent 7d45ae68a6
commit 368142e79b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 148 additions and 195 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -37,6 +37,7 @@ describe('Album service', () => {
shouldChangePassword: false,
oauthId: '',
tags: [],
assets: [],
});
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222';

View File

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

View File

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

View File

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

View File

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

View File

@ -25,6 +25,7 @@ describe('TagService', () => {
deletedAt: undefined,
updatedAt: '2022-12-02T19:29:23.603Z',
tags: [],
assets: [],
oauthId: 'oauth-id-1',
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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