mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 11:58:59 +03:00
feat: add offline library statistics
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user