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
+72
View File
@@ -74,6 +74,7 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
Size: &detailedInfo.Bytes,
WebsiteAccess: detailedInfo.WebsiteAccess,
WebsiteConfig: detailedInfo.WebsiteConfig,
Quotas: detailedInfo.Quotas,
}
buckets = append(buckets, bucketInfo)
@@ -419,3 +420,74 @@ func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
return c.JSON(models.SuccessResponse(result))
}
// UpdateBucketQuotas updates the quota settings for a bucket
//
// @Summary Update bucket quotas
// @Description Sets or clears the max size (bytes) and max object count quotas for a bucket. A null field clears that quota (unlimited).
// @Tags Buckets
// @Accept json
// @Produce json
// @Param name path string true "Name of the bucket"
// @Param request body models.UpdateBucketQuotasRequest true "Quota configuration"
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Quotas updated"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update bucket"
// @Router /api/v1/buckets/{name}/quotas [put]
func (h *BucketHandler) UpdateBucketQuotas(c fiber.Ctx) error {
ctx := c.Context()
bucketName := c.Params("name")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
var req models.UpdateBucketQuotasRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
if req.MaxSize != nil && *req.MaxSize <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "maxSize must be greater than 0"),
)
}
if req.MaxObjects != nil && *req.MaxObjects <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "maxObjects must be greater than 0"),
)
}
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
}
if bucketInfo == nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
)
}
updateReq := models.UpdateBucketRequest{
Quotas: &models.BucketQuotas{
MaxSize: req.MaxSize,
MaxObjects: req.MaxObjects,
},
}
result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update bucket quotas: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(result))
}
+194
View File
@@ -29,6 +29,7 @@ func newBucketsTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
app.Delete("/buckets/:name", h.DeleteBucket)
app.Post("/buckets/:name/permissions", h.GrantBucketPermission)
app.Put("/buckets/:name/website", h.UpdateBucketWebsite)
app.Put("/buckets/:name/quotas", h.UpdateBucketQuotas)
return app, admin
}
@@ -426,3 +427,196 @@ func TestUpdateBucketWebsite_Disable(t *testing.T) {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_SetBoth(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
if req.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil")
}
if req.Quotas.MaxSize == nil || *req.Quotas.MaxSize != 53687091200 {
t.Errorf("MaxSize = %v, want 53687091200", req.Quotas.MaxSize)
}
if req.Quotas.MaxObjects == nil || *req.Quotas.MaxObjects != 10000 {
t.Errorf("MaxObjects = %v, want 10000", req.Quotas.MaxObjects)
}
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
}
maxSize := int64(53687091200)
maxObjects := int64(10000)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize, MaxObjects: &maxObjects})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_SetOnlyMaxSize(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
if req.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil")
}
if req.Quotas.MaxSize == nil || *req.Quotas.MaxSize != 1024 {
t.Errorf("MaxSize = %v, want 1024", req.Quotas.MaxSize)
}
if req.Quotas.MaxObjects != nil {
t.Errorf("MaxObjects = %v, want nil", req.Quotas.MaxObjects)
}
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
}
maxSize := int64(1024)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_SetOnlyMaxObjects(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
if req.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil")
}
if req.Quotas.MaxObjects == nil || *req.Quotas.MaxObjects != 500 {
t.Errorf("MaxObjects = %v, want 500", req.Quotas.MaxObjects)
}
if req.Quotas.MaxSize != nil {
t.Errorf("MaxSize = %v, want nil", req.Quotas.MaxSize)
}
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
}
maxObjects := int64(500)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxObjects: &maxObjects})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_ClearBoth(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
if req.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil (envelope must be present so service clears both)")
}
if req.Quotas.MaxSize != nil {
t.Errorf("MaxSize = %v, want nil", req.Quotas.MaxSize)
}
if req.Quotas.MaxObjects != nil {
t.Errorf("MaxObjects = %v, want nil", req.Quotas.MaxObjects)
}
return &models.GarageBucketInfo{ID: id}, nil
}
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_RejectsZeroMaxSize(t *testing.T) {
app, _ := newBucketsTestApp(t)
zero := int64(0)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &zero})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_RejectsNegativeMaxObjects(t *testing.T) {
app, _ := newBucketsTestApp(t)
neg := int64(-1)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxObjects: &neg})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_NotFound(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return nil, nil
}
maxSize := int64(1024)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize})
req := httptest.NewRequest(http.MethodPut, "/buckets/missing/quotas", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_MalformedJSONReturns400(t *testing.T) {
app, _ := newBucketsTestApp(t)
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader([]byte("{not json")))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
+8
View File
@@ -29,3 +29,11 @@ type UpdateBucketWebsiteRequest struct {
IndexDocument string `json:"indexDocument,omitempty"`
ErrorDocument string `json:"errorDocument,omitempty"`
}
// UpdateBucketQuotasRequest represents a request to update bucket quota settings.
// A nil field means "clear this quota" (unlimited). A non-nil field must be > 0;
// Garage rejects 0.
type UpdateBucketQuotasRequest struct {
MaxSize *int64 `json:"maxSize,omitempty"`
MaxObjects *int64 `json:"maxObjects,omitempty"`
}
+1
View File
@@ -47,6 +47,7 @@ type BucketInfo struct {
Region string `json:"region,omitempty"`
WebsiteAccess bool `json:"websiteAccess"`
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
Quotas *BucketQuotas `json:"quotas,omitempty"`
}
// BucketListResponse represents a list of buckets
+1
View File
@@ -64,6 +64,7 @@ func SetupRoutes(
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions
buckets.Put("/:name/website", bucketHandler.UpdateBucketWebsite) // Update bucket website configuration
buckets.Put("/:name/quotas", bucketHandler.UpdateBucketQuotas) // Update bucket quotas
}
// Object routes
+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 {