mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-31 01:09:25 +00:00
feat(backend,frontend): enable quotas support in bucket settings (#64)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user