fix comments

This commit is contained in:
Jonathan Jogenfors
2026-02-25 00:39:58 +01:00
parent 6982987f3f
commit 9663eec7ae
14 changed files with 336 additions and 227 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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