mirror of
https://github.com/immich-app/immich.git
synced 2026-03-12 21:42:54 -07:00
Compare commits
5 Commits
push-lvmzu
...
feat/libra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e064fdbf | ||
|
|
c65bc8762f | ||
|
|
016c338877 | ||
|
|
9663eec7ae | ||
|
|
6982987f3f |
@@ -2,22 +2,27 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
const zeros = $derived(() => {
|
||||
const maxLength = 13;
|
||||
const valueLength = value.toString().length;
|
||||
const zeroLength = maxLength - valueLength;
|
||||
let { icon, title, valuePromise }: Props = $props();
|
||||
const zeros = (data?: ValueData) => {
|
||||
let length = 13;
|
||||
if (data) {
|
||||
const valueLength = data.value.toString().length;
|
||||
length = length - valueLength;
|
||||
}
|
||||
|
||||
return '0'.repeat(zeroLength);
|
||||
});
|
||||
return '0'.repeat(length);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex h-35 w-full flex-col justify-between rounded-3xl bg-subtle text-primary p-5">
|
||||
@@ -26,10 +31,35 @@
|
||||
<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>
|
||||
{#await valuePromise}
|
||||
<div class="mx-auto font-mono text-2xl font-medium relative">
|
||||
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros()}</span>
|
||||
</div>
|
||||
{:then data}
|
||||
<div class="mx-auto font-mono text-2xl font-medium relative">
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros(data)}</span><span>{data.value}</span>
|
||||
{#if data.unit}<code class="font-mono text-base font-normal">{data.unit}</code>{/if}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import type { ServerStatsResponseDto } from '@immich/sdk';
|
||||
import type { ServerStatsResponseDto, UserAdminResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Code,
|
||||
FormatBytes,
|
||||
@@ -19,10 +19,28 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
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 valueLength = value.toString().length;
|
||||
@@ -31,18 +49,25 @@
|
||||
return '0'.repeat(zeroLength);
|
||||
};
|
||||
|
||||
const TiB = 1024 ** 4;
|
||||
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
|
||||
const getUserStatsPromise = (userId: string) => {
|
||||
return statsPromise.then((stats) => stats.usageByUser.find((userStats) => userStats.userId === userId));
|
||||
};
|
||||
</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>
|
||||
<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} />
|
||||
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
|
||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
|
||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} valuePromise={storagePromise} />
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex lg:hidden">
|
||||
@@ -54,7 +79,13 @@
|
||||
</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>
|
||||
{#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 class="flex flex-wrap gap-x-12">
|
||||
@@ -64,7 +95,13 @@
|
||||
</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>
|
||||
{#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 class="flex flex-wrap gap-x-5">
|
||||
@@ -74,11 +111,20 @@
|
||||
</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>
|
||||
{#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">
|
||||
<Code color="muted" class="text-xs font-light font-mono">{statsUsageUnit}</Code>
|
||||
</div>
|
||||
<div class="absolute -end-1.5 -bottom-4">
|
||||
<Code color="muted" class="text-xs font-light font-mono">{storageUsageWithUnit[1]}</Code>
|
||||
</div>
|
||||
{:catch}
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros(0)}</span>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,34 +141,99 @@
|
||||
<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)}
|
||||
{#each users as user (user.id)}
|
||||
<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} />
|
||||
<TableCell class="w-1/4">{user.name}</TableCell>
|
||||
{#await getUserStatsPromise(user.id)}
|
||||
{@render placeholder()}
|
||||
{:then userStats}
|
||||
{#if userStats}
|
||||
<TableCell class="w-1/4">
|
||||
{userStats.photos.toLocaleString($locale)} (<FormatBytes bytes={userStats.usagePhotos} />)</TableCell
|
||||
>
|
||||
<TableCell class="w-1/4">
|
||||
{userStats.videos.toLocaleString($locale)} (<FormatBytes
|
||||
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}
|
||||
<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>
|
||||
{:catch}
|
||||
{@render placeholder()}
|
||||
{/await}
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
|
||||
@@ -7,7 +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,
|
||||
type UserAdminResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import {
|
||||
CommandPaletteDefaultProvider,
|
||||
Container,
|
||||
@@ -31,25 +37,36 @@
|
||||
data: LayoutData;
|
||||
};
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
const props: Props = $props();
|
||||
|
||||
let libraries = $state(data.libraries);
|
||||
let statistics = $state(data.statistics);
|
||||
let owners = $state(data.owners);
|
||||
let libraries = $state<LibraryResponseDto[]>([]);
|
||||
let statistics = $state<Record<string, LibraryStatsResponseDto>>({});
|
||||
let owners = $state<Record<string, UserAdminResponseDto>>({});
|
||||
|
||||
const onLibraryCreate = async (library: LibraryResponseDto) => {
|
||||
await goto(Route.viewLibrary(library));
|
||||
$effect(() => {
|
||||
libraries = [...props.data.libraries];
|
||||
owners = { ...props.data.owners };
|
||||
});
|
||||
|
||||
const onLibraryCreate = (library: LibraryResponseDto) => {
|
||||
void goto(Route.viewLibrary(library));
|
||||
};
|
||||
|
||||
const onLibraryUpdate = async (library: LibraryResponseDto) => {
|
||||
const onLibraryUpdate = (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 });
|
||||
void Promise.all([getLibrary({ id: library.id }), 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 }) => {
|
||||
@@ -79,7 +96,7 @@
|
||||
|
||||
<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">
|
||||
<div class="flex flex-col items-center gap-2" in:fade={{ duration: 500 }}>
|
||||
{#if libraries.length > 0}
|
||||
@@ -94,8 +111,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 +119,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 props.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 = statistics[library.id] || 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>
|
||||
@@ -123,7 +169,41 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
{@render props.children?.()}
|
||||
</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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
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';
|
||||
@@ -27,22 +28,32 @@
|
||||
data: LayoutData;
|
||||
};
|
||||
|
||||
const { children, data }: Props = $props();
|
||||
let { children, data }: Props = $props();
|
||||
const statisticsPromise = $derived.by(() => data.statisticsPromise);
|
||||
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const onLibraryDelete = async ({ id }: { id: string }) => {
|
||||
const onLibraryDelete = ({ id }: { id: string }) => {
|
||||
if (id === library.id) {
|
||||
await goto(Route.libraries());
|
||||
void goto(Route.libraries());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,9 +72,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}>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -123,9 +123,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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user