mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 15:59:30 +03:00
fix comments
This commit is contained in:
@@ -13,16 +13,12 @@ part of openapi.api;
|
|||||||
class LibraryStatsResponseDto {
|
class LibraryStatsResponseDto {
|
||||||
/// Returns a new [LibraryStatsResponseDto] instance.
|
/// Returns a new [LibraryStatsResponseDto] instance.
|
||||||
LibraryStatsResponseDto({
|
LibraryStatsResponseDto({
|
||||||
this.offline = 0,
|
|
||||||
this.photos = 0,
|
this.photos = 0,
|
||||||
this.total = 0,
|
this.total = 0,
|
||||||
this.usage = 0,
|
this.usage = 0,
|
||||||
this.videos = 0,
|
this.videos = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Number of offline assets
|
|
||||||
int offline;
|
|
||||||
|
|
||||||
/// Number of photos
|
/// Number of photos
|
||||||
int photos;
|
int photos;
|
||||||
|
|
||||||
@@ -37,7 +33,6 @@ class LibraryStatsResponseDto {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is LibraryStatsResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is LibraryStatsResponseDto &&
|
||||||
other.offline == offline &&
|
|
||||||
other.photos == photos &&
|
other.photos == photos &&
|
||||||
other.total == total &&
|
other.total == total &&
|
||||||
other.usage == usage &&
|
other.usage == usage &&
|
||||||
@@ -46,18 +41,16 @@ class LibraryStatsResponseDto {
|
|||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(offline.hashCode) +
|
|
||||||
(photos.hashCode) +
|
(photos.hashCode) +
|
||||||
(total.hashCode) +
|
(total.hashCode) +
|
||||||
(usage.hashCode) +
|
(usage.hashCode) +
|
||||||
(videos.hashCode);
|
(videos.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'LibraryStatsResponseDto[offline=$offline, photos=$photos, total=$total, usage=$usage, videos=$videos]';
|
String toString() => 'LibraryStatsResponseDto[photos=$photos, total=$total, usage=$usage, videos=$videos]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'offline'] = this.offline;
|
|
||||||
json[r'photos'] = this.photos;
|
json[r'photos'] = this.photos;
|
||||||
json[r'total'] = this.total;
|
json[r'total'] = this.total;
|
||||||
json[r'usage'] = this.usage;
|
json[r'usage'] = this.usage;
|
||||||
@@ -74,7 +67,6 @@ class LibraryStatsResponseDto {
|
|||||||
final json = value.cast<String, dynamic>();
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
return LibraryStatsResponseDto(
|
return LibraryStatsResponseDto(
|
||||||
offline: mapValueOfType<int>(json, r'offline')!,
|
|
||||||
photos: mapValueOfType<int>(json, r'photos')!,
|
photos: mapValueOfType<int>(json, r'photos')!,
|
||||||
total: mapValueOfType<int>(json, r'total')!,
|
total: mapValueOfType<int>(json, r'total')!,
|
||||||
usage: mapValueOfType<int>(json, r'usage')!,
|
usage: mapValueOfType<int>(json, r'usage')!,
|
||||||
@@ -126,7 +118,6 @@ class LibraryStatsResponseDto {
|
|||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'offline',
|
|
||||||
'photos',
|
'photos',
|
||||||
'total',
|
'total',
|
||||||
'usage',
|
'usage',
|
||||||
|
|||||||
@@ -18264,11 +18264,6 @@
|
|||||||
},
|
},
|
||||||
"LibraryStatsResponseDto": {
|
"LibraryStatsResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"offline": {
|
|
||||||
"default": 0,
|
|
||||||
"description": "Number of offline assets",
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"photos": {
|
"photos": {
|
||||||
"default": 0,
|
"default": 0,
|
||||||
"description": "Number of photos",
|
"description": "Number of photos",
|
||||||
@@ -18292,7 +18287,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"offline",
|
|
||||||
"photos",
|
"photos",
|
||||||
"total",
|
"total",
|
||||||
"usage",
|
"usage",
|
||||||
|
|||||||
@@ -1323,8 +1323,6 @@ export type UpdateLibraryDto = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
export type LibraryStatsResponseDto = {
|
export type LibraryStatsResponseDto = {
|
||||||
/** Number of offline assets */
|
|
||||||
offline: number;
|
|
||||||
/** Number of photos */
|
/** Number of photos */
|
||||||
photos: number;
|
photos: number;
|
||||||
/** Total number of assets */
|
/** Total number of assets */
|
||||||
|
|||||||
@@ -136,9 +136,6 @@ export class LibraryStatsResponseDto {
|
|||||||
@ApiProperty({ type: 'integer', description: 'Total number of assets' })
|
@ApiProperty({ type: 'integer', description: 'Total number of assets' })
|
||||||
total = 0;
|
total = 0;
|
||||||
|
|
||||||
@ApiProperty({ type: 'integer', description: 'Number of offline assets' })
|
|
||||||
offline = 0;
|
|
||||||
|
|
||||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage in bytes' })
|
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage in bytes' })
|
||||||
usage = 0;
|
usage = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,37 +36,27 @@ select
|
|||||||
(
|
(
|
||||||
"asset"."type" = $1
|
"asset"."type" = $1
|
||||||
and "asset"."visibility" != $2
|
and "asset"."visibility" != $2
|
||||||
and "asset"."isOffline" = $3
|
|
||||||
)
|
)
|
||||||
) as "photos",
|
) as "photos",
|
||||||
count(*) filter (
|
count(*) filter (
|
||||||
where
|
where
|
||||||
(
|
(
|
||||||
"asset"."type" = $4
|
"asset"."type" = $3
|
||||||
and "asset"."visibility" != $5
|
and "asset"."visibility" != $4
|
||||||
and "asset"."isOffline" = $6
|
|
||||||
)
|
)
|
||||||
) as "videos",
|
) as "videos",
|
||||||
count(*) filter (
|
coalesce(sum("asset_exif"."fileSizeInByte"), $5) as "usage"
|
||||||
where
|
|
||||||
(
|
|
||||||
"asset"."isOffline" = $7
|
|
||||||
and "asset"."visibility" != $8
|
|
||||||
)
|
|
||||||
) as "offline",
|
|
||||||
coalesce(sum("asset_exif"."fileSizeInByte"), $9) as "usage"
|
|
||||||
from
|
from
|
||||||
"library"
|
"library"
|
||||||
inner join "asset" on "asset"."libraryId" = "library"."id"
|
inner join "asset" on "asset"."libraryId" = "library"."id"
|
||||||
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||||
where
|
where
|
||||||
"library"."id" = $10
|
"library"."id" = $6
|
||||||
group by
|
group by
|
||||||
"library"."id"
|
"library"."id"
|
||||||
select
|
select
|
||||||
0::int as "photos",
|
0::int as "photos",
|
||||||
0::int as "videos",
|
0::int as "videos",
|
||||||
0::int as "offline",
|
|
||||||
0::int as "usage",
|
0::int as "usage",
|
||||||
0::int as "total"
|
0::int as "total"
|
||||||
from
|
from
|
||||||
|
|||||||
@@ -79,11 +79,7 @@ export class LibraryRepository {
|
|||||||
eb.fn
|
eb.fn
|
||||||
.countAll<number>()
|
.countAll<number>()
|
||||||
.filterWhere((eb) =>
|
.filterWhere((eb) =>
|
||||||
eb.and([
|
eb.and([eb('asset.type', '=', AssetType.Image), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
|
||||||
eb('asset.type', '=', AssetType.Image),
|
|
||||||
eb('asset.visibility', '!=', AssetVisibility.Hidden),
|
|
||||||
eb('asset.isOffline', '=', false),
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
.as('photos'),
|
.as('photos'),
|
||||||
)
|
)
|
||||||
@@ -91,22 +87,10 @@ export class LibraryRepository {
|
|||||||
eb.fn
|
eb.fn
|
||||||
.countAll<number>()
|
.countAll<number>()
|
||||||
.filterWhere((eb) =>
|
.filterWhere((eb) =>
|
||||||
eb.and([
|
eb.and([eb('asset.type', '=', AssetType.Video), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
|
||||||
eb('asset.type', '=', AssetType.Video),
|
|
||||||
eb('asset.visibility', '!=', AssetVisibility.Hidden),
|
|
||||||
eb('asset.isOffline', '=', false),
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
.as('videos'),
|
.as('videos'),
|
||||||
)
|
)
|
||||||
.select((eb) =>
|
|
||||||
eb.fn
|
|
||||||
.countAll<number>()
|
|
||||||
.filterWhere((eb) =>
|
|
||||||
eb.and([eb('asset.isOffline', '=', true), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
|
|
||||||
)
|
|
||||||
.as('offline'),
|
|
||||||
)
|
|
||||||
.select((eb) => eb.fn.coalesce((eb) => eb.fn.sum('asset_exif.fileSizeInByte'), eb.val(0)).as('usage'))
|
.select((eb) => eb.fn.coalesce((eb) => eb.fn.sum('asset_exif.fileSizeInByte'), eb.val(0)).as('usage'))
|
||||||
.groupBy('library.id')
|
.groupBy('library.id')
|
||||||
.where('library.id', '=', id)
|
.where('library.id', '=', id)
|
||||||
@@ -119,7 +103,6 @@ export class LibraryRepository {
|
|||||||
.selectFrom('library')
|
.selectFrom('library')
|
||||||
.select(zero.as('photos'))
|
.select(zero.as('photos'))
|
||||||
.select(zero.as('videos'))
|
.select(zero.as('videos'))
|
||||||
.select(zero.as('offline'))
|
|
||||||
.select(zero.as('usage'))
|
.select(zero.as('usage'))
|
||||||
.select(zero.as('total'))
|
.select(zero.as('total'))
|
||||||
.where('library.id', '=', id)
|
.where('library.id', '=', id)
|
||||||
@@ -129,7 +112,6 @@ export class LibraryRepository {
|
|||||||
return {
|
return {
|
||||||
photos: stats.photos,
|
photos: stats.photos,
|
||||||
videos: stats.videos,
|
videos: stats.videos,
|
||||||
offline: stats.offline,
|
|
||||||
usage: stats.usage,
|
usage: stats.usage,
|
||||||
total: stats.photos + stats.videos,
|
total: stats.photos + stats.videos,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -681,19 +681,12 @@ describe(LibraryService.name, () => {
|
|||||||
it('should return library statistics', async () => {
|
it('should return library statistics', async () => {
|
||||||
const library = factory.library();
|
const library = factory.library();
|
||||||
|
|
||||||
mocks.library.getStatistics.mockResolvedValue({
|
mocks.library.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
|
||||||
photos: 10,
|
|
||||||
videos: 0,
|
|
||||||
total: 10,
|
|
||||||
usage: 1337,
|
|
||||||
offline: 67,
|
|
||||||
});
|
|
||||||
await expect(sut.getStatistics(library.id)).resolves.toEqual({
|
await expect(sut.getStatistics(library.id)).resolves.toEqual({
|
||||||
photos: 10,
|
photos: 10,
|
||||||
videos: 0,
|
videos: 0,
|
||||||
total: 10,
|
total: 10,
|
||||||
usage: 1337,
|
usage: 1337,
|
||||||
offline: 67,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.library.getStatistics).toHaveBeenCalledWith(library.id);
|
expect(mocks.library.getStatistics).toHaveBeenCalledWith(library.id);
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ByteUnit } from '$lib/utils/byte-units';
|
import { ByteUnit } from '$lib/utils/byte-units';
|
||||||
import { Icon, LoadingSpinner, Text } from '@immich/ui';
|
import { Icon, Text } from '@immich/ui';
|
||||||
|
|
||||||
|
interface ValueData {
|
||||||
|
value: number;
|
||||||
|
unit?: ByteUnit | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon: string;
|
icon: string;
|
||||||
title: string;
|
title: string;
|
||||||
value?: number;
|
valuePromise: Promise<ValueData>;
|
||||||
unit?: ByteUnit | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { icon, title, value = undefined, unit = undefined }: Props = $props();
|
let { icon, title, valuePromise }: Props = $props();
|
||||||
|
const zeros = (data?: ValueData) => {
|
||||||
const zeros = $derived(() => {
|
let length = 13;
|
||||||
if (value === undefined) {
|
if (data) {
|
||||||
return '';
|
const valueLength = data.value.toString().length;
|
||||||
|
length = length - valueLength;
|
||||||
}
|
}
|
||||||
const maxLength = 13;
|
|
||||||
const valueLength = value.toString().length;
|
|
||||||
const zeroLength = maxLength - valueLength;
|
|
||||||
|
|
||||||
return '0'.repeat(zeroLength);
|
return '0'.repeat(length);
|
||||||
});
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-35 w-full flex-col justify-between rounded-3xl bg-subtle text-primary p-5">
|
<div class="flex h-35 w-full flex-col justify-between rounded-3xl bg-subtle text-primary p-5">
|
||||||
@@ -29,14 +31,35 @@
|
|||||||
<Text size="giant" fontWeight="medium">{title}</Text>
|
<Text size="giant" fontWeight="medium">{title}</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto font-mono text-2xl font-medium">
|
{#await valuePromise}
|
||||||
{#if value === undefined}
|
<div class="mx-auto font-mono text-2xl font-medium relative">
|
||||||
<LoadingSpinner />
|
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros()}</span>
|
||||||
{:else}
|
</div>
|
||||||
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span><span>{value}</span>
|
{:then data}
|
||||||
{#if unit}
|
<div class="mx-auto font-mono text-2xl font-medium relative">
|
||||||
<code class="font-mono text-base font-normal">{unit}</code>
|
<span class="text-gray-300 dark:text-gray-600">{zeros(data)}</span><span>{data.value}</span>
|
||||||
{/if}
|
{#if data.unit}<code class="font-mono text-base font-normal">{data.unit}</code>{/if}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{:catch _}
|
||||||
|
<div class="mx-auto font-mono text-2xl font-medium relative">
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span>
|
||||||
|
</div>
|
||||||
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shimmer-text {
|
||||||
|
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 1) 100%);
|
||||||
|
mask-size: 200% 100%;
|
||||||
|
animation: shimmer 2.25s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
from {
|
||||||
|
mask-position: 200% 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
mask-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import StatsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||||
import type { ServerStatsResponseDto } from '@immich/sdk';
|
import type { ServerStatsResponseDto, UserAdminResponseDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
Code,
|
Code,
|
||||||
FormatBytes,
|
FormatBytes,
|
||||||
@@ -19,10 +19,28 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
stats: ServerStatsResponseDto;
|
statsPromise: Promise<ServerStatsResponseDto>;
|
||||||
|
users: UserAdminResponseDto[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const { stats }: Props = $props();
|
const { statsPromise, users }: Props = $props();
|
||||||
|
|
||||||
|
const photosPromise = $derived.by(() => statsPromise.then((data) => ({ value: data.photos })));
|
||||||
|
|
||||||
|
const videosPromise = $derived.by(() => statsPromise.then((data) => ({ value: data.videos })));
|
||||||
|
|
||||||
|
const storagePromise = $derived.by(() =>
|
||||||
|
statsPromise.then((data) => {
|
||||||
|
const TiB = 1024 ** 4;
|
||||||
|
const [value, unit] = getBytesWithUnit(data.usage, data.usage > TiB ? 2 : 0);
|
||||||
|
return { value, unit };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStorageUsageWithUnit = (usage: number) => {
|
||||||
|
const TiB = 1024 ** 4;
|
||||||
|
return getBytesWithUnit(usage, usage > TiB ? 2 : 0);
|
||||||
|
};
|
||||||
|
|
||||||
const zeros = (value: number, maxLength = 13) => {
|
const zeros = (value: number, maxLength = 13) => {
|
||||||
const valueLength = value.toString().length;
|
const valueLength = value.toString().length;
|
||||||
@@ -31,18 +49,25 @@
|
|||||||
return '0'.repeat(zeroLength);
|
return '0'.repeat(zeroLength);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TiB = 1024 ** 4;
|
const getUserStatsPromise = (userId: string) => {
|
||||||
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
|
return statsPromise.then((stats) => stats.usageByUser.find((userStats) => userStats.userId === userId));
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet placeholder()}
|
||||||
|
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-16"></span></TableCell>
|
||||||
|
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-16"></span></TableCell>
|
||||||
|
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-24"></span></TableCell>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<div class="flex flex-col gap-5 my-4">
|
<div class="flex flex-col gap-5 my-4">
|
||||||
<div>
|
<div>
|
||||||
<Text class="mb-2" fontWeight="medium">{$t('total_usage')}</Text>
|
<Text class="mb-2" fontWeight="medium">{$t('total_usage')}</Text>
|
||||||
|
|
||||||
<div class="hidden justify-between lg:flex gap-4">
|
<div class="hidden justify-between lg:flex gap-4">
|
||||||
<StatsCard icon={mdiCameraIris} title={$t('photos')} value={stats.photos} />
|
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
|
||||||
<StatsCard icon={mdiPlayCircle} title={$t('videos')} value={stats.videos} />
|
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
|
||||||
<StatsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
|
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} valuePromise={storagePromise} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 flex lg:hidden">
|
<div class="mt-5 flex lg:hidden">
|
||||||
@@ -54,7 +79,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative text-center font-mono text-2xl font-medium">
|
<div class="relative text-center font-mono text-2xl font-medium">
|
||||||
<span class="text-light-300">{zeros(stats.photos)}</span><span class="text-primary">{stats.photos}</span>
|
{#await statsPromise}
|
||||||
|
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros(0)}</span>
|
||||||
|
{:then stats}
|
||||||
|
<span class="text-light-300">{zeros(stats.photos)}</span><span class="text-primary">{stats.photos}</span>
|
||||||
|
{:catch}
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">{zeros(0)}</span>
|
||||||
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-x-12">
|
<div class="flex flex-wrap gap-x-12">
|
||||||
@@ -64,7 +95,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative text-center font-mono text-2xl font-medium">
|
<div class="relative text-center font-mono text-2xl font-medium">
|
||||||
<span class="text-light-300">{zeros(stats.videos)}</span><span class="text-primary">{stats.videos}</span>
|
{#await statsPromise}
|
||||||
|
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros(0)}</span>
|
||||||
|
{:then stats}
|
||||||
|
<span class="text-light-300">{zeros(stats.videos)}</span><span class="text-primary">{stats.videos}</span>
|
||||||
|
{:catch}
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">{zeros(0)}</span>
|
||||||
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-x-5">
|
<div class="flex flex-wrap gap-x-5">
|
||||||
@@ -74,11 +111,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative flex text-center font-mono text-2xl font-medium">
|
<div class="relative flex text-center font-mono text-2xl font-medium">
|
||||||
<span class="text-light-300">{zeros(statsUsage)}</span><span class="text-primary">{statsUsage}</span>
|
{#await statsPromise}
|
||||||
|
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros(0)}</span>
|
||||||
|
{:then stats}
|
||||||
|
{@const storageUsageWithUnit = getStorageUsageWithUnit(stats.usage)}
|
||||||
|
<span class="text-light-300">{zeros(storageUsageWithUnit[0])}</span><span class="text-primary"
|
||||||
|
>{storageUsageWithUnit[0]}</span
|
||||||
|
>
|
||||||
|
|
||||||
<div class="absolute -end-1.5 -bottom-4">
|
<div class="absolute -end-1.5 -bottom-4">
|
||||||
<Code color="muted" class="text-xs font-light font-mono">{statsUsageUnit}</Code>
|
<Code color="muted" class="text-xs font-light font-mono">{storageUsageWithUnit[1]}</Code>
|
||||||
</div>
|
</div>
|
||||||
|
{:catch}
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">{zeros(0)}</span>
|
||||||
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,34 +141,99 @@
|
|||||||
<TableHeading class="w-1/4">{$t('usage')}</TableHeading>
|
<TableHeading class="w-1/4">{$t('usage')}</TableHeading>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody class="block max-h-80 overflow-y-auto">
|
<TableBody class="block max-h-80 overflow-y-auto">
|
||||||
{#each stats.usageByUser as user (user.userId)}
|
{#each users as user (user.id)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class="w-1/4">{user.userName}</TableCell>
|
<TableCell class="w-1/4">{user.name}</TableCell>
|
||||||
<TableCell class="w-1/4">
|
{#await getUserStatsPromise(user.id)}
|
||||||
{user.photos.toLocaleString($locale)} (<FormatBytes bytes={user.usagePhotos} />)</TableCell
|
{@render placeholder()}
|
||||||
>
|
{:then userStats}
|
||||||
<TableCell class="w-1/4">
|
{#if userStats}
|
||||||
{user.videos.toLocaleString($locale)} (<FormatBytes bytes={user.usageVideos} precision={0} />)</TableCell
|
<TableCell class="w-1/4">
|
||||||
>
|
{userStats.photos.toLocaleString($locale)} (<FormatBytes bytes={userStats.usagePhotos} />)</TableCell
|
||||||
<TableCell class="w-1/4">
|
>
|
||||||
<FormatBytes bytes={user.usage} precision={0} />
|
<TableCell class="w-1/4">
|
||||||
{#if user.quotaSizeInBytes !== null}
|
{userStats.videos.toLocaleString($locale)} (<FormatBytes
|
||||||
/ <FormatBytes bytes={user.quotaSizeInBytes} precision={0} />
|
bytes={userStats.usageVideos}
|
||||||
|
precision={0}
|
||||||
|
/>)</TableCell
|
||||||
|
>
|
||||||
|
<TableCell class="w-1/4">
|
||||||
|
<FormatBytes bytes={userStats.usage} precision={0} />
|
||||||
|
{#if userStats.quotaSizeInBytes !== null}
|
||||||
|
/ <FormatBytes bytes={userStats.quotaSizeInBytes} precision={0} />
|
||||||
|
{/if}
|
||||||
|
<span class="text-primary">
|
||||||
|
{#if userStats.quotaSizeInBytes !== null && userStats.quotaSizeInBytes >= 0}
|
||||||
|
({(userStats.quotaSizeInBytes === 0
|
||||||
|
? 1
|
||||||
|
: userStats.usage / userStats.quotaSizeInBytes
|
||||||
|
).toLocaleString($locale, {
|
||||||
|
style: 'percent',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})})
|
||||||
|
{:else}
|
||||||
|
({$t('unlimited')})
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
{:else}
|
||||||
|
{@render placeholder()}
|
||||||
{/if}
|
{/if}
|
||||||
<span class="text-primary">
|
{:catch}
|
||||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
{@render placeholder()}
|
||||||
({(user.quotaSizeInBytes === 0 ? 1 : user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
|
{/await}
|
||||||
style: 'percent',
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
})})
|
|
||||||
{:else}
|
|
||||||
({$t('unlimited')})
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.skeleton-loader {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: rgba(156, 163, 175, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-loader::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0),
|
||||||
|
rgba(255, 255, 255, 0.8) 50%,
|
||||||
|
rgba(255, 255, 255, 0)
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-position: 200% 0;
|
||||||
|
animation: skeleton-animation 2000ms infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-animation {
|
||||||
|
from {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer-text {
|
||||||
|
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 1) 100%);
|
||||||
|
mask-size: 200% 100%;
|
||||||
|
animation: shimmer 2.25s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
from {
|
||||||
|
mask-position: 200% 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
mask-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -7,13 +7,18 @@
|
|||||||
import { getLibrariesActions, getLibraryActions } from '$lib/services/library.service';
|
import { getLibrariesActions, getLibraryActions } from '$lib/services/library.service';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto, type LibraryStatsResponseDto } from '@immich/sdk';
|
import {
|
||||||
|
getLibrary,
|
||||||
|
getLibraryStatistics,
|
||||||
|
type LibraryResponseDto,
|
||||||
|
type LibraryStatsResponseDto,
|
||||||
|
type UserAdminResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
CommandPaletteDefaultProvider,
|
CommandPaletteDefaultProvider,
|
||||||
Container,
|
Container,
|
||||||
ContextMenuButton,
|
ContextMenuButton,
|
||||||
Link,
|
Link,
|
||||||
LoadingSpinner,
|
|
||||||
MenuItemType,
|
MenuItemType,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -32,37 +37,36 @@
|
|||||||
data: LayoutData;
|
data: LayoutData;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { children, data }: Props = $props();
|
const props: Props = $props();
|
||||||
|
|
||||||
let libraries = $state(data.libraries);
|
let libraries = $state<LibraryResponseDto[]>([]);
|
||||||
let statistics = $state<Record<string, LibraryStatsResponseDto>>({});
|
let statistics = $state<Record<string, LibraryStatsResponseDto>>({});
|
||||||
let owners = $state(data.owners);
|
let owners = $state<Record<string, UserAdminResponseDto>>({});
|
||||||
|
|
||||||
const loadStatistics = async () => {
|
|
||||||
try {
|
|
||||||
statistics = await data.statisticsPromise;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load library statistics:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void loadStatistics();
|
libraries = [...props.data.libraries];
|
||||||
|
owners = { ...props.data.owners };
|
||||||
});
|
});
|
||||||
|
|
||||||
const onLibraryCreate = async (library: LibraryResponseDto) => {
|
const onLibraryCreate = (library: LibraryResponseDto) => {
|
||||||
await goto(Route.viewLibrary(library));
|
void goto(Route.viewLibrary(library));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLibraryUpdate = async (library: LibraryResponseDto) => {
|
const onLibraryUpdate = (library: LibraryResponseDto) => {
|
||||||
const index = libraries.findIndex(({ id }) => id === library.id);
|
const index = libraries.findIndex(({ id }) => id === library.id);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
libraries[index] = await getLibrary({ id: library.id });
|
void Promise.all([getLibrary({ id: library.id }), getLibraryStatistics({ id: library.id })])
|
||||||
statistics[library.id] = await getLibraryStatistics({ id: library.id });
|
.then(([updatedLibrary, updatedStats]) => {
|
||||||
|
libraries[index] = updatedLibrary;
|
||||||
|
statistics[library.id] = updatedStats;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(`Failed to refresh library after update: ${error}`);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLibraryDelete = ({ id }: { id: string }) => {
|
const onLibraryDelete = ({ id }: { id: string }) => {
|
||||||
@@ -92,7 +96,7 @@
|
|||||||
|
|
||||||
<CommandPaletteDefaultProvider name={$t('library')} actions={[Create, ScanAll]} />
|
<CommandPaletteDefaultProvider name={$t('library')} actions={[Create, ScanAll]} />
|
||||||
|
|
||||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[ScanAll, Create]}>
|
<AdminPageLayout breadcrumbs={[{ title: props.data.meta.title }]} actions={[ScanAll, Create]}>
|
||||||
<Container size="large" center class="my-4">
|
<Container size="large" center class="my-4">
|
||||||
<div class="flex flex-col items-center gap-2" in:fade={{ duration: 500 }}>
|
<div class="flex flex-col items-center gap-2" in:fade={{ duration: 500 }}>
|
||||||
{#if libraries.length > 0}
|
{#if libraries.length > 0}
|
||||||
@@ -107,7 +111,6 @@
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{#each libraries as library (library.id + library.name)}
|
{#each libraries as library (library.id + library.name)}
|
||||||
{@const stats = statistics[library.id]}
|
|
||||||
{@const owner = owners[library.id]}
|
{@const owner = owners[library.id]}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class={classes.column1}>
|
<TableCell class={classes.column1}>
|
||||||
@@ -116,29 +119,40 @@
|
|||||||
<TableCell class={classes.column2}>
|
<TableCell class={classes.column2}>
|
||||||
<Link href={Route.viewUser(owner)}>{owner.name}</Link>
|
<Link href={Route.viewUser(owner)}>{owner.name}</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class={classes.column3}>
|
{#await props.data.statisticsPromise}
|
||||||
{#if stats}
|
<TableCell class={classes.column3}>
|
||||||
|
<span class="skeleton-loader inline-block h-4 w-14"></span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class={classes.column4}>
|
||||||
|
<span class="skeleton-loader inline-block h-4 w-14"></span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class={classes.column5}>
|
||||||
|
<span class="skeleton-loader inline-block h-4 w-20"></span>
|
||||||
|
</TableCell>
|
||||||
|
{:then loadedStats}
|
||||||
|
{@const stats = statistics[library.id] || loadedStats[library.id]}
|
||||||
|
<TableCell class={classes.column3}>
|
||||||
{stats.photos.toLocaleString($locale)}
|
{stats.photos.toLocaleString($locale)}
|
||||||
{:else}
|
</TableCell>
|
||||||
<LoadingSpinner />
|
<TableCell class={classes.column4}>
|
||||||
{/if}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class={classes.column4}>
|
|
||||||
{#if stats}
|
|
||||||
{stats.videos.toLocaleString($locale)}
|
{stats.videos.toLocaleString($locale)}
|
||||||
{:else}
|
</TableCell>
|
||||||
<LoadingSpinner />
|
<TableCell class={classes.column5}>
|
||||||
{/if}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class={classes.column5}>
|
|
||||||
{#if stats}
|
|
||||||
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(stats.usage, 0)}
|
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(stats.usage, 0)}
|
||||||
{diskUsage}
|
{diskUsage}
|
||||||
{diskUsageUnit}
|
{diskUsageUnit}
|
||||||
{:else}
|
</TableCell>
|
||||||
<LoadingSpinner />
|
{:catch}
|
||||||
{/if}
|
<TableCell class={classes.column3}>
|
||||||
</TableCell>
|
<span class="skeleton-loader inline-block h-4 w-14"></span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class={classes.column4}>
|
||||||
|
<span class="skeleton-loader inline-block h-4 w-14"></span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class={classes.column5}>
|
||||||
|
<span class="skeleton-loader inline-block h-4 w-20"></span>
|
||||||
|
</TableCell>
|
||||||
|
{/await}
|
||||||
<TableCell class={classes.column6}>
|
<TableCell class={classes.column6}>
|
||||||
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
|
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -155,7 +169,41 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{@render children?.()}
|
{@render props.children?.()}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.skeleton-loader {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: rgba(156, 163, 175, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-loader::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0),
|
||||||
|
rgba(255, 255, 255, 0.8) 50%,
|
||||||
|
rgba(255, 255, 255, 0)
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-position: 200% 0;
|
||||||
|
animation: skeleton-animation 2000ms infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-animation {
|
||||||
|
from {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -14,19 +14,11 @@
|
|||||||
getLibraryExclusionPatternActions,
|
getLibraryExclusionPatternActions,
|
||||||
getLibraryFolderActions,
|
getLibraryFolderActions,
|
||||||
} from '$lib/services/library.service';
|
} from '$lib/services/library.service';
|
||||||
import type { ByteUnit } from '$lib/utils/byte-units';
|
|
||||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||||
|
|
||||||
import type { LibraryResponseDto, LibraryStatsResponseDto } from '@immich/sdk';
|
import type { LibraryResponseDto } from '@immich/sdk';
|
||||||
import { Code, CommandPaletteDefaultProvider, Container, Heading, modalManager } from '@immich/ui';
|
import { Code, CommandPaletteDefaultProvider, Container, Heading, modalManager } from '@immich/ui';
|
||||||
import {
|
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
|
||||||
mdiCameraIris,
|
|
||||||
mdiChartPie,
|
|
||||||
mdiFileDocumentRemoveOutline,
|
|
||||||
mdiFilterMinusOutline,
|
|
||||||
mdiFolderOutline,
|
|
||||||
mdiPlayCircle,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
@@ -36,46 +28,32 @@
|
|||||||
data: LayoutData;
|
data: LayoutData;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { children, data }: Props = $props();
|
let { children, data }: Props = $props();
|
||||||
|
const statisticsPromise = $derived.by(() => data.statisticsPromise);
|
||||||
|
|
||||||
let statistics = $state<LibraryStatsResponseDto | undefined>(undefined);
|
const photosPromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.photos })));
|
||||||
let storageUsage = $state<number | undefined>(undefined);
|
|
||||||
let unit = $state<ByteUnit | undefined>(undefined);
|
|
||||||
|
|
||||||
$effect(() => {
|
const videosPromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.videos })));
|
||||||
if (statistics) {
|
|
||||||
const [usage, u] = getBytesWithUnit(statistics.usage);
|
|
||||||
storageUsage = usage;
|
|
||||||
unit = u;
|
|
||||||
} else {
|
|
||||||
storageUsage = undefined;
|
|
||||||
unit = undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadStatistics = async () => {
|
const usagePromise = $derived.by(() =>
|
||||||
try {
|
statisticsPromise.then((stats) => {
|
||||||
statistics = await data.statisticsPromise;
|
const [value, unit] = getBytesWithUnit(stats.usage);
|
||||||
} catch (error) {
|
return { value, unit };
|
||||||
console.error('Failed to load statistics:', error);
|
}),
|
||||||
}
|
);
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
let updatedLibrary = $state<LibraryResponseDto | undefined>(undefined);
|
||||||
void loadStatistics();
|
const library = $derived.by(() => (updatedLibrary?.id === data.library.id ? updatedLibrary : data.library));
|
||||||
});
|
|
||||||
|
|
||||||
let library = $state(data.library);
|
|
||||||
|
|
||||||
const onLibraryUpdate = (newLibrary: LibraryResponseDto) => {
|
const onLibraryUpdate = (newLibrary: LibraryResponseDto) => {
|
||||||
if (newLibrary.id === library.id) {
|
if (newLibrary.id === library.id) {
|
||||||
library = newLibrary;
|
updatedLibrary = newLibrary;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLibraryDelete = async ({ id }: { id: string }) => {
|
const onLibraryDelete = ({ id }: { id: string }) => {
|
||||||
if (id === library.id) {
|
if (id === library.id) {
|
||||||
await goto(Route.libraries());
|
void goto(Route.libraries());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,9 +72,9 @@
|
|||||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
|
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
|
||||||
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
|
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
|
||||||
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
|
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
|
||||||
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics?.photos} />
|
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
|
||||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics?.videos} />
|
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
|
||||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
|
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} valuePromise={usagePromise} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
|
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
|
||||||
@@ -145,10 +123,6 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</AdminCard>
|
</AdminCard>
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row gap-4">
|
|
||||||
<ServerStatisticsCard icon={mdiFileDocumentRemoveOutline} title={$t('offline')} value={statistics?.offline} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||||
import ServerStatisticsPanel from '$lib/components/server-statistics/ServerStatisticsPanel.svelte';
|
import ServerStatisticsPanel from '$lib/components/server-statistics/ServerStatisticsPanel.svelte';
|
||||||
import { getServerStatistics, type ServerStatsResponseDto } from '@immich/sdk';
|
import { getServerStatistics, type ServerStatsResponseDto } from '@immich/sdk';
|
||||||
import { Container, LoadingSpinner } from '@immich/ui';
|
import { Container } from '@immich/ui';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
@@ -14,20 +14,18 @@
|
|||||||
|
|
||||||
let stats = $state<ServerStatsResponseDto | undefined>(undefined);
|
let stats = $state<ServerStatsResponseDto | undefined>(undefined);
|
||||||
|
|
||||||
const loadStatistics = async () => {
|
const statsPromise = $derived.by(() => {
|
||||||
try {
|
if (stats) {
|
||||||
stats = await data.statsPromise;
|
return Promise.resolve(stats);
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load server statistics:', error);
|
|
||||||
}
|
}
|
||||||
};
|
return data.statsPromise;
|
||||||
|
});
|
||||||
|
|
||||||
const updateStatistics = async () => {
|
const updateStatistics = async () => {
|
||||||
stats = await getServerStatistics();
|
stats = await getServerStatistics();
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void loadStatistics();
|
|
||||||
const interval = setInterval(() => void updateStatistics(), 5000);
|
const interval = setInterval(() => void updateStatistics(), 5000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
@@ -36,10 +34,6 @@
|
|||||||
|
|
||||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
|
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
|
||||||
<Container size="large" center>
|
<Container size="large" center>
|
||||||
{#if stats}
|
<ServerStatisticsPanel {statsPromise} users={data.users} />
|
||||||
<ServerStatisticsPanel {stats} />
|
|
||||||
{:else}
|
|
||||||
<LoadingSpinner />
|
|
||||||
{/if}
|
|
||||||
</Container>
|
</Container>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { authenticate } from '$lib/utils/auth';
|
import { authenticate } from '$lib/utils/auth';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import { getServerStatistics } from '@immich/sdk';
|
import { getServerStatistics, searchUsersAdmin } from '@immich/sdk';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load = (async ({ url }) => {
|
export const load = (async ({ url }) => {
|
||||||
await authenticate(url, { admin: true });
|
await authenticate(url, { admin: true });
|
||||||
const statsPromise = getServerStatistics();
|
const statsPromise = getServerStatistics();
|
||||||
|
const users = await searchUsersAdmin({ withDeleted: false });
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statsPromise,
|
statsPromise,
|
||||||
|
users,
|
||||||
meta: {
|
meta: {
|
||||||
title: $t('server_stats'),
|
title: $t('server_stats'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -123,9 +123,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-span-full">
|
<div class="col-span-full">
|
||||||
<div class="flex flex-col lg:flex-row gap-4 w-full">
|
<div class="flex flex-col lg:flex-row gap-4 w-full">
|
||||||
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={userStatistics.images} />
|
<ServerStatisticsCard
|
||||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={userStatistics.videos} />
|
icon={mdiCameraIris}
|
||||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
|
title={$t('photos')}
|
||||||
|
valuePromise={Promise.resolve({ value: userStatistics.images })}
|
||||||
|
/>
|
||||||
|
<ServerStatisticsCard
|
||||||
|
icon={mdiPlayCircle}
|
||||||
|
title={$t('videos')}
|
||||||
|
valuePromise={Promise.resolve({ value: userStatistics.videos })}
|
||||||
|
/>
|
||||||
|
<ServerStatisticsCard
|
||||||
|
icon={mdiChartPie}
|
||||||
|
title={$t('storage')}
|
||||||
|
valuePromise={Promise.resolve({ value: statsUsage, unit: statsUsageUnit })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user