Compare commits

...

2 Commits

Author SHA1 Message Date
Jonathan Jogenfors
024fde9627 fix comments 2026-02-25 14:59:31 +01:00
Jonathan Jogenfors
6982987f3f feat: add offline library statistics 2026-02-21 00:00:49 +01:00
15 changed files with 316 additions and 106 deletions

View File

@@ -13,12 +13,16 @@ part of openapi.api;
class LibraryStatsResponseDto {
/// Returns a new [LibraryStatsResponseDto] instance.
LibraryStatsResponseDto({
this.offline = 0,
this.photos = 0,
this.total = 0,
this.usage = 0,
this.videos = 0,
});
/// Number of offline assets
int offline;
/// Number of photos
int photos;
@@ -33,6 +37,7 @@ class LibraryStatsResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is LibraryStatsResponseDto &&
other.offline == offline &&
other.photos == photos &&
other.total == total &&
other.usage == usage &&
@@ -41,16 +46,18 @@ class LibraryStatsResponseDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(offline.hashCode) +
(photos.hashCode) +
(total.hashCode) +
(usage.hashCode) +
(videos.hashCode);
@override
String toString() => 'LibraryStatsResponseDto[photos=$photos, total=$total, usage=$usage, videos=$videos]';
String toString() => 'LibraryStatsResponseDto[offline=$offline, photos=$photos, total=$total, usage=$usage, videos=$videos]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'offline'] = this.offline;
json[r'photos'] = this.photos;
json[r'total'] = this.total;
json[r'usage'] = this.usage;
@@ -67,6 +74,7 @@ class LibraryStatsResponseDto {
final json = value.cast<String, dynamic>();
return LibraryStatsResponseDto(
offline: mapValueOfType<int>(json, r'offline')!,
photos: mapValueOfType<int>(json, r'photos')!,
total: mapValueOfType<int>(json, r'total')!,
usage: mapValueOfType<int>(json, r'usage')!,
@@ -118,6 +126,7 @@ class LibraryStatsResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'offline',
'photos',
'total',
'usage',

View File

@@ -18264,6 +18264,11 @@
},
"LibraryStatsResponseDto": {
"properties": {
"offline": {
"default": 0,
"description": "Number of offline assets",
"type": "integer"
},
"photos": {
"default": 0,
"description": "Number of photos",
@@ -18287,6 +18292,7 @@
}
},
"required": [
"offline",
"photos",
"total",
"usage",

View File

@@ -1323,6 +1323,8 @@ export type UpdateLibraryDto = {
name?: string;
};
export type LibraryStatsResponseDto = {
/** Number of offline assets */
offline: number;
/** Number of photos */
photos: number;
/** Total number of assets */

View File

@@ -136,6 +136,9 @@ export class LibraryStatsResponseDto {
@ApiProperty({ type: 'integer', description: 'Total number of assets' })
total = 0;
@ApiProperty({ type: 'integer', description: 'Number of offline assets' })
offline = 0;
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage in bytes' })
usage = 0;
}

View File

@@ -36,27 +36,37 @@ select
(
"asset"."type" = $1
and "asset"."visibility" != $2
and "asset"."isOffline" = $3
)
) as "photos",
count(*) filter (
where
(
"asset"."type" = $3
and "asset"."visibility" != $4
"asset"."type" = $4
and "asset"."visibility" != $5
and "asset"."isOffline" = $6
)
) as "videos",
coalesce(sum("asset_exif"."fileSizeInByte"), $5) as "usage"
count(*) filter (
where
(
"asset"."isOffline" = $7
and "asset"."visibility" != $8
)
) as "offline",
coalesce(sum("asset_exif"."fileSizeInByte"), $9) as "usage"
from
"library"
inner join "asset" on "asset"."libraryId" = "library"."id"
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
where
"library"."id" = $6
"library"."id" = $10
group by
"library"."id"
select
0::int as "photos",
0::int as "videos",
0::int as "offline",
0::int as "usage",
0::int as "total"
from

View File

@@ -79,7 +79,11 @@ export class LibraryRepository {
eb.fn
.countAll<number>()
.filterWhere((eb) =>
eb.and([eb('asset.type', '=', AssetType.Image), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
eb.and([
eb('asset.type', '=', AssetType.Image),
eb('asset.visibility', '!=', AssetVisibility.Hidden),
eb('asset.isOffline', '=', false),
]),
)
.as('photos'),
)
@@ -87,10 +91,22 @@ export class LibraryRepository {
eb.fn
.countAll<number>()
.filterWhere((eb) =>
eb.and([eb('asset.type', '=', AssetType.Video), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
eb.and([
eb('asset.type', '=', AssetType.Video),
eb('asset.visibility', '!=', AssetVisibility.Hidden),
eb('asset.isOffline', '=', false),
]),
)
.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'))
.groupBy('library.id')
.where('library.id', '=', id)
@@ -103,6 +119,7 @@ export class LibraryRepository {
.selectFrom('library')
.select(zero.as('photos'))
.select(zero.as('videos'))
.select(zero.as('offline'))
.select(zero.as('usage'))
.select(zero.as('total'))
.where('library.id', '=', id)
@@ -112,6 +129,7 @@ export class LibraryRepository {
return {
photos: stats.photos,
videos: stats.videos,
offline: stats.offline,
usage: stats.usage,
total: stats.photos + stats.videos,
};

View File

@@ -681,12 +681,19 @@ describe(LibraryService.name, () => {
it('should return library statistics', async () => {
const library = factory.library();
mocks.library.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
mocks.library.getStatistics.mockResolvedValue({
photos: 10,
videos: 0,
total: 10,
usage: 1337,
offline: 67,
});
await expect(sut.getStatistics(library.id)).resolves.toEqual({
photos: 10,
videos: 0,
total: 10,
usage: 1337,
offline: 67,
});
expect(mocks.library.getStatistics).toHaveBeenCalledWith(library.id);

View File

@@ -2,20 +2,36 @@
import { ByteUnit } from '$lib/utils/byte-units';
import { Icon, Text } from '@immich/ui';
interface Props {
icon: string;
title: string;
interface ValueData {
value: number;
unit?: ByteUnit | undefined;
}
let { icon, title, value, unit = undefined }: Props = $props();
interface Props {
icon: string;
title: string;
valuePromise: Promise<ValueData>;
}
let { icon, title, valuePromise }: Props = $props();
let isLoading = $state(true);
let data = $state<ValueData | null>(null);
$effect.pre(() => {
isLoading = true;
void valuePromise.then((result) => {
data = result;
isLoading = false;
});
});
const zeros = $derived(() => {
const maxLength = 13;
const valueLength = value.toString().length;
if (!data) {
return '0'.repeat(maxLength);
}
const valueLength = data.value.toString().length;
const zeroLength = maxLength - valueLength;
return '0'.repeat(zeroLength);
});
</script>
@@ -26,10 +42,26 @@
<Text size="giant" fontWeight="medium">{title}</Text>
</div>
<div class="mx-auto font-mono text-2xl font-medium">
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span><span>{value}</span>
{#if unit}
<code class="font-mono text-base font-normal">{unit}</code>
{/if}
<div class="mx-auto font-mono text-2xl font-medium relative">
<span class="text-gray-300 dark:text-gray-600" class:shimmer-text={isLoading}>{zeros()}</span
>{#if !isLoading && data}<span>{data.value}</span>
{#if data.unit}<code class="font-mono text-base font-normal">{data.unit}</code>{/if}{/if}
</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 {
0% {
mask-position: 200% 0;
}
100% {
mask-position: -200% 0;
}
}
</style>

View File

@@ -19,10 +19,34 @@
import { t } from 'svelte-i18n';
type Props = {
stats: ServerStatsResponseDto;
statsPromise: Promise<ServerStatsResponseDto>;
};
const { stats }: Props = $props();
const { statsPromise }: Props = $props();
let stats = $state<ServerStatsResponseDto | null>(null);
$effect.pre(() => {
void statsPromise.then((result) => {
stats = result;
});
});
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 storageUsageWithUnit = $derived.by(() => {
const TiB = 1024 ** 4;
return !stats ? ([0, ''] as const) : getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0);
});
const zeros = (value: number, maxLength = 13) => {
const valueLength = value.toString().length;
@@ -30,9 +54,6 @@
return '0'.repeat(zeroLength);
};
const TiB = 1024 ** 4;
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
</script>
<div class="flex flex-col gap-5 my-4">
@@ -40,48 +61,52 @@
<Text class="mb-2" fontWeight="medium">{$t('total_usage')}</Text>
<div class="hidden justify-between lg:flex gap-4">
<StatsCard icon={mdiCameraIris} title={$t('photos')} value={stats.photos} />
<StatsCard icon={mdiPlayCircle} title={$t('videos')} value={stats.videos} />
<StatsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
<StatsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
<StatsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
<StatsCard icon={mdiChartPie} title={$t('storage')} valuePromise={storagePromise} />
</div>
<div class="mt-5 flex lg:hidden">
<div class="flex flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
<div class="flex flex-wrap gap-x-12">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiCameraIris} size="25" />
<Text size="medium" fontWeight="medium">{$t('photos')}</Text>
</div>
{#if stats}
<div class="flex flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
<div class="flex flex-wrap gap-x-12">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiCameraIris} size="25" />
<Text size="medium" fontWeight="medium">{$t('photos')}</Text>
</div>
<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>
</div>
</div>
<div class="flex flex-wrap gap-x-12">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiPlayCircle} size="25" />
<Text size="medium" fontWeight="medium">{$t('videos')}</Text>
<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>
</div>
</div>
<div class="flex flex-wrap gap-x-12">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiPlayCircle} size="25" />
<Text size="medium" fontWeight="medium">{$t('videos')}</Text>
</div>
<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>
</div>
</div>
<div class="flex flex-wrap gap-x-5">
<div class="flex flex-1 flex-nowrap place-items-center gap-4 text-primary">
<Icon icon={mdiChartPie} size="25" />
<Text size="medium" fontWeight="medium">{$t('storage')}</Text>
<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>
</div>
</div>
<div class="flex flex-wrap gap-x-5">
<div class="flex flex-1 flex-nowrap place-items-center gap-4 text-primary">
<Icon icon={mdiChartPie} size="25" />
<Text size="medium" fontWeight="medium">{$t('storage')}</Text>
</div>
<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>
<div class="relative flex text-center font-mono text-2xl font-medium">
<span class="text-light-300">{zeros(storageUsageWithUnit[0])}</span><span class="text-primary"
>{storageUsageWithUnit[0]}</span
>
<div class="absolute -end-1.5 -bottom-4">
<Code color="muted" class="text-xs font-light font-mono">{statsUsageUnit}</Code>
<div class="absolute -end-1.5 -bottom-4">
<Code color="muted" class="text-xs font-light font-mono">{storageUsageWithUnit[1]}</Code>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
@@ -95,34 +120,69 @@
<TableHeading class="w-1/4">{$t('usage')}</TableHeading>
</TableHeader>
<TableBody class="block max-h-80 overflow-y-auto">
{#each stats.usageByUser as user (user.userId)}
<TableRow>
<TableCell class="w-1/4">{user.userName}</TableCell>
<TableCell class="w-1/4">
{user.photos.toLocaleString($locale)} (<FormatBytes bytes={user.usagePhotos} />)</TableCell
>
<TableCell class="w-1/4">
{user.videos.toLocaleString($locale)} (<FormatBytes bytes={user.usageVideos} precision={0} />)</TableCell
>
<TableCell class="w-1/4">
<FormatBytes bytes={user.usage} precision={0} />
{#if user.quotaSizeInBytes !== null}
/ <FormatBytes bytes={user.quotaSizeInBytes} precision={0} />
{/if}
<span class="text-primary">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
({(user.quotaSizeInBytes === 0 ? 1 : user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
style: 'percent',
maximumFractionDigits: 0,
})})
{:else}
({$t('unlimited')})
{#if stats}
{#each stats.usageByUser as user (user.userId)}
<TableRow>
<TableCell class="w-1/4">{user.userName}</TableCell>
<TableCell class="w-1/4">
{user.photos.toLocaleString($locale)} (<FormatBytes bytes={user.usagePhotos} />)</TableCell
>
<TableCell class="w-1/4">
{user.videos.toLocaleString($locale)} (<FormatBytes
bytes={user.usageVideos}
precision={0}
/>)</TableCell
>
<TableCell class="w-1/4">
<FormatBytes bytes={user.usage} precision={0} />
{#if user.quotaSizeInBytes !== null}
/ <FormatBytes bytes={user.quotaSizeInBytes} precision={0} />
{/if}
</span>
</TableCell>
</TableRow>
{/each}
<span class="text-primary">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
({(user.quotaSizeInBytes === 0 ? 1 : user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
style: 'percent',
maximumFractionDigits: 0,
})})
{:else}
({$t('unlimited')})
{/if}
</span>
</TableCell>
</TableRow>
{/each}
{:else}
{#each Array(5) as _, i (i)}
<TableRow>
<TableCell class="w-1/4"><span class="loading-shimmer inline-block h-4 w-20 rounded" /></TableCell>
<TableCell class="w-1/4"><span class="loading-shimmer inline-block h-4 w-16 rounded" /></TableCell>
<TableCell class="w-1/4"><span class="loading-shimmer inline-block h-4 w-16 rounded" /></TableCell>
<TableCell class="w-1/4"><span class="loading-shimmer inline-block h-4 w-24 rounded" /></TableCell>
</TableRow>
{/each}
{/if}
</TableBody>
</Table>
</div>
</div>
<style>
.loading-shimmer {
background: linear-gradient(90deg, rgb(107, 114, 128) 0%, rgb(75, 85, 99) 50%, rgb(107, 114, 128) 100%);
background-size: 200% 100%;
animation: shimmer-load 1.5s infinite linear;
}
:global(.dark) .loading-shimmer {
background: linear-gradient(90deg, rgb(63, 63, 70) 0%, rgb(48, 48, 54) 50%, rgb(63, 63, 70) 100%);
}
@keyframes shimmer-load {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View File

@@ -7,12 +7,13 @@
import { getLibrariesActions, getLibraryActions } from '$lib/services/library.service';
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto, type LibraryStatsResponseDto } from '@immich/sdk';
import {
CommandPaletteDefaultProvider,
Container,
ContextMenuButton,
Link,
LoadingSpinner,
MenuItemType,
Table,
TableBody,
@@ -34,9 +35,21 @@
let { children, data }: Props = $props();
let libraries = $state(data.libraries);
let statistics = $state(data.statistics);
let statistics = $state<Record<string, LibraryStatsResponseDto>>({});
let owners = $state(data.owners);
const loadStatistics = async () => {
try {
statistics = await data.statisticsPromise;
} catch (error) {
console.error('Failed to load library statistics:', error);
}
};
$effect(() => {
void loadStatistics();
});
const onLibraryCreate = async (library: LibraryResponseDto) => {
await goto(Route.viewLibrary(library));
};
@@ -94,8 +107,7 @@
</TableHeader>
<TableBody>
{#each libraries as library (library.id + library.name)}
{@const { photos, usage, videos } = statistics[library.id]}
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
{@const stats = statistics[library.id]}
{@const owner = owners[library.id]}
<TableRow>
<TableCell class={classes.column1}>
@@ -104,9 +116,29 @@
<TableCell class={classes.column2}>
<Link href={Route.viewUser(owner)}>{owner.name}</Link>
</TableCell>
<TableCell class={classes.column3}>{photos.toLocaleString($locale)}</TableCell>
<TableCell class={classes.column4}>{videos.toLocaleString($locale)}</TableCell>
<TableCell class={classes.column5}>{diskUsage} {diskUsageUnit}</TableCell>
{#if stats}
<TableCell class={classes.column3}>
{stats.photos.toLocaleString($locale)}
</TableCell>
<TableCell class={classes.column4}>
{stats.videos.toLocaleString($locale)}
</TableCell>
<TableCell class={classes.column5}>
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(stats.usage, 0)}
{diskUsage}
{diskUsageUnit}
</TableCell>
{:else}
<TableCell class={classes.column3}>
<LoadingSpinner />
</TableCell>
<TableCell class={classes.column4}>
<LoadingSpinner />
</TableCell>
<TableCell class={classes.column5}>
<LoadingSpinner />
</TableCell>
{/if}
<TableCell class={classes.column6}>
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
</TableCell>

View File

@@ -10,7 +10,7 @@ export const load = (async ({ url }) => {
const $t = await getFormatter();
const libraries = await getAllLibraries();
const statistics = await Promise.all(
const statisticsPromise = Promise.all(
libraries.map(async ({ id }) => [id, await getLibraryStatistics({ id })] as const),
);
const owners = await Promise.all(
@@ -20,7 +20,7 @@ export const load = (async ({ url }) => {
return {
allUsers,
libraries,
statistics: Object.fromEntries(statistics),
statisticsPromise: statisticsPromise.then((stats) => Object.fromEntries(stats)),
owners: Object.fromEntries(owners),
meta: {
title: $t('external_libraries'),

View File

@@ -15,9 +15,17 @@
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { LibraryResponseDto } from '@immich/sdk';
import type { LibraryResponseDto, LibraryStatsResponseDto } from '@immich/sdk';
import { Code, CommandPaletteDefaultProvider, Container, Heading, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import {
mdiCameraIris,
mdiChartPie,
mdiFileDocumentRemoveOutline,
mdiFilterMinusOutline,
mdiFolderOutline,
mdiPlayCircle,
} from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { LayoutData } from './$types';
@@ -27,16 +35,28 @@
data: LayoutData;
};
const { children, data }: Props = $props();
let { children, data }: Props = $props();
const statisticsPromise = $derived.by(() => data.statisticsPromise as Promise<LibraryStatsResponseDto>);
const statistics = data.statistics;
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
const photosPromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.photos })));
let library = $state(data.library);
const videosPromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.videos })));
const usagePromise = $derived.by(() =>
statisticsPromise.then((stats) => {
const [value, unit] = getBytesWithUnit(stats.usage);
return { value, unit };
}),
);
const offlinePromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.offline })));
let updatedLibrary = $state<LibraryResponseDto | undefined>(undefined);
const library = $derived.by(() => (updatedLibrary?.id === data.library.id ? updatedLibrary : data.library));
const onLibraryUpdate = (newLibrary: LibraryResponseDto) => {
if (newLibrary.id === library.id) {
library = newLibrary;
updatedLibrary = newLibrary;
}
};
@@ -61,9 +81,9 @@
<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>
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics.photos} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} valuePromise={usagePromise} />
</div>
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
@@ -112,6 +132,10 @@
</tbody>
</table>
</AdminCard>
<div class="flex flex-col lg:flex-row gap-4">
<ServerStatisticsCard icon={mdiFileDocumentRemoveOutline} title={$t('offline')} valuePromise={offlinePromise} />
</div>
</div>
{@render children?.()}
</Container>

View File

@@ -16,12 +16,12 @@ export const load = (async ({ params: { id }, url }) => {
redirect(307, Route.libraries());
}
const statistics = await getLibraryStatistics({ id });
const statisticsPromise = getLibraryStatistics({ id });
const $t = await getFormatter();
return {
library,
statistics,
statisticsPromise,
meta: {
title: $t('admin.library_details'),
},

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import ServerStatisticsPanel from '$lib/components/server-statistics/ServerStatisticsPanel.svelte';
import { getServerStatistics } from '@immich/sdk';
import { getServerStatistics, type ServerStatsResponseDto } from '@immich/sdk';
import { Container } from '@immich/ui';
import { onMount } from 'svelte';
import type { PageData } from './$types';
@@ -12,7 +12,14 @@
const { data }: Props = $props();
let stats = $state(data.stats);
let stats = $state<ServerStatsResponseDto | undefined>(undefined);
const statsPromise = $derived.by(() => {
if (stats) {
return Promise.resolve(stats);
}
return data.statsPromise;
});
const updateStatistics = async () => {
stats = await getServerStatistics();
@@ -27,6 +34,6 @@
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
<Container size="large" center>
<ServerStatisticsPanel {stats} />
<ServerStatisticsPanel {statsPromise} />
</Container>
</AdminPageLayout>

View File

@@ -5,11 +5,11 @@ import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const stats = await getServerStatistics();
const statsPromise = getServerStatistics();
const $t = await getFormatter();
return {
stats,
statsPromise,
meta: {
title: $t('server_stats'),
},