feat: add offline library statistics

This commit is contained in:
Jonathan Jogenfors
2026-02-20 23:40:23 +01:00
parent 82c6302549
commit 6982987f3f
14 changed files with 181 additions and 37 deletions

View File

@@ -1,17 +1,20 @@
<script lang="ts">
import { ByteUnit } from '$lib/utils/byte-units';
import { Icon, Text } from '@immich/ui';
import { Icon, LoadingSpinner, Text } from '@immich/ui';
interface Props {
icon: string;
title: string;
value: number;
value?: number;
unit?: ByteUnit | undefined;
}
let { icon, title, value, unit = undefined }: Props = $props();
let { icon, title, value = undefined, unit = undefined }: Props = $props();
const zeros = $derived(() => {
if (value === undefined) {
return '';
}
const maxLength = 13;
const valueLength = value.toString().length;
const zeroLength = maxLength - valueLength;
@@ -27,9 +30,13 @@
</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 value === undefined}
<LoadingSpinner />
{:else}
<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}
{/if}
</div>
</div>

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>
<TableCell class={classes.column3}>
{#if stats}
{stats.photos.toLocaleString($locale)}
{:else}
<LoadingSpinner />
{/if}
</TableCell>
<TableCell class={classes.column4}>
{#if stats}
{stats.videos.toLocaleString($locale)}
{:else}
<LoadingSpinner />
{/if}
</TableCell>
<TableCell class={classes.column5}>
{#if stats}
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(stats.usage, 0)}
{diskUsage}
{diskUsageUnit}
{:else}
<LoadingSpinner />
{/if}
</TableCell>
<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

@@ -14,10 +14,19 @@
getLibraryExclusionPatternActions,
getLibraryFolderActions,
} from '$lib/services/library.service';
import type { ByteUnit } from '$lib/utils/byte-units';
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';
@@ -29,8 +38,32 @@
const { children, data }: Props = $props();
const statistics = data.statistics;
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
let statistics = $state<LibraryStatsResponseDto | undefined>(undefined);
let storageUsage = $state<number | undefined>(undefined);
let unit = $state<ByteUnit | undefined>(undefined);
$effect(() => {
if (statistics) {
const [usage, u] = getBytesWithUnit(statistics.usage);
storageUsage = usage;
unit = u;
} else {
storageUsage = undefined;
unit = undefined;
}
});
const loadStatistics = async () => {
try {
statistics = await data.statisticsPromise;
} catch (error) {
console.error('Failed to load statistics:', error);
}
};
$effect(() => {
void loadStatistics();
});
let library = $state(data.library);
@@ -61,8 +94,8 @@
<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={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} />
</div>
@@ -112,6 +145,10 @@
</tbody>
</table>
</AdminCard>
<div class="flex flex-col lg:flex-row gap-4">
<ServerStatisticsCard icon={mdiFileDocumentRemoveOutline} title={$t('offline')} value={statistics?.offline} />
</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,8 +1,8 @@
<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 { Container } from '@immich/ui';
import { getServerStatistics, type ServerStatsResponseDto } from '@immich/sdk';
import { Container, LoadingSpinner } from '@immich/ui';
import { onMount } from 'svelte';
import type { PageData } from './$types';
@@ -12,13 +12,22 @@
const { data }: Props = $props();
let stats = $state(data.stats);
let stats = $state<ServerStatsResponseDto | undefined>(undefined);
const loadStatistics = async () => {
try {
stats = await data.statsPromise;
} catch (error) {
console.error('Failed to load server statistics:', error);
}
};
const updateStatistics = async () => {
stats = await getServerStatistics();
};
onMount(() => {
void loadStatistics();
const interval = setInterval(() => void updateStatistics(), 5000);
return () => clearInterval(interval);
@@ -27,6 +36,10 @@
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
<Container size="large" center>
<ServerStatisticsPanel {stats} />
{#if stats}
<ServerStatisticsPanel {stats} />
{:else}
<LoadingSpinner />
{/if}
</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'),
},