feat(server, web): quotas (#4471)

* feat: quotas

* chore: open api

* chore: update status box and upload error message

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
cfitzw 2024-01-12 18:43:36 -06:00 committed by GitHub
parent f4edb6c4bd
commit deb1f970a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 646 additions and 118 deletions

View file

@ -3,7 +3,7 @@
import type { ServerStatsResponseDto } from '@api';
import { asByteUnitString, getBytesWithUnit } from '$lib/utils/byte-units';
import StatsCard from './stats-card.svelte';
import { mdiCameraIris, mdiMemory, mdiPlayCircle } from '@mdi/js';
import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
export let stats: ServerStatsResponseDto = {
@ -32,7 +32,7 @@
<div class="mt-5 hidden justify-between lg:flex">
<StatsCard icon={mdiCameraIris} title="PHOTOS" value={stats.photos} />
<StatsCard icon={mdiPlayCircle} title="VIDEOS" value={stats.videos} />
<StatsCard icon={mdiMemory} title="STORAGE" value={statsUsage} unit={statsUsageUnit} />
<StatsCard icon={mdiChartPie} title="STORAGE" value={statsUsage} unit={statsUsageUnit} />
</div>
<div class="mt-5 flex lg:hidden">
<div class="flex flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray">
@ -62,7 +62,7 @@
</div>
<div class="flex flex-wrap gap-x-7">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiMemory} size="25" />
<Icon path={mdiChartPie} size="25" />
<p>STORAGE</p>
</div>
@ -87,7 +87,7 @@
<th class="w-1/4 text-center text-sm font-medium">User</th>
<th class="w-1/4 text-center text-sm font-medium">Photos</th>
<th class="w-1/4 text-center text-sm font-medium">Videos</th>
<th class="w-1/4 text-center text-sm font-medium">Size</th>
<th class="w-1/4 text-center text-sm font-medium">Usage</th>
</tr>
</thead>
<tbody
@ -100,7 +100,16 @@
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.userName}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.photos.toLocaleString($locale)}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.videos.toLocaleString($locale)}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{asByteUnitString(user.usage, $locale)}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">
{asByteUnitString(user.usage, $locale, 0)}
<span class="text-immich-primary dark:text-immich-dark-primary">
{#if user.quotaSizeInBytes}
({((user.usage / user.quotaSizeInBytes) * 100).toFixed(0)}%)
{:else}
(Unlimited)
{/if}
</span>
</td>
</tr>
{/each}
</tbody>

View file

@ -4,6 +4,7 @@
import ImmichLogo from '../shared-components/immich-logo.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte';
import { convertToBytes } from '$lib/utils/byte-converter';
let error: string;
let success: string;
@ -42,6 +43,7 @@
const email = form.get('email');
const password = form.get('password');
const name = form.get('name');
const quotaSize = form.get('quotaSize');
try {
const { status } = await api.userApi.createUser({
@ -49,6 +51,7 @@
email: String(email),
password: String(password),
name: String(name),
quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), 'GiB') : null,
},
});
@ -117,6 +120,11 @@
<input class="immich-form-input" id="name" name="name" type="text" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="quotaSize">Quota Size (GB)</label>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" />
</div>
{#if error}
<p class="ml-4 text-sm text-red-400">{error}</p>
{/if}

View file

@ -4,11 +4,12 @@
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../utils/handle-error';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiAccountEditOutline, mdiClose } from '@mdi/js';
import { AppRoute } from '$lib/constants';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { handleError } from '$lib/utils/handle-error';
import { convertFromBytes, convertToBytes } from '$lib/utils/byte-converter';
export let user: UserResponseDto;
export let canResetPassword = true;
@ -24,6 +25,8 @@
editSuccess: void;
}>();
let quotaSize = user.quotaSizeInBytes ? convertFromBytes(user.quotaSizeInBytes, 'GiB') : null;
const editUser = async () => {
try {
const { id, email, name, storageLabel, externalPath } = user;
@ -34,6 +37,7 @@
name,
storageLabel: storageLabel || '',
externalPath: externalPath || '',
quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), 'GiB') : null,
},
});
@ -97,6 +101,11 @@
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="quotaSize">Quota Size (GB)</label>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="storage-label">Storage Label</label>
<input

View file

@ -1,19 +1,43 @@
<script lang="ts">
import { browser } from '$app/environment';
import Icon from '$lib/components/elements/icon.svelte';
import { locale } from '$lib/stores/preferences.store';
import { websocketStore } from '$lib/stores/websocket';
import { api } from '@api';
import { onDestroy, onMount } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { UserResponseDto, api } from '@api';
import { onMount } from 'svelte';
import { asByteUnitString } from '../../utils/byte-units';
import LoadingSpinner from './loading-spinner.svelte';
import { mdiCloud, mdiDns } from '@mdi/js';
import { mdiChartPie, mdiDns } from '@mdi/js';
import { serverInfoStore } from '$lib/stores/server-info.store';
const { serverVersion, connected } = websocketStore;
let userInfo: UserResponseDto;
let usageClasses = '';
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
$: usedPercentage = Math.round(($serverInfoStore?.diskUseRaw / $serverInfoStore?.diskSizeRaw) * 100);
$: hasQuota = userInfo?.quotaSizeInBytes !== null;
$: availableBytes = (hasQuota ? userInfo?.quotaSizeInBytes : $serverInfoStore.diskSizeRaw) || 0;
$: usedBytes = (hasQuota ? userInfo?.quotaUsageInBytes : $serverInfoStore.diskUseRaw) || 0;
$: usedPercentage = Math.round((usedBytes / availableBytes) * 100);
const onUpdate = () => {
usedPercentage = 81;
usageClasses = getUsageClass();
};
const getUsageClass = () => {
if (usedPercentage >= 95) {
return 'bg-red-500';
}
if (usedPercentage > 80) {
return 'bg-yellow-500';
}
return 'bg-immich-primary dark:bg-immich-dark-primary';
};
$: userInfo && onUpdate();
onMount(async () => {
await refresh();
@ -21,39 +45,33 @@
const refresh = async () => {
try {
const { data } = await api.serverInfoApi.getServerInfo();
$serverInfoStore = data;
[$serverInfoStore, userInfo] = await Promise.all([
api.serverInfoApi.getServerInfo().then(({ data }) => data),
api.userApi.getMyUserInfo().then(({ data }) => data),
]);
} catch (e) {
console.log('Error [StatusBox] [onMount]');
}
};
let interval: number;
if (browser) {
interval = window.setInterval(() => refresh(), 10_000);
}
onDestroy(() => clearInterval(interval));
</script>
<div class="dark:text-immich-dark-fg">
<div class="storage-status grid grid-cols-[64px_auto]">
<div
class="storage-status grid grid-cols-[64px_auto]"
title="Used {asByteUnitString(usedBytes, $locale, 3)} of {asByteUnitString(availableBytes, $locale, 3)}"
>
<div class="pb-[2.15rem] pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0">
<Icon path={mdiCloud} size={'24'} />
<Icon path={mdiChartPie} size="24" />
</div>
<div class="hidden group-hover:sm:block md:block">
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Storage</p>
{#if $serverInfoStore}
<div class="my-2 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<!-- style={`width: ${$downloadAssets[fileName]}%`} -->
<div
class="h-[7px] rounded-full bg-immich-primary dark:bg-immich-dark-primary"
style="width: {usedPercentage}%"
/>
<div class="h-[7px] rounded-full {usageClasses}" style="width: {usedPercentage}%" />
</div>
<p class="text-xs">
{asByteUnitString($serverInfoStore?.diskUseRaw, $locale)} of
{asByteUnitString($serverInfoStore?.diskSizeRaw, $locale)} used
{asByteUnitString(usedBytes, $locale)} of
{asByteUnitString(availableBytes, $locale)} used
</p>
{:else}
<div class="mt-2">
@ -67,7 +85,7 @@
</div>
<div class="server-status grid grid-cols-[64px_auto]">
<div class="pb-11 pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0">
<Icon path={mdiDns} size={'24'} />
<Icon path={mdiDns} size="26" />
</div>
<div class="hidden text-xs group-hover:sm:block md:block">
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Server</p>