feat(web): lazy load library and server statistics (#26406)

* feat: add offline library statistics

* fix comments

* feat: add offline library statistics

* fix comments

* fix Daniel's comments

* fix Daniels comment 2
This commit is contained in:
Jonathan Jogenfors
2026-04-14 18:54:09 +02:00
committed by GitHub
parent 81780b0cc0
commit 84a1fb27ca
9 changed files with 324 additions and 108 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, invalidate } from '$app/navigation';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
@@ -7,7 +7,7 @@
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 { type LibraryResponseDto } from '@immich/sdk';
import {
CommandPaletteDefaultProvider,
Container,
@@ -33,30 +33,15 @@
let { children, data }: Props = $props();
let libraries = $state(data.libraries);
let statistics = $state(data.statistics);
let owners = $state(data.owners);
let libraries = $derived([...data.libraries]);
let owners = $derived({ ...data.owners });
const onLibraryCreate = async (library: LibraryResponseDto) => {
await goto(Route.viewLibrary(library));
};
const onLibraryUpdate = async (library: LibraryResponseDto) => {
const index = libraries.findIndex(({ id }) => id === library.id);
if (index === -1) {
return;
}
libraries[index] = await getLibrary({ id: library.id });
statistics[library.id] = await getLibraryStatistics({ id: library.id });
};
const onLibraryDelete = ({ id }: { id: string }) => {
libraries = libraries.filter((library) => library.id !== id);
delete statistics[id];
delete owners[id];
};
const onLibraryUpdate = () => invalidate('app:libraries');
const onLibraryDelete = () => invalidate('app:libraries');
const { Create, ScanAll } = $derived(getLibrariesActions($t));
@@ -94,8 +79,6 @@
</TableHeader>
<TableBody>
{#each libraries as library (library.id + library.name)}
{@const { photos, usage, videos } = statistics[library.id]}
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
{@const owner = owners[library.id]}
<TableRow>
<TableCell class={classes.column1}>
@@ -104,9 +87,40 @@
<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>
{#await data.statisticsPromise}
<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 = loadedStats[library.id]}
<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>
{:catch}
<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>
{/await}
<TableCell class={classes.column6}>
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
</TableCell>
@@ -127,3 +141,37 @@
</div>
</Container>
</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>
@@ -3,14 +3,15 @@ import { getFormatter } from '$lib/utils/i18n';
import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk';
import type { LayoutLoad } from './$types';
export const load = (async ({ url }) => {
export const load = (async ({ url, depends }) => {
depends('app:libraries');
await authenticate(url, { admin: true });
await requestServerInfo();
const allUsers = await searchUsersAdmin({ withDeleted: false });
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 +21,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'),
@@ -1,5 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, invalidate } from '$app/navigation';
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
@@ -15,7 +15,6 @@
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { LibraryResponseDto } from '@immich/sdk';
import { Code, CommandPaletteDefaultProvider, Container, Heading, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import type { Snippet } from 'svelte';
@@ -29,16 +28,20 @@
const { children, data }: Props = $props();
const statistics = data.statistics;
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
const photosPromise = $derived(data.statisticsPromise.then((stats) => ({ value: stats.photos })));
let library = $state(data.library);
const videosPromise = $derived(data.statisticsPromise.then((stats) => ({ value: stats.videos })));
const onLibraryUpdate = (newLibrary: LibraryResponseDto) => {
if (newLibrary.id === library.id) {
library = newLibrary;
}
};
const usagePromise = $derived(
data.statisticsPromise.then((stats) => {
const [value, unit] = getBytesWithUnit(stats.usage);
return { value, unit };
}),
);
const library = $derived(data.library);
const onLibraryUpdate = () => invalidate('app:library');
const onLibraryDelete = async ({ id }: { id: string }) => {
if (id === library.id) {
@@ -61,9 +64,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}>
@@ -5,7 +5,8 @@ import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immi
import { redirect } from '@sveltejs/kit';
import type { LayoutLoad } from './$types';
export const load = (async ({ params: { id }, url }) => {
export const load = (async ({ params: { id }, url, depends }) => {
depends('app:library');
await authenticate(url, { admin: true });
let library: LibraryResponseDto;
@@ -16,12 +17,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'),
},
@@ -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} users={data.users} />
</Container>
</AdminPageLayout>
+5 -3
View File
@@ -1,15 +1,17 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getServerStatistics } from '@immich/sdk';
import { getServerStatistics, searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const stats = await getServerStatistics();
const statsPromise = getServerStatistics();
const users = await searchUsersAdmin({ withDeleted: false });
const $t = await getFormatter();
return {
stats,
statsPromise,
users,
meta: {
title: $t('server_stats'),
},
+15 -3
View File
@@ -115,9 +115,21 @@
</div>
<div class="col-span-full">
<div class="flex flex-col lg:flex-row gap-4 w-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={userStatistics.images} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={userStatistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
<ServerStatisticsCard
icon={mdiCameraIris}
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>