refactor: server endpoints (#32467)

This commit is contained in:
Jason Rasmussen
2026-10-16 20:51:28 +03:00
committed by Codex
parent 6adebeda0d
commit 2ebe288010
26 changed files with 211 additions and 242 deletions
@@ -1,6 +1,6 @@
<script lang="ts">
import { ByteUnit } from '$lib/utils/byte-units';
import { Icon, Text, type MaybePromise } from '@immich/ui';
import { Icon, Text } from '@immich/ui';
import type { Snippet } from 'svelte';
type ValueData = {
@@ -11,12 +11,12 @@
interface Props {
icon?: string;
title: string;
valuePromise: MaybePromise<ValueData>;
value?: ValueData;
tooltip?: string;
footer?: Snippet;
}
let { icon, title, valuePromise, tooltip, footer }: Props = $props();
let { icon, title, value, tooltip, footer }: Props = $props();
const zeros = (data?: ValueData) => {
let length = 13;
if (data) {
@@ -36,22 +36,18 @@
<Text size="giant" fontWeight="medium" title={tooltip}>{title}</Text>
</div>
{#await valuePromise}
{#if value}
<div class="relative mx-auto font-mono text-2xl font-medium" aria-label="{value.value} {value.unit ?? ''}">
<span class="text-gray-300 dark:text-gray-600">{zeros(value)}</span><span>{value.value}</span>
{#if value.unit}
<code class="font-mono text-base font-normal">{value.unit}</code>
{/if}
</div>
{:else}
<div class="relative mx-auto font-mono text-2xl font-medium" aria-label="0">
<span class="shimmer-text text-gray-300 dark:text-gray-600">{zeros()}</span>
</div>
{:then data}
<div class="relative mx-auto font-mono text-2xl font-medium" aria-label="{data.value} {data.unit ?? ''}">
<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="relative mx-auto font-mono text-2xl font-medium" aria-label="0">
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span>
</div>
{/await}
{@render footer?.()}
</div>
@@ -5,11 +5,10 @@
import AvatarEditModal from '$lib/modals/AvatarEditModal.svelte';
import HelpAndFeedbackModal from '$lib/modals/HelpAndFeedbackModal.svelte';
import { Route } from '$lib/route';
import { userInteraction } from '$lib/stores/user.svelte';
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
import { getAboutInfoQuery } from '$lib/queries/server.query';
import { Button, Icon, IconButton, modalManager } from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query';
import { mdiCog, mdiLogout, mdiPencil, mdiWrench } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import UserAvatar from '../UserAvatar.svelte';
@@ -20,11 +19,8 @@
let { onClose }: Props = $props();
let info: ServerAboutResponseDto | undefined = $state();
onMount(async () => {
info = userInteraction.aboutInfo ?? (await getAboutInfo());
});
const query = createQuery(() => getAboutInfoQuery());
const info = $derived(query.data);
</script>
<div
@@ -2,38 +2,21 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { releaseManager } from '$lib/managers/release-manager.svelte';
import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte';
import { userInteraction } from '$lib/stores/user.svelte';
import { getAboutInfoQuery, getVersionHistoryQuery } from '$lib/queries/server.query';
import { websocketStore } from '$lib/stores/websocket';
import { semverToName } from '$lib/utils';
import { requestServerInfo } from '$lib/utils/auth';
import {
getAboutInfo,
getVersionHistory,
type ReleaseEventV1,
type ServerAboutResponseDto,
type ServerVersionHistoryResponseDto,
} from '@immich/sdk';
import { type ReleaseEventV1 } from '@immich/sdk';
import { Icon, modalManager, Text } from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query';
import { mdiAlert, mdiNewBox } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
const { serverVersion, connected } = websocketStore;
let info: ServerAboutResponseDto | undefined = $state();
let versions: ServerVersionHistoryResponseDto[] = $state([]);
onMount(async () => {
if (userInteraction.aboutInfo && userInteraction.versions && $serverVersion) {
info = userInteraction.aboutInfo;
versions = userInteraction.versions;
return;
}
await requestServerInfo();
[info, versions] = await Promise.all([getAboutInfo(), getVersionHistory()]);
userInteraction.aboutInfo = info;
userInteraction.versions = versions;
});
const aboutQuery = createQuery(() => getAboutInfoQuery());
const info = $derived(aboutQuery.data);
const historyQuery = createQuery(() => getVersionHistoryQuery());
const versions = $derived(historyQuery.data ?? []);
let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich');
let version = $derived($serverVersion ? semverToName($serverVersion) : null);
@@ -1,36 +1,27 @@
<script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import { userInteraction } from '$lib/stores/user.svelte';
import { requestServerInfo } from '$lib/utils/auth';
import { getStorageQuery } from '$lib/queries/server.query';
import { getByteUnitString } from '$lib/utils/byte-units';
import { LoadingSpinner, Meter } from '@immich/ui';
import { onMount } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n';
const query = createQuery(() => getStorageQuery());
const serverInfo = $derived(query.data);
let hasQuota = $derived(authManager.user.quotaSizeInBytes !== null);
let availableBytes = $derived(
(hasQuota && authManager.authenticated
? authManager.user.quotaSizeInBytes
: userInteraction.serverInfo?.diskSizeRaw) || 0,
(hasQuota && authManager.authenticated ? authManager.user.quotaSizeInBytes : serverInfo?.diskSizeRaw) || 0,
);
let usedBytes = $derived(
(hasQuota && authManager.authenticated
? authManager.user.quotaUsageInBytes
: userInteraction.serverInfo?.diskUseRaw) || 0,
(hasQuota && authManager.authenticated ? authManager.user.quotaUsageInBytes : serverInfo?.diskUseRaw) || 0,
);
const thresholds = [
{ from: 0.8, className: 'bg-warning' },
{ from: 0.95, className: 'bg-danger' },
];
onMount(async () => {
if (userInteraction.serverInfo && authManager.authenticated) {
return;
}
await requestServerInfo();
});
</script>
<div
@@ -42,7 +33,7 @@
},
})}
>
{#if userInteraction.serverInfo}
{#if serverInfo}
<Meter
size="tiny"
class="bg-light-200"
+3 -2
View File
@@ -1,5 +1,4 @@
import {
getAboutInfo,
getMyPreferences,
getMyUser,
logout,
@@ -10,6 +9,8 @@ import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { getAboutInfoQuery } from '$lib/queries/server.query';
import { Route } from '$lib/route';
import { isSharedLinkRoute } from '$lib/utils/navigation';
@@ -69,7 +70,7 @@ class AuthManager {
this.isPurchased = true;
} else {
// check server status
const serverInfo = await getAboutInfo().catch(() => {});
const serverInfo = await queryClient.query(getAboutInfoQuery()).catch(() => {});
if (serverInfo?.licensed) {
this.isPurchased = true;
}
+8
View File
@@ -0,0 +1,8 @@
import { getLibraryStatistics } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
export const getLibraryStatisticsQuery = (id: string) =>
queryOptions({
queryKey: ['libraries', id, 'statistics'],
queryFn: ({ signal }) => getLibraryStatistics({ id }, { signal }),
});
+29
View File
@@ -0,0 +1,29 @@
import { getAboutInfo, getServerStatistics, getStorage, getVersionHistory } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
export const getAboutInfoQuery = () =>
queryOptions({
queryKey: ['server', 'about'],
queryFn: ({ signal }) => getAboutInfo({ signal }),
staleTime: Infinity,
});
export const getVersionHistoryQuery = () =>
queryOptions({
queryKey: ['server', 'version-history'],
queryFn: ({ signal }) => getVersionHistory({ signal }),
staleTime: Infinity,
});
export const getStorageQuery = () =>
queryOptions({
queryKey: ['server', 'storage'],
queryFn: ({ signal }) => getStorage({ signal }),
staleTime: Infinity,
});
export const getServerStatisticsQuery = () =>
queryOptions({
queryKey: ['server', 'statistics'],
queryFn: ({ signal }) => getServerStatistics({ signal }),
});
+1 -12
View File
@@ -1,23 +1,12 @@
import type {
AlbumResponseDto,
ServerAboutResponseDto,
ServerStorageResponseDto,
ServerVersionHistoryResponseDto,
} from '@immich/sdk';
import type { AlbumResponseDto } from '@immich/sdk';
import { eventManager } from '$lib/managers/event-manager.svelte';
interface UserInteractions {
recentAlbums?: AlbumResponseDto[];
versions?: ServerVersionHistoryResponseDto[];
aboutInfo?: ServerAboutResponseDto;
serverInfo?: ServerStorageResponseDto;
}
const defaultUserInteraction: UserInteractions = {
recentAlbums: undefined,
versions: undefined,
aboutInfo: undefined,
serverInfo: undefined,
};
export const userInteraction = $state<UserInteractions>(defaultUserInteraction);
+8 -1
View File
@@ -8,6 +8,7 @@ import {
type SyncAssetEditV1,
type SyncAssetV2,
} from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { io, type Socket } from 'socket.io-client';
import { get, writable } from 'svelte/store';
import { page } from '$app/state';
@@ -69,7 +70,13 @@ websocket
websocketStore.connected.set(true);
})
.on('disconnect', () => websocketStore.connected.set(false))
.on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
.on('on_server_version', (serverVersion) => {
const previous = get(websocketStore.serverVersion);
websocketStore.serverVersion.set(serverVersion);
if (previous && !isEqual(previous, serverVersion)) {
void queryClient.invalidateQueries({ queryKey: ['server'] });
}
})
.on(
'on_config_update',
() => void queryClient.invalidateQueries({ queryKey: getConfigQuery().queryKey, exact: true }),
-11
View File
@@ -1,9 +1,7 @@
import { getStorage } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { DateTime } from 'luxon';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Route } from '$lib/route';
import { userInteraction } from '$lib/stores/user.svelte';
export interface AuthOptions {
admin?: true;
@@ -27,15 +25,6 @@ export const authenticate = async (url: URL, options?: AuthOptions) => {
}
};
export const requestServerInfo = async () => {
if (!authManager.authenticated) {
return;
}
const data = await getStorage();
userInteraction.serverInfo = data;
};
export const getAccountAge = (): number => {
if (!authManager.authenticated) {
return 0;
+10 -1
View File
@@ -3,6 +3,8 @@ import { PUBLIC_IMMICH_BUY_HOST, PUBLIC_IMMICH_PAY_HOST } from '$env/static/publ
import type { ImmichProduct } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { getAboutInfoQuery } from '$lib/queries/server.query';
export const activateProduct = async (licenseKey: string, activationKey: string): Promise<LicenseResponseDto> => {
// TODO is this needed?
@@ -11,7 +13,14 @@ export const activateProduct = async (licenseKey: string, activationKey: string)
const isServerActivation = authManager.user.isAdmin && licenseKey.search('IMSV') !== -1;
const licenseKeyDto = { licenseKey, activationKey };
// Send server key to user activation if user is not admin
return isServerActivation ? setServerLicense({ licenseKeyDto }) : setUserLicense({ licenseKeyDto });
if (!isServerActivation) {
return setUserLicense({ licenseKeyDto });
}
const license = await setServerLicense({ licenseKeyDto });
// contains license status
await queryClient.invalidateQueries({ queryKey: getAboutInfoQuery().queryKey });
return license;
};
export const getActivationKey = async (licenseKey: string): Promise<string> => {
@@ -5,13 +5,14 @@
import SettingSwitch from '$lib/components/shared-components/settings/SettingSwitch.svelte';
import { dateFormats } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAboutInfoQuery } from '$lib/queries/server.query';
import { queryClient } from '$lib/queries/client';
import { locale } from '$lib/stores/preferences.store';
import { handleError } from '$lib/utils/handle-error';
import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
import {
deleteUserLicense as deleteIndividualProductKey,
deleteServerLicense as deleteServerProductKey,
getAboutInfo,
getMyUser,
getServerLicense,
isHttpError,
@@ -26,7 +27,7 @@
let serverPurchaseInfo: LicenseResponseDto | null = $state(null);
const checkPurchaseInfo = async () => {
const serverInfo = await getAboutInfo();
const serverInfo = await queryClient.query(getAboutInfoQuery());
isServerProduct = serverInfo.licensed;
const response = await getMyUser();
@@ -90,6 +91,8 @@
}
await deleteServerProductKey();
// contains license status
await queryClient.invalidateQueries({ queryKey: getAboutInfoQuery().queryKey });
authManager.isPurchased = false;
} catch (error) {
handleError(error, $t('errors.failed_to_remove_product_key'));
@@ -1,12 +1,11 @@
import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk';
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { LayoutLoad } from './$types';
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();
@@ -8,6 +8,8 @@
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import { queryClient } from '$lib/queries/client';
import { getLibraryStatisticsQuery } from '$lib/queries/library.query';
import { Route } from '$lib/route';
import {
getLibraryActions,
@@ -16,6 +18,7 @@
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { Code, CommandPaletteDefaultProvider, Container, Heading, modalManager } from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -28,20 +31,25 @@
const { children, data }: Props = $props();
const photosPromise = $derived(data.statisticsPromise.then((stats) => ({ value: stats.photos })));
const videosPromise = $derived(data.statisticsPromise.then((stats) => ({ value: stats.videos })));
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 query = createQuery(() => getLibraryStatisticsQuery(library.id));
const stats = $derived(query.data);
const photos = $derived(stats && { value: stats.photos });
const videos = $derived(stats && { value: stats.videos });
const usage = $derived.by(() => {
if (!stats) {
return;
}
const [value, unit] = getBytesWithUnit(stats.usage);
return { value, unit };
});
const onLibraryUpdate = async () => {
await invalidate('app:library');
await queryClient.invalidateQueries({ queryKey: getLibraryStatisticsQuery(library.id).queryKey });
};
const onLibraryDelete = async ({ id }: { id: string }) => {
if (id === library.id) {
@@ -64,9 +72,9 @@
<div class="grid w-full grid-cols-1 gap-4 lg:grid-cols-2">
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
<div class="col-span-full flex flex-col gap-4 lg:flex-row">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} valuePromise={usagePromise} />
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={photos} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={usage} />
</div>
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
@@ -1,4 +1,4 @@
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { getLibrary, type LibraryResponseDto } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { Route } from '$lib/route';
import { authenticate } from '$lib/utils/auth';
@@ -17,12 +17,10 @@ export const load = (async ({ params: { id }, url, depends }) => {
redirect(307, Route.libraries());
}
const statisticsPromise = getLibraryStatistics({ id });
const $t = await getFormatter();
return {
library,
statisticsPromise,
meta: {
title: $t('admin.library_details'),
},
@@ -143,7 +143,7 @@
<ServerStatisticsCard
title={$t(getReportTypeTranslation(reportType))}
tooltip={$t(getReportTypeDescriptionKey(reportType))}
valuePromise={{ value: integrityReport[reportType] }}
value={{ value: integrityReport[reportType] }}
>
{#snippet footer()}
<HStack gap={1} class="justify-between">
+1 -2
View File
@@ -1,13 +1,12 @@
import { getQueue, getQueueJobs, QueueJobStatus } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { fromQueueSlug, Route } from '$lib/route';
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url, { admin: true });
await requestServerInfo();
const name = fromQueueSlug(params.name);
if (!name) {
@@ -1,9 +1,7 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import ServerStatisticsPanel from './ServerStatisticsPanel.svelte';
import { getServerStatistics, type ServerStatsResponseDto } from '@immich/sdk';
import { Container } from '@immich/ui';
import { onMount } from 'svelte';
import type { PageData } from './$types';
type Props = {
@@ -11,29 +9,10 @@
};
const { data }: Props = $props();
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();
};
onMount(() => {
const interval = setInterval(() => void updateStatistics(), 5000);
return () => clearInterval(interval);
});
</script>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
<Container size="large" center>
<ServerStatisticsPanel {statsPromise} users={data.users} />
<ServerStatisticsPanel users={data.users} />
</Container>
</AdminPageLayout>
+5 -3
View File
@@ -1,16 +1,18 @@
import { getServerStatistics, searchUsersAdmin } from '@immich/sdk';
import { searchUsersAdmin } from '@immich/sdk';
import { noop } from 'lodash-es';
import { queryClient } from '$lib/queries/client';
import { getServerStatisticsQuery } from '$lib/queries/server.query';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const statsPromise = getServerStatistics();
void queryClient.query(getServerStatisticsQuery()).catch(noop);
const users = await searchUsersAdmin({ withDeleted: false });
const $t = await getFormatter();
return {
statsPromise,
users,
meta: {
title: $t('server_stats'),
@@ -1,8 +1,9 @@
<script lang="ts">
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import { getServerStatisticsQuery } from '$lib/queries/server.query';
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { ServerStatsResponseDto, UserAdminResponseDto } from '@immich/sdk';
import type { UserAdminResponseDto } from '@immich/sdk';
import {
Code,
FormatBytes,
@@ -15,33 +16,34 @@
TableRow,
Text,
} from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query';
import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
statsPromise: Promise<ServerStatsResponseDto>;
users: UserAdminResponseDto[];
};
const { statsPromise, users }: Props = $props();
const { 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 query = createQuery(() => ({ ...getServerStatisticsQuery(), refetchInterval: 5000 }));
const stats = $derived(query.data);
const getStorageUsageWithUnit = (usage: number) => {
const TiB = 1024 ** 4;
return getBytesWithUnit(usage, usage > TiB ? 2 : 0);
};
const photos = $derived(stats && { value: stats.photos });
const videos = $derived(stats && { value: stats.videos });
const storage = $derived.by(() => {
if (!stats) {
return;
}
const [value, unit] = getStorageUsageWithUnit(stats.usage);
return { value, unit };
});
const zeros = (value: number, maxLength = 13) => {
const valueLength = value.toString().length;
const zeroLength = maxLength - valueLength;
@@ -49,10 +51,7 @@
return '0'.repeat(zeroLength);
};
const getUserStatsPromise = async (userId: string) => {
const stats = await statsPromise;
return stats.usageByUser.find((userStats) => userStats.userId === userId);
};
const getUserStats = (userId: string) => stats?.usageByUser.find((userStats) => userStats.userId === userId);
</script>
{#snippet placeholder()}
@@ -66,9 +65,9 @@
<Text class="mb-2" fontWeight="medium">{$t('total_usage')}</Text>
<div class="hidden justify-between gap-4 lg:flex">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} valuePromise={storagePromise} />
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={photos} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={storage} />
</div>
<div class="mt-5 flex lg:hidden">
@@ -80,13 +79,11 @@
</div>
<div class="relative text-center font-mono text-2xl font-medium">
{#await statsPromise}
<span class="shimmer-text text-gray-300 dark:text-gray-600">{zeros(0)}</span>
{:then stats}
{#if 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}
{:else}
<span class="shimmer-text text-gray-300 dark:text-gray-600">{zeros(0)}</span>
{/if}
</div>
</div>
<div class="flex flex-wrap gap-x-12">
@@ -96,13 +93,11 @@
</div>
<div class="relative text-center font-mono text-2xl font-medium">
{#await statsPromise}
<span class="shimmer-text text-gray-300 dark:text-gray-600">{zeros(0)}</span>
{:then stats}
{#if 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}
{:else}
<span class="shimmer-text text-gray-300 dark:text-gray-600">{zeros(0)}</span>
{/if}
</div>
</div>
<div class="flex flex-wrap gap-x-5">
@@ -112,9 +107,7 @@
</div>
<div class="relative flex text-center font-mono text-2xl font-medium">
{#await statsPromise}
<span class="shimmer-text text-gray-300 dark:text-gray-600">{zeros(0)}</span>
{:then stats}
{#if stats}
{@const storageUsageWithUnit = getStorageUsageWithUnit(stats.usage)}
<span class="text-light-300">{zeros(storageUsageWithUnit[0])}</span><span class="text-primary"
>{storageUsageWithUnit[0]}</span
@@ -123,9 +116,9 @@
<div class="absolute -inset-e-1.5 -bottom-4">
<Code color="muted" class="font-mono text-xs font-light">{storageUsageWithUnit[1]}</Code>
</div>
{:catch}
<span class="text-gray-300 dark:text-gray-600">{zeros(0)}</span>
{/await}
{:else}
<span class="shimmer-text text-gray-300 dark:text-gray-600">{zeros(0)}</span>
{/if}
</div>
</div>
</div>
@@ -145,9 +138,7 @@
{#each users as user (user.id)}
<TableRow>
<TableCell class="w-1/4">{user.name}</TableCell>
{#await getUserStatsPromise(user.id)}
{@render placeholder()}
{:then userStats}
{@const userStats = getUserStats(user.id)}
{#if userStats}
<TableCell class="w-1/4">
{userStats.photos.toLocaleString($locale)} (<FormatBytes bytes={userStats.usagePhotos} />)</TableCell
@@ -180,7 +171,6 @@
{:else}
{@render placeholder()}
{/if}
{/await}
</TableRow>
{/each}
</TableBody>
@@ -18,7 +18,7 @@
let { config = $bindable() }: Props = $props();
const saved = createDefinedQuery(getConfigQuery());
const query = createDefinedQuery(getConfigQuery());
let loadingPreview = $state(false);
@@ -56,7 +56,7 @@
];
const isEdited = (templateKey: keyof AdminConfigTemplateEmailsDto) =>
config.templates.email[templateKey] !== saved.data.templates.email[templateKey];
config.templates.email[templateKey] !== query.data.templates.email[templateKey];
const onsubmit = (event: Event) => {
event.preventDefault();
+1 -2
View File
@@ -1,11 +1,10 @@
import { searchUsersAdmin } from '@immich/sdk';
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { LayoutLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
await requestServerInfo();
const users = await searchUsersAdmin({ withDeleted: true });
const $t = await getFormatter();
@@ -3,11 +3,15 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { handleCreateUserAdmin } from '$lib/services/user-admin.service';
import { userInteraction } from '$lib/stores/user.svelte';
import { getStorageQuery } from '$lib/queries/server.query';
import { ByteUnit, convertToBytes } from '$lib/utils/byte-units';
import { Field, FormModal, HelperText, Input, PasswordInput, Stack, Switch } from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n';
const query = createQuery(() => getStorageQuery());
const serverInfo = $derived(query.data);
let success = $state(false);
let email = $state('');
@@ -22,9 +26,7 @@
let isCreatingUser = $state(false);
let quotaSizeInBytes = $derived(quotaSize === null ? null : convertToBytes(Number(quotaSize), ByteUnit.GiB));
let quotaSizeWarning = $derived(
quotaSizeInBytes && userInteraction.serverInfo && quotaSizeInBytes > userInteraction.serverInfo.diskSizeRaw,
);
let quotaSizeWarning = $derived(quotaSizeInBytes && serverInfo && quotaSizeInBytes > serverInfo.diskSizeRaw);
const passwordMismatch = $derived(password !== passwordConfirm && passwordConfirm.length > 0);
const passwordMismatchMessage = $derived(passwordMismatch ? $t('password_does_not_match') : '');
+3 -11
View File
@@ -122,20 +122,12 @@
</div>
<div class="col-span-full">
<div class="flex w-full flex-col gap-4 lg:flex-row">
<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={mdiCameraIris} title={$t('photos')} value={{ value: userStatistics.images }} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={{ value: userStatistics.videos }} />
<ServerStatisticsCard
icon={mdiChartPie}
title={$t('storage')}
valuePromise={Promise.resolve({ value: statsUsage, unit: statsUsageUnit })}
value={{ value: statsUsage, unit: statsUsageUnit }}
/>
</div>
</div>
+1 -2
View File
@@ -2,13 +2,12 @@ import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin,
import { redirect } from '@sveltejs/kit';
import { UUID_REGEX } from '$lib/constants';
import { Route } from '$lib/route';
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { LayoutLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url, { admin: true });
await requestServerInfo();
if (!UUID_REGEX.test(params.id)) {
redirect(307, Route.users());
@@ -3,9 +3,10 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Route } from '$lib/route';
import { handleUpdateUserAdmin } from '$lib/services/user-admin.service';
import { userInteraction } from '$lib/stores/user.svelte';
import { getStorageQuery } from '$lib/queries/server.query';
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
import { Field, FormModal, Input, Link, NumberInput, Switch, Text } from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query';
import { mdiAccountEditOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -16,6 +17,9 @@
let { data }: Props = $props();
const query = createQuery(() => getStorageQuery());
const serverInfo = $derived(query.data);
const user = $derived(data.user);
let { isAdmin, name, email } = $derived(user);
let storageLabel = $derived(user.storageLabel || '');
@@ -28,10 +32,7 @@
const quotaSizeBytes = $derived(typeof quotaSize === 'number' ? convertToBytes(quotaSize, ByteUnit.GiB) : null);
let quotaSizeWarning = $derived(
previousQuota !== quotaSizeBytes &&
!!quotaSizeBytes &&
userInteraction.serverInfo &&
quotaSizeBytes > userInteraction.serverInfo.diskSizeRaw,
previousQuota !== quotaSizeBytes && !!quotaSizeBytes && serverInfo && quotaSizeBytes > serverInfo.diskSizeRaw,
);
const onClose = async () => {