mirror of
https://github.com/immich-app/immich.git
synced 2024-12-26 10:50:29 +02:00
feat(web): improved server stats
This commit is contained in:
parent
71d8567f18
commit
09a1d56bf3
BIN
mobile/openapi/doc/ServerStatsResponseDto.md
generated
BIN
mobile/openapi/doc/ServerStatsResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/UsageByUserDto.md
generated
BIN
mobile/openapi/doc/UsageByUserDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/server_stats_response_dto.dart
generated
BIN
mobile/openapi/lib/model/server_stats_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/usage_by_user_dto.dart
generated
BIN
mobile/openapi/lib/model/usage_by_user_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/server_stats_response_dto_test.dart
generated
BIN
mobile/openapi/test/server_stats_response_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/usage_by_user_dto_test.dart
generated
BIN
mobile/openapi/test/usage_by_user_dto_test.dart
generated
Binary file not shown.
@ -37,6 +37,7 @@ describe('Album service', () => {
|
|||||||
shouldChangePassword: false,
|
shouldChangePassword: false,
|
||||||
oauthId: '',
|
oauthId: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
assets: [],
|
||||||
});
|
});
|
||||||
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
||||||
const sharedAlbumOwnerId = '2222';
|
const sharedAlbumOwnerId = '2222';
|
||||||
|
@ -2,28 +2,14 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
import { UsageByUserDto } from './usage-by-user-response.dto';
|
import { UsageByUserDto } from './usage-by-user-response.dto';
|
||||||
|
|
||||||
export class ServerStatsResponseDto {
|
export class ServerStatsResponseDto {
|
||||||
constructor() {
|
@ApiProperty({ type: 'integer' })
|
||||||
this.photos = 0;
|
photos = 0;
|
||||||
this.videos = 0;
|
|
||||||
this.usageByUser = [];
|
|
||||||
this.usageRaw = 0;
|
|
||||||
this.usage = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer' })
|
||||||
photos!: number;
|
videos = 0;
|
||||||
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
videos!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
objects!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
usageRaw!: number;
|
usage = 0;
|
||||||
|
|
||||||
@ApiProperty({ type: 'string' })
|
|
||||||
usage!: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
isArray: true,
|
isArray: true,
|
||||||
@ -37,5 +23,5 @@ export class ServerStatsResponseDto {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
usageByUser!: UsageByUserDto[];
|
usageByUser: UsageByUserDto[] = [];
|
||||||
}
|
}
|
||||||
|
@ -1,22 +1,16 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class UsageByUserDto {
|
export class UsageByUserDto {
|
||||||
constructor(userId: string) {
|
|
||||||
this.userId = userId;
|
|
||||||
this.videos = 0;
|
|
||||||
this.photos = 0;
|
|
||||||
this.usageRaw = 0;
|
|
||||||
this.usage = '0B';
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiProperty({ type: 'string' })
|
@ApiProperty({ type: 'string' })
|
||||||
userId: string;
|
userId!: string;
|
||||||
|
@ApiProperty({ type: 'string' })
|
||||||
|
userFirstName!: string;
|
||||||
|
@ApiProperty({ type: 'string' })
|
||||||
|
userLastName!: string;
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer' })
|
||||||
videos: number;
|
photos!: number;
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer' })
|
||||||
photos: number;
|
videos!: number;
|
||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
usageRaw!: number;
|
usage!: number;
|
||||||
@ApiProperty({ type: 'string' })
|
|
||||||
usage!: string;
|
|
||||||
}
|
}
|
||||||
|
@ -1,11 +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/infra';
|
import { UserEntity } from '@app/infra';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([AssetEntity])],
|
imports: [TypeOrmModule.forFeature([UserEntity])],
|
||||||
controllers: [ServerInfoController],
|
controllers: [ServerInfoController],
|
||||||
providers: [ServerInfoService],
|
providers: [ServerInfoService],
|
||||||
})
|
})
|
||||||
|
@ -4,7 +4,7 @@ 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 { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
|
||||||
import { UsageByUserDto } from './response-dto/usage-by-user-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 { Repository } from 'typeorm';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { asHumanReadable } from '../../utils/human-readable.util';
|
import { asHumanReadable } from '../../utils/human-readable.util';
|
||||||
@ -12,8 +12,8 @@ import { asHumanReadable } from '../../utils/human-readable.util';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ServerInfoService {
|
export class ServerInfoService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AssetEntity)
|
@InjectRepository(UserEntity)
|
||||||
private assetRepository: Repository<AssetEntity>,
|
private userRepository: Repository<UserEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getServerInfo(): Promise<ServerInfoResponseDto> {
|
async getServerInfo(): Promise<ServerInfoResponseDto> {
|
||||||
@ -33,44 +33,48 @@ export class ServerInfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getStats(): Promise<ServerStatsResponseDto> {
|
async getStats(): Promise<ServerStatsResponseDto> {
|
||||||
const serverStats = new ServerStatsResponseDto();
|
|
||||||
|
|
||||||
type UserStatsQueryResponse = {
|
type UserStatsQueryResponse = {
|
||||||
assetType: string;
|
userId: string;
|
||||||
assetCount: string;
|
userFirstName: string;
|
||||||
totalSizeInBytes: string;
|
userLastName: string;
|
||||||
ownerId: string;
|
photos: string;
|
||||||
|
videos: string;
|
||||||
|
usage: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const userStatsQueryResponse: UserStatsQueryResponse[] = await this.assetRepository
|
const userStatsQueryResponse: UserStatsQueryResponse[] = await this.userRepository
|
||||||
.createQueryBuilder('a')
|
.createQueryBuilder('users')
|
||||||
.select('COUNT(a.id)', 'assetCount')
|
.select('users.id', 'userId')
|
||||||
.addSelect('SUM(ei.fileSizeInByte)', 'totalSizeInBytes')
|
.addSelect('users.firstName', 'userFirstName')
|
||||||
.addSelect('a."ownerId"')
|
.addSelect('users.lastName', 'userLastName')
|
||||||
.addSelect('a.type', 'assetType')
|
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
|
||||||
.where('a.isVisible = true')
|
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
|
||||||
.leftJoin('a.exifInfo', 'ei')
|
.addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
|
||||||
.groupBy('a."ownerId"')
|
.leftJoin('users.assets', 'assets')
|
||||||
.addGroupBy('a.type')
|
.leftJoin('assets.exifInfo', 'exif')
|
||||||
|
.groupBy('users.id')
|
||||||
|
.orderBy('users.createdAt', 'ASC')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
const tmpMap = new Map<string, UsageByUserDto>();
|
const usageByUser = userStatsQueryResponse.map((userStats) => {
|
||||||
const getUsageByUser = (id: string) => tmpMap.get(id) || new UsageByUserDto(id);
|
const usage = new UsageByUserDto();
|
||||||
userStatsQueryResponse.forEach((r) => {
|
usage.userId = userStats.userId;
|
||||||
const usageByUser = getUsageByUser(r.ownerId);
|
usage.userFirstName = userStats.userFirstName;
|
||||||
usageByUser.photos += r.assetType === 'IMAGE' ? parseInt(r.assetCount) : 0;
|
usage.userLastName = userStats.userLastName;
|
||||||
usageByUser.videos += r.assetType === 'VIDEO' ? parseInt(r.assetCount) : 0;
|
usage.photos = Number(userStats.photos);
|
||||||
usageByUser.usageRaw += parseInt(r.totalSizeInBytes);
|
usage.videos = Number(userStats.videos);
|
||||||
usageByUser.usage = asHumanReadable(usageByUser.usageRaw);
|
usage.usage = Number(userStats.usage);
|
||||||
|
|
||||||
serverStats.photos += r.assetType === 'IMAGE' ? parseInt(r.assetCount) : 0;
|
return usage;
|
||||||
serverStats.videos += r.assetType === 'VIDEO' ? parseInt(r.assetCount) : 0;
|
|
||||||
serverStats.usageRaw += parseInt(r.totalSizeInBytes);
|
|
||||||
serverStats.usage = asHumanReadable(serverStats.usageRaw);
|
|
||||||
tmpMap.set(r.ownerId, usageByUser);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
return serverStats;
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,7 @@ describe('TagService', () => {
|
|||||||
deletedAt: undefined,
|
deletedAt: undefined,
|
||||||
updatedAt: '2022-12-02T19:29:23.603Z',
|
updatedAt: '2022-12-02T19:29:23.603Z',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
assets: [],
|
||||||
oauthId: 'oauth-id-1',
|
oauthId: 'oauth-id-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -4883,25 +4883,29 @@
|
|||||||
"userId": {
|
"userId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"videos": {
|
"userFirstName": {
|
||||||
"type": "integer"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"userLastName": {
|
||||||
|
"type": "string"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"usageRaw": {
|
"videos": {
|
||||||
"type": "integer",
|
"type": "integer"
|
||||||
"format": "int64"
|
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"type": "string"
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"userId",
|
"userId",
|
||||||
"videos",
|
"userFirstName",
|
||||||
|
"userLastName",
|
||||||
"photos",
|
"photos",
|
||||||
"usageRaw",
|
"videos",
|
||||||
"usage"
|
"usage"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -4909,22 +4913,20 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"photos": {
|
"photos": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"default": 0
|
||||||
},
|
},
|
||||||
"videos": {
|
"videos": {
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"objects": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"usageRaw": {
|
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"default": 0
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"type": "string"
|
"type": "integer",
|
||||||
|
"default": 0,
|
||||||
|
"format": "int64"
|
||||||
},
|
},
|
||||||
"usageByUser": {
|
"usageByUser": {
|
||||||
|
"default": [],
|
||||||
"title": "Array of usage for each user",
|
"title": "Array of usage for each user",
|
||||||
"example": [
|
"example": [
|
||||||
{
|
{
|
||||||
@ -4942,8 +4944,6 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"photos",
|
"photos",
|
||||||
"videos",
|
"videos",
|
||||||
"objects",
|
|
||||||
"usageRaw",
|
|
||||||
"usage",
|
"usage",
|
||||||
"usageByUser"
|
"usageByUser"
|
||||||
]
|
]
|
||||||
|
@ -33,6 +33,7 @@ const adminUser: UserEntity = Object.freeze({
|
|||||||
createdAt: '2021-01-01',
|
createdAt: '2021-01-01',
|
||||||
updatedAt: '2021-01-01',
|
updatedAt: '2021-01-01',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
assets: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const immichUser: UserEntity = Object.freeze({
|
const immichUser: UserEntity = Object.freeze({
|
||||||
@ -48,6 +49,7 @@ const immichUser: UserEntity = Object.freeze({
|
|||||||
createdAt: '2021-01-01',
|
createdAt: '2021-01-01',
|
||||||
updatedAt: '2021-01-01',
|
updatedAt: '2021-01-01',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
assets: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedImmichUser: UserEntity = Object.freeze({
|
const updatedImmichUser: UserEntity = Object.freeze({
|
||||||
@ -63,6 +65,7 @@ const updatedImmichUser: UserEntity = Object.freeze({
|
|||||||
createdAt: '2021-01-01',
|
createdAt: '2021-01-01',
|
||||||
updatedAt: '2021-01-01',
|
updatedAt: '2021-01-01',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
assets: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const adminUserResponse = Object.freeze({
|
const adminUserResponse = Object.freeze({
|
||||||
|
@ -76,6 +76,7 @@ export const userEntityStub = {
|
|||||||
createdAt: '2021-01-01',
|
createdAt: '2021-01-01',
|
||||||
updatedAt: '2021-01-01',
|
updatedAt: '2021-01-01',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
assets: [],
|
||||||
}),
|
}),
|
||||||
user1: Object.freeze<UserEntity>({
|
user1: Object.freeze<UserEntity>({
|
||||||
...authStub.user1,
|
...authStub.user1,
|
||||||
@ -88,6 +89,7 @@ export const userEntityStub = {
|
|||||||
createdAt: '2021-01-01',
|
createdAt: '2021-01-01',
|
||||||
updatedAt: '2021-01-01',
|
updatedAt: '2021-01-01',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
assets: [],
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { AssetEntity } from './asset.entity';
|
||||||
import { TagEntity } from './tag.entity';
|
import { TagEntity } from './tag.entity';
|
||||||
|
|
||||||
@Entity('users')
|
@Entity('users')
|
||||||
@ -49,4 +50,7 @@ export class UserEntity {
|
|||||||
|
|
||||||
@OneToMany(() => TagEntity, (tag) => tag.user)
|
@OneToMany(() => TagEntity, (tag) => tag.user)
|
||||||
tags!: TagEntity[];
|
tags!: TagEntity[];
|
||||||
|
|
||||||
|
@OneToMany(() => AssetEntity, (asset) => asset.owner)
|
||||||
|
assets!: AssetEntity[];
|
||||||
}
|
}
|
||||||
|
30
web/src/api/open-api/api.ts
generated
30
web/src/api/open-api/api.ts
generated
@ -1549,19 +1549,7 @@ export interface ServerStatsResponseDto {
|
|||||||
* @type {number}
|
* @type {number}
|
||||||
* @memberof ServerStatsResponseDto
|
* @memberof ServerStatsResponseDto
|
||||||
*/
|
*/
|
||||||
'objects': number;
|
'usage': number;
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @type {number}
|
|
||||||
* @memberof ServerStatsResponseDto
|
|
||||||
*/
|
|
||||||
'usageRaw': number;
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @type {string}
|
|
||||||
* @memberof ServerStatsResponseDto
|
|
||||||
*/
|
|
||||||
'usage': string;
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {Array<UsageByUserDto>}
|
* @type {Array<UsageByUserDto>}
|
||||||
@ -2184,10 +2172,16 @@ export interface UsageByUserDto {
|
|||||||
'userId': string;
|
'userId': string;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {number}
|
* @type {string}
|
||||||
* @memberof UsageByUserDto
|
* @memberof UsageByUserDto
|
||||||
*/
|
*/
|
||||||
'videos': number;
|
'userFirstName': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof UsageByUserDto
|
||||||
|
*/
|
||||||
|
'userLastName': string;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {number}
|
* @type {number}
|
||||||
@ -2199,13 +2193,13 @@ export interface UsageByUserDto {
|
|||||||
* @type {number}
|
* @type {number}
|
||||||
* @memberof UsageByUserDto
|
* @memberof UsageByUserDto
|
||||||
*/
|
*/
|
||||||
'usageRaw': number;
|
'videos': number;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {number}
|
||||||
* @memberof UsageByUserDto
|
* @memberof UsageByUserDto
|
||||||
*/
|
*/
|
||||||
'usage': string;
|
'usage': number;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
@ -1,43 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api, ServerStatsResponseDto, UserResponseDto } from '@api';
|
import { ServerStatsResponseDto } from '@api';
|
||||||
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
|
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
|
||||||
import PlayCircle from 'svelte-material-icons/PlayCircle.svelte';
|
import PlayCircle from 'svelte-material-icons/PlayCircle.svelte';
|
||||||
import Memory from 'svelte-material-icons/Memory.svelte';
|
import Memory from 'svelte-material-icons/Memory.svelte';
|
||||||
import StatsCard from './stats-card.svelte';
|
import StatsCard from './stats-card.svelte';
|
||||||
import { getBytesWithUnit, asByteUnitString } from '../../../utils/byte-units';
|
import { asByteUnitString, getBytesWithUnit } from '../../../utils/byte-units';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
|
||||||
export let allUsers: Array<UserResponseDto>;
|
export let stats: ServerStatsResponseDto = {
|
||||||
|
photos: 0,
|
||||||
let stats: ServerStatsResponseDto;
|
videos: 0,
|
||||||
let setIntervalHandler: NodeJS.Timer;
|
usage: 0,
|
||||||
|
usageByUser: []
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stats are unavailable if data is not loaded yet
|
$: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, 0);
|
||||||
$: [spaceUsage, spaceUnit] = getBytesWithUnit(stats ? stats.usageRaw : 0);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
@ -45,14 +22,9 @@
|
|||||||
<p class="text-sm dark:text-immich-dark-fg">TOTAL USAGE</p>
|
<p class="text-sm dark:text-immich-dark-fg">TOTAL USAGE</p>
|
||||||
|
|
||||||
<div class="flex mt-5 justify-between">
|
<div class="flex mt-5 justify-between">
|
||||||
<StatsCard logo={CameraIris} title={'PHOTOS'} value={stats && stats.photos.toString()} />
|
<StatsCard logo={CameraIris} title="PHOTOS" value={stats.photos} />
|
||||||
<StatsCard logo={PlayCircle} title={'VIDEOS'} value={stats && stats.videos.toString()} />
|
<StatsCard logo={PlayCircle} title="VIDEOS" value={stats.videos} />
|
||||||
<StatsCard
|
<StatsCard logo={Memory} title="STORAGE" value={statsUsage} unit={statsUsageUnit} />
|
||||||
logo={Memory}
|
|
||||||
title={'STORAGE'}
|
|
||||||
value={stats && spaceUsage.toString()}
|
|
||||||
unit={spaceUnit}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -72,32 +44,19 @@
|
|||||||
<tbody
|
<tbody
|
||||||
class="overflow-y-auto rounded-md w-full max-h-[320px] block border dark:border-immich-dark-gray dark:text-immich-dark-fg"
|
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 (user.userId)}
|
||||||
{#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}
|
|
||||||
<tr
|
<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">
|
<td class="text-sm px-2 w-1/4 text-ellipsis"
|
||||||
<LoadingSpinner />
|
>{user.userFirstName} {user.userLastName}</td
|
||||||
</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>
|
</tr>
|
||||||
{/if}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,19 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type Icon from 'svelte-material-icons/AbTesting.svelte';
|
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 logo: typeof Icon;
|
||||||
export let title: string;
|
export let title: string;
|
||||||
export let value: string;
|
export let value: number;
|
||||||
export let unit: string | undefined = undefined;
|
export let unit: string | undefined = undefined;
|
||||||
|
|
||||||
$: zeros = () => {
|
$: zeros = () => {
|
||||||
if (!value) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxLength = 13;
|
const maxLength = 13;
|
||||||
const valueLength = parseInt(value).toString().length;
|
const valueLength = value.toString().length;
|
||||||
const zeroLength = maxLength - valueLength;
|
const zeroLength = maxLength - valueLength;
|
||||||
|
|
||||||
return '0'.repeat(zeroLength);
|
return '0'.repeat(zeroLength);
|
||||||
@ -29,15 +24,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative text-center font-mono font-semibold text-2xl">
|
<div class="relative text-center font-mono font-semibold text-2xl">
|
||||||
{#if value !== undefined}
|
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros()}</span><span
|
||||||
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros()}</span><span
|
class="text-immich-primary dark:text-immich-dark-primary">{value}</span
|
||||||
class="text-immich-primary dark:text-immich-dark-primary">{parseInt(value)}</span
|
>
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<div class="flex justify-end pr-2">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if unit}
|
{#if unit}
|
||||||
<span class="absolute -top-5 right-2 text-base font-light text-gray-400">{unit}</span>
|
<span class="absolute -top-5 right-2 text-base font-light text-gray-400">{unit}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -135,7 +135,7 @@
|
|||||||
|
|
||||||
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
|
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p>{asByteUnitString(asset.exifInfo.fileSizeInByte)}</p>
|
<p>{asByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
import LoadingSpinner from './loading-spinner.svelte';
|
import LoadingSpinner from './loading-spinner.svelte';
|
||||||
import { api, ServerInfoResponseDto } from '@api';
|
import { api, ServerInfoResponseDto } from '@api';
|
||||||
import { asByteUnitString } from '../../utils/byte-units';
|
import { asByteUnitString } from '../../utils/byte-units';
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
|
||||||
let isServerOk = true;
|
let isServerOk = true;
|
||||||
let serverVersion = '';
|
let serverVersion = '';
|
||||||
@ -63,7 +64,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs">
|
<p class="text-xs">
|
||||||
{asByteUnitString(serverInfo?.diskUseRaw)} of {asByteUnitString(serverInfo?.diskSizeRaw)} used
|
{asByteUnitString(serverInfo?.diskUseRaw, $locale)} of
|
||||||
|
{asByteUnitString(serverInfo?.diskSizeRaw, $locale)} used
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import { asByteUnitString } from '$lib/utils/byte-units';
|
import { asByteUnitString } from '$lib/utils/byte-units';
|
||||||
import { UploadAsset } from '$lib/models/upload-asset';
|
import { UploadAsset } from '$lib/models/upload-asset';
|
||||||
import ImmichLogo from './immich-logo.svelte';
|
import ImmichLogo from './immich-logo.svelte';
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
|
||||||
export let uploadAsset: UploadAsset;
|
export let uploadAsset: UploadAsset;
|
||||||
|
|
||||||
@ -50,7 +51,7 @@
|
|||||||
<input
|
<input
|
||||||
disabled
|
disabled
|
||||||
class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
|
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">
|
<div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative">
|
||||||
|
@ -38,8 +38,7 @@ export function getBytesWithUnit(bytes: number, maxPrecision = 1): [number, stri
|
|||||||
* @param maxPrecision maximum number of decimal places, default is `1`
|
* @param maxPrecision maximum number of decimal places, default is `1`
|
||||||
* @returns localized bytes with unit as string
|
* @returns localized bytes with unit as string
|
||||||
*/
|
*/
|
||||||
export function asByteUnitString(bytes: number, maxPrecision = 1): string {
|
export function asByteUnitString(bytes: number, locale?: string, maxPrecision = 1): string {
|
||||||
const locale = Array.from(navigator.languages);
|
|
||||||
const [size, unit] = getBytesWithUnit(bytes, maxPrecision);
|
const [size, unit] = getBytesWithUnit(bytes, maxPrecision);
|
||||||
return `${size.toLocaleString(locale)} ${unit}`;
|
return `${size.toLocaleString(locale)} ${unit}`;
|
||||||
}
|
}
|
||||||
|
@ -10,12 +10,12 @@ export const load = (async ({ parent, locals: { api } }) => {
|
|||||||
throw redirect(302, '/photos');
|
throw redirect(302, '/photos');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: allUsers } = await api.userApi.getAllUsers(false);
|
const { data: stats } = await api.serverInfoApi.getStats();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allUsers,
|
stats,
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Server Status'
|
title: 'Server Stats'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}) satisfies PageServerLoad;
|
}) satisfies PageServerLoad;
|
||||||
|
@ -1,8 +1,22 @@
|
|||||||
<script lang="ts">
|
<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 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>
|
</script>
|
||||||
|
|
||||||
{#if $page.data.allUsers}
|
<ServerStatsPanel stats={data.stats} />
|
||||||
<ServerStatsPanel allUsers={$page.data.allUsers} />
|
|
||||||
{/if}
|
|
||||||
|
Loading…
Reference in New Issue
Block a user