feat(backend,frontend): enable quotas support in bucket settings (#64)

This commit is contained in:
Noste
2026-05-23 15:29:04 +02:00
committed by GitHub
parent 36ec8e800e
commit 1f16edd39c
11 changed files with 581 additions and 6 deletions
+5 -3
View File
@@ -20,7 +20,7 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
/>
<div
className={cn(
'relative h-6 w-11 rounded-full border transition-colors',
'relative h-6 w-11 rounded-full border-2 transition-colors',
checked ? 'border-[#ff9329] bg-[#ff9329]' : 'border-[#6b7280] bg-[#6b7280]',
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
'peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
@@ -28,8 +28,10 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
)}
>
<span
className="absolute left-[2px] h-5 w-5 rounded-full bg-white shadow transition-transform"
style={{ top: '50%', transform: `translateY(-50%) translateX(${checked ? '20px' : '0px'})` }}
className={cn(
'absolute top-0 left-0 h-5 w-5 rounded-full bg-white shadow transition-transform',
checked ? 'translate-x-full' : 'translate-x-0'
)}
/>
</div>
</label>
+21
View File
@@ -63,6 +63,27 @@ export function useGrantBucketPermission() {
});
}
export function useUpdateBucketQuotas() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
bucketName,
maxSize,
maxObjects,
}: {
bucketName: string;
maxSize: number | null;
maxObjects: number | null;
}) => bucketsApi.updateBucketQuotas(bucketName, { maxSize, maxObjects }),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucketName) });
toast.success('Quotas updated successfully');
},
});
}
export function useObjects(bucket: string, prefix?: string, enabled = true) {
return useQuery({
+16
View File
@@ -234,6 +234,22 @@ export const bucketsApi = {
);
return response.data.data;
},
updateBucketQuotas: async (
name: string,
payload: { maxSize: number | null; maxObjects: number | null }
) => {
// Map nulls to undefined so they are omitted from the JSON body —
// backend treats a missing field as "clear this quota".
const body: { maxSize?: number; maxObjects?: number } = {};
if (payload.maxSize !== null) body.maxSize = payload.maxSize;
if (payload.maxObjects !== null) body.maxObjects = payload.maxObjects;
const response = await api.put<ApiResponse<any>>(
`/v1/buckets/${encodeURIComponent(name)}/quotas`,
body
);
return response.data.data;
},
};
// Objects API
+33
View File
@@ -0,0 +1,33 @@
export type QuotaUnit = 'MB' | 'GB' | 'TB';
export const QUOTA_UNIT_BYTES: Record<QuotaUnit, number> = {
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024,
TB: 1024 * 1024 * 1024 * 1024,
};
// Convert a byte count to a {value, unit} pair using the largest unit that
// yields an integer. Falls back to GB if the value is 0 or doesn't divide
// evenly into any unit.
export function bytesToQuotaValue(bytes: number): { value: number; unit: QuotaUnit } {
const units: QuotaUnit[] = ['TB', 'GB', 'MB'];
for (const unit of units) {
const factor = QUOTA_UNIT_BYTES[unit];
if (bytes >= factor && bytes % factor === 0) {
return { value: bytes / factor, unit };
}
}
// Doesn't divide evenly — pick the largest unit where the value is >= 1,
// rounded for display. The user is free to change it.
for (const unit of units) {
const factor = QUOTA_UNIT_BYTES[unit];
if (bytes >= factor) {
return { value: Math.round((bytes / factor) * 100) / 100, unit };
}
}
return { value: 0, unit: 'GB' };
}
export function quotaValueToBytes(value: number, unit: QuotaUnit): number {
return Math.round(value * QUOTA_UNIT_BYTES[unit]);
}
+224 -3
View File
@@ -1,27 +1,128 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { AlertTriangle, Info } from 'lucide-react';
import { useBuckets, useDeleteBucket } from '@/hooks/useApi';
import { AlertTriangle, Gauge, Info } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useBuckets, useDeleteBucket, useUpdateBucketQuotas } from '@/hooks/useApi';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/ui/empty-state';
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Select, SelectOption } from '@/components/ui/select';
import { formatBytes } from '@/lib/file-utils';
import { formatDate as formatDateUtil } from '@/lib/utils';
import {
bytesToQuotaValue,
quotaValueToBytes,
QUOTA_UNIT_BYTES,
type QuotaUnit,
} from '@/lib/quota-utils';
const formatBytesOrDash = (n?: number) => (n == null ? '—' : formatBytes(n));
const formatDateOrDash = (iso?: string) => (iso ? formatDateUtil(iso) : '—');
const quotaFormSchema = z
.object({
maxSizeEnabled: z.boolean(),
maxSizeValue: z.string(),
maxSizeUnit: z.enum(['MB', 'GB', 'TB']),
maxObjectsEnabled: z.boolean(),
maxObjectsValue: z.string(),
})
.superRefine((data, ctx) => {
if (data.maxSizeEnabled) {
const n = Number(data.maxSizeValue);
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['maxSizeValue'],
message: 'Enter a positive whole number',
});
}
}
if (data.maxObjectsEnabled) {
const n = Number(data.maxObjectsValue);
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['maxObjectsValue'],
message: 'Enter a positive whole number',
});
}
}
});
type QuotaFormValues = z.infer<typeof quotaFormSchema>;
function deriveDefaults(quotas: { maxSize?: number; maxObjects?: number } | null | undefined): QuotaFormValues {
const size = quotas?.maxSize;
const objects = quotas?.maxObjects;
if (size != null) {
const { value, unit } = bytesToQuotaValue(size);
return {
maxSizeEnabled: true,
maxSizeValue: String(value),
maxSizeUnit: unit,
maxObjectsEnabled: objects != null,
maxObjectsValue: objects != null ? String(objects) : '',
};
}
return {
maxSizeEnabled: false,
maxSizeValue: '',
maxSizeUnit: 'GB',
maxObjectsEnabled: objects != null,
maxObjectsValue: objects != null ? String(objects) : '',
};
}
export function BucketSettings() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const navigate = useNavigate();
const { data: buckets = [], isLoading } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const deleteMutation = useDeleteBucket();
const updateQuotasMutation = useUpdateBucketQuotas();
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const defaults = useMemo(() => deriveDefaults(bucket?.quotas), [bucket?.quotas]);
const {
control,
register,
handleSubmit,
watch,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<QuotaFormValues>({
resolver: zodResolver(quotaFormSchema),
values: defaults,
});
const watched = watch();
const currentSize = bucket?.size ?? 0;
const currentObjects = bucket?.objectCount ?? 0;
const newMaxSizeBytes =
watched.maxSizeEnabled && watched.maxSizeValue !== '' && !Number.isNaN(Number(watched.maxSizeValue))
? quotaValueToBytes(Number(watched.maxSizeValue), watched.maxSizeUnit)
: null;
const newMaxObjects =
watched.maxObjectsEnabled && watched.maxObjectsValue !== '' && !Number.isNaN(Number(watched.maxObjectsValue))
? Number(watched.maxObjectsValue)
: null;
const sizeBelowCurrent =
newMaxSizeBytes !== null && bucket?.size != null && newMaxSizeBytes < currentSize;
const objectsBelowCurrent =
newMaxObjects !== null && bucket?.objectCount != null && newMaxObjects < currentObjects;
if (isLoading) {
return <div className="px-7 py-6 text-[13.5px] text-[var(--muted-foreground)]">Loading</div>;
}
@@ -48,6 +149,14 @@ export function BucketSettings() {
}
};
const onSubmit = handleSubmit(async (values) => {
const maxSize = values.maxSizeEnabled
? quotaValueToBytes(Number(values.maxSizeValue), values.maxSizeUnit)
: null;
const maxObjects = values.maxObjectsEnabled ? Number(values.maxObjectsValue) : null;
await updateQuotasMutation.mutateAsync({ bucketName: bucket.name, maxSize, maxObjects });
});
return (
<div className="space-y-6 px-7 py-6">
{/* Info */}
@@ -73,6 +182,118 @@ export function BucketSettings() {
</dl>
</section>
{/* Quotas */}
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
<header className="flex items-center gap-2 border-b border-[var(--border)] px-5 py-3">
<Gauge className="h-4 w-4 text-[var(--primary)]" />
<h2 className="text-[15px] font-semibold">Quotas</h2>
</header>
<form onSubmit={onSubmit} className="space-y-6 px-5 py-5">
{/* Max size row */}
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<Controller
control={control}
name="maxSizeEnabled"
render={({ field }) => (
<label className="flex items-center gap-2 text-[14px]">
<Switch checked={field.value} onCheckedChange={field.onChange} />
<span>Limit total size</span>
</label>
)}
/>
<Input
type="number"
min={1}
step={1}
className="w-32"
disabled={!watched.maxSizeEnabled}
{...register('maxSizeValue')}
/>
<Controller
control={control}
name="maxSizeUnit"
render={({ field }) => (
<Select
value={field.value}
onChange={(v) => field.onChange(v as QuotaUnit)}
disabled={!watched.maxSizeEnabled}
className="w-24"
>
{(Object.keys(QUOTA_UNIT_BYTES) as QuotaUnit[]).map((u) => (
<SelectOption key={u} value={u}>
{u}
</SelectOption>
))}
</Select>
)}
/>
</div>
<p className="text-[13px] text-[var(--muted-foreground)]">
Current: {formatBytesOrDash(bucket.size)}
</p>
{errors.maxSizeValue && (
<p className="text-[13px] text-[var(--destructive)]">{errors.maxSizeValue.message}</p>
)}
{sizeBelowCurrent && (
<p className="text-[13px] text-amber-600 dark:text-amber-400">
Current size ({formatBytes(currentSize)}) exceeds this limit. New writes will be rejected.
</p>
)}
</div>
{/* Max objects row */}
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<Controller
control={control}
name="maxObjectsEnabled"
render={({ field }) => (
<label className="flex items-center gap-2 text-[14px]">
<Switch checked={field.value} onCheckedChange={field.onChange} />
<span>Limit object count</span>
</label>
)}
/>
<Input
type="number"
min={1}
step={1}
className="w-40"
disabled={!watched.maxObjectsEnabled}
{...register('maxObjectsValue')}
/>
</div>
<p className="text-[13px] text-[var(--muted-foreground)]">
Current: {bucket.objectCount != null ? bucket.objectCount.toLocaleString() : '—'}
</p>
{errors.maxObjectsValue && (
<p className="text-[13px] text-[var(--destructive)]">{errors.maxObjectsValue.message}</p>
)}
{objectsBelowCurrent && (
<p className="text-[13px] text-amber-600 dark:text-amber-400">
Current object count ({currentObjects.toLocaleString()}) exceeds this limit. New writes will be rejected.
</p>
)}
</div>
<div className="flex items-center gap-3 border-t border-[var(--border)] pt-4">
<Button type="submit" disabled={!isDirty || isSubmitting}>
Save changes
</Button>
<Button
type="button"
variant="ghost"
onClick={() => reset(defaults)}
disabled={!isDirty || isSubmitting}
>
Reset
</Button>
</div>
</form>
</section>
{/* Danger zone */}
<section className="rounded-xl border border-[var(--danger-border)] bg-[var(--card)]">
<header className="border-b border-[var(--danger-border)] px-5 py-3">
+6
View File
@@ -1,4 +1,9 @@
// Bucket types
export interface BucketQuotas {
maxSize?: number;
maxObjects?: number;
}
export interface Bucket {
name: string;
creationDate: string;
@@ -10,6 +15,7 @@ export interface Bucket {
indexDocument: string;
errorDocument?: string;
};
quotas?: BucketQuotas | null;
}
export interface BucketDetails extends Bucket {