diff --git a/backend/internal/handlers/buckets.go b/backend/internal/handlers/buckets.go index 8963bce..9ec87b4 100644 --- a/backend/internal/handlers/buckets.go +++ b/backend/internal/handlers/buckets.go @@ -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)) +} diff --git a/backend/internal/handlers/buckets_test.go b/backend/internal/handlers/buckets_test.go index 754a3f9..722776a 100644 --- a/backend/internal/handlers/buckets_test.go +++ b/backend/internal/handlers/buckets_test.go @@ -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) + } +} diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go index 73ee721..2ca8364 100644 --- a/backend/internal/models/requests.go +++ b/backend/internal/models/requests.go @@ -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"` +} diff --git a/backend/internal/models/responses.go b/backend/internal/models/responses.go index c0296fa..29f49b5 100644 --- a/backend/internal/models/responses.go +++ b/backend/internal/models/responses.go @@ -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 diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index f4d6be6..cea8275 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -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 diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx index 1ef7f1f..72fd137 100644 --- a/frontend/src/components/ui/switch.tsx +++ b/frontend/src/components/ui/switch.tsx @@ -20,7 +20,7 @@ const Switch = React.forwardRef( />
( )} >
diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index af4fc46..52af79b 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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({ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b5ff6ed..a8d799f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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>( + `/v1/buckets/${encodeURIComponent(name)}/quotas`, + body + ); + return response.data.data; + }, }; // Objects API diff --git a/frontend/src/lib/quota-utils.ts b/frontend/src/lib/quota-utils.ts new file mode 100644 index 0000000..f479510 --- /dev/null +++ b/frontend/src/lib/quota-utils.ts @@ -0,0 +1,33 @@ +export type QuotaUnit = 'MB' | 'GB' | 'TB'; + +export const QUOTA_UNIT_BYTES: Record = { + 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]); +} diff --git a/frontend/src/pages/BucketSettings.tsx b/frontend/src/pages/BucketSettings.tsx index 789ea42..55e489e 100644 --- a/frontend/src/pages/BucketSettings.tsx +++ b/frontend/src/pages/BucketSettings.tsx @@ -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; + +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({ + 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
Loading…
; } @@ -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 (
{/* Info */} @@ -73,6 +182,118 @@ export function BucketSettings() { + {/* Quotas */} +
+
+ +

Quotas

+
+ +
+ {/* Max size row */} +
+
+ ( + + )} + /> + + ( + + )} + /> +
+

+ Current: {formatBytesOrDash(bucket.size)} +

+ {errors.maxSizeValue && ( +

{errors.maxSizeValue.message}

+ )} + {sizeBelowCurrent && ( +

+ Current size ({formatBytes(currentSize)}) exceeds this limit. New writes will be rejected. +

+ )} +
+ + {/* Max objects row */} +
+
+ ( + + )} + /> + +
+

+ Current: {bucket.objectCount != null ? bucket.objectCount.toLocaleString() : '—'} +

+ {errors.maxObjectsValue && ( +

{errors.maxObjectsValue.message}

+ )} + {objectsBelowCurrent && ( +

+ Current object count ({currentObjects.toLocaleString()}) exceeds this limit. New writes will be rejected. +

+ )} +
+ +
+ + +
+
+
+ {/* Danger zone */}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a3518bf..ebbf1e7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 {