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

This commit is contained in:
Michel Heusschen 2023-02-25 15:05:58 +01:00
parent 71d8567f18
commit 09a1d56bf3
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

@ -33,6 +33,7 @@ const adminUser: UserEntity = Object.freeze({
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
tags: [],
assets: [],
});
const immichUser: UserEntity = Object.freeze({
@ -48,6 +49,7 @@ const immichUser: UserEntity = Object.freeze({
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
tags: [],
assets: [],
});
const updatedImmichUser: UserEntity = Object.freeze({
@ -63,6 +65,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} />