mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
feat(backend,frontend): enable quotas support in bucket settings (#64)
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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